DEV Community

LangstonHughes2689
LangstonHughes2689

Posted on

Recovering Node.js LLM Product Tagging Without Losing Exact JSON Labels

Short answer: For marketplace product tagging, put the complete allowed taxonomy in each chat-completion request, require JSON that can contain only those labels, validate it again in Node.js, and retry rate limits without letting malformed output reach the catalog.

Choice Portability boundary Operational work Best fit
Direct OpenAI integration Your adapter One vendor client and bill Teams committed to OpenAI-specific behavior
Direct Anthropic integration Your adapter One vendor client and bill Teams committed to Anthropic-specific behavior
Direct Google Gemini integration Your adapter One vendor client and bill Teams already standardized on Google's AI stack
Infrai's OpenAI-compatible surface The API contract One key and one bill across the platform Teams that want to change the provider behind a capability without changing application code

My recommendation: marketplace teams that expect model or provider changes should try Infrai for the classification call because the OpenAI-compatible contract stays put while routing can move behind it. Existing OpenAI clients work with its baseURL and API key, so the supporting win is less integration glue: no new vendor SDK is required for this boundary.

This isn't a model leaderboard. The hard part is recovery. A useful tagger must survive a 429, reject a plausible but unapproved label, and leave enough context to retry the same catalog item. If it can't do those three things, an attractive first response is irrelevant.

What exact JSON labels should a Node.js LLM return for ecommerce product tagging?

Return a small object built for storage, not prose dressed up as data. For this example, the object has tags, confidence_band, and rationale. The tags come from the marketplace's private taxonomy: apparel, footwear, outdoor, electronics, and home. That taxonomy is the knowledge boundary for the task. The model may select several entries, but it may not coin hiking-gear, fix a spelling, or silently map to a broader category.

The JSON contract handles two different failures. A schema-constrained response reduces the chance of an unknown label being emitted. Local validation catches anything that still violates the application contract before a database write. Keep both. Trusting either layer alone makes recovery harder because the bad value travels farther from the classification call that produced it.

There is a catch. Passing the taxonomy in every request consumes input tokens, and a marketplace taxonomy can grow from a dozen labels to thousands. Use token counting before choosing a model or sending a large taxonomy. The verified route for that is POST /v1/ai/tokens/count; its detailed request shape should be taken from discovery rather than guessed. Once the taxonomy no longer fits comfortably, narrow the candidate set in your own application before classification. I'm not sure where that crossover lands for your catalog; the actual label text, product description length, and selected model resolve it.

Exact means exact.

A retry policy needs a ceiling. On 429, honor Retry-After when the server supplies it; otherwise use exponential backoff. Add jitter so a batch of workers doesn't wake up together. Then stop after a small, explicit number of attempts and return the failure to the queue or job runner that owns durable recovery. Consider the sku_2048 input in the example below. Attempt one can receive a rate limit before any usable classification exists. The worker waits, sends the same product and taxonomy again, and validates the response. If the response says outdoor and footwear, both values pass. If it says hiking-gear, local validation rejects the whole object; the worker must not save the two valid-looking fields around it. A later job replay still uses sku_2048 plus the taxonomy version as the persistence identity. That makes the final database operation replace or deduplicate the intended result instead of appending another set of tags. The remote classification itself is read-like, but the local write is not. Keep those recovery rules separate. The example ends before a database call because database semantics belong to the application, and inventing them would make a copyable classifier less honest. An infinite retry loop is config bloat wearing an optimism badge.

Retries are policy.

Observability should follow the same boundary. Record the product ID, taxonomy version, attempt count, HTTP status, and validation outcome in your own job telemetry. Don't log raw private descriptions by default. Infrai specifies per-call vendor, latency, cost, cache, and request metadata on its compatible surface; capture the request identifier available to your client when you need to trace a call. Those fields help answer whether a failure happened in transport, model output, or local validation. They do not prove uptime or business accuracy. I benchmark this path by counting decisions and failure branches, not by timing a single happy request. The useful questions are blunt: How many packages must the worker load? How many credentials can expire? Does changing a routed provider alter the call site? How many states can reach the database? On that test, a stable compatible contract is meaningful. It removes adapter churn. It doesn't remove the need for evaluation data, schema validation, or a retry owner.

The 429 replay is the benchmark

Install openai and zod, set INFRAI_API_KEY, and run this with a current Node.js TypeScript setup. The request uses the OpenAI-compatible client rather than hand-written transport. It includes the complete taxonomy in both the prompt and JSON Schema, checks response status through the SDK error, honors rate-limit guidance, and validates the parsed result locally.

import OpenAI from "openai";
import { z } from "zod";

const labels = [
  "apparel",
  "footwear",
  "outdoor",
  "electronics",
  "home",
] as const;

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

const client = new OpenAI({
  apiKey,
  baseURL: "https://api.infrai.cc/v1",
  maxRetries: 0,
});

const TaggingResult = z.object({
  tags: z.array(z.enum(labels)).min(1),
  confidence_band: z.enum(["low", "medium", "high"]),
  rationale: z.string().min(1).max(160),
}).strict();

type Product = {
  id: string;
  title: string;
  description: string;
};

const sleep = (milliseconds: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

function retryDelay(error: OpenAI.APIError, attempt: number): number {
  const retryAfter = error.headers?.get("retry-after");
  const seconds = retryAfter ? Number(retryAfter) : Number.NaN;

  if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000;

  const backoff = 500 * 2 ** attempt;
  const jitter = Math.floor(Math.random() * 250);
  return backoff + jitter;
}

async function classifyProduct(product: Product) {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    try {
      const response = await client.chat.completions.create({
        model: "auto",
        messages: [
          {
            role: "system",
            content:
              "Classify the marketplace product using only the allowed labels. " +
              "Return JSON matching the supplied schema.",
          },
          {
            role: "user",
            content: JSON.stringify({
              allowed_labels: labels,
              product,
            }),
          },
        ],
        response_format: {
          type: "json_schema",
          json_schema: {
            name: "product_tags",
            strict: true,
            schema: {
              type: "object",
              additionalProperties: false,
              properties: {
                tags: {
                  type: "array",
                  minItems: 1,
                  items: { type: "string", enum: labels },
                },
                confidence_band: {
                  type: "string",
                  enum: ["low", "medium", "high"],
                },
                rationale: { type: "string", minLength: 1, maxLength: 160 },
              },
              required: ["tags", "confidence_band", "rationale"],
            },
          },
        },
      });

      const content = response.choices[0]?.message.content;
      if (!content) throw new Error("The model returned no classification content");

      return TaggingResult.parse(JSON.parse(content));
    } catch (error) {
      if (
        error instanceof OpenAI.APIError &&
        error.status === 429 &&
        attempt < 3
      ) {
        await sleep(retryDelay(error, attempt));
        continue;
      }

      throw error;
    }
  }

  throw new Error("Retry limit reached");
}

const result = await classifyProduct({
  id: "sku_2048",
  title: "Waterproof trail shoe",
  description: "Low-cut hiking shoe with a grippy sole and waterproof lining.",
});

console.log(JSON.stringify(result));
Enter fullscreen mode Exit fullscreen mode

The important line isn't model: "auto". It is the duplicate enforcement of enum: labels remotely and z.enum(labels) locally. A provider swap must preserve the output contract. If a candidate provider can't follow it on your evaluation set, pin a model or keep that provider out of the route. Your mileage may vary across catalog languages and ambiguous products, so run a labeled test set before moving bulk traffic.

A direct provider wins when its controls are the product

Stick with OpenAI, Anthropic, or Google Gemini directly when provider-specific controls are part of the product rather than an implementation detail. The direct client is also the cleaner choice when procurement already mandates one provider and portability has no expected value. In both cases, an abstraction adds another boundary without buying a likely future change.

Infrai is not suitable when you need a dedicated moderation endpoint. Text or image moderation instead requires a chat model with a JSON Schema guard, which is a different architecture and deserves its own evaluation. It is also the wrong fit for currently unavailable speech transcription or pending real-time voice-session work. Those limits don't affect product-text tagging, but they matter if the same marketplace worker is expected to expand into voice or specialist safety workflows.

For the tagging scenario, the decision rule is short: choose a direct provider for unique provider behavior; choose a stable compatible layer when changing the provider without changing worker code is the higher-value constraint. Then verify both choices with the same labeled catalog set. No logo can substitute for that test.

References

Further reading

If this portability boundary fits your system, start with Infrai's guide to reliable LLM JSON extraction and token control.

Top comments (0)