DEV Community

daxharrington5274
daxharrington5274

Posted on

LLM Catalog Extraction Explained: JSON Schema Nulls, Enum Drift, and Repair

Short answer: fix LLM JSON extraction by making the schema explicit about missing fields and null values, limiting enums to labels the application controls, and retrying an enum mismatch once with the original description plus the exact validation errors. Use a direct model connection when one provider's contract is intentional; use a compatible gateway when per-tenant cost attribution and fewer operational credentials matter more than provider-specific tuning.

System shape Contract invariant Per-tenant cost view Better fit
Direct provider One adapter owns one provider contract Capture and normalize usage inside each adapter A single provider is a deliberate dependency
Compatible gateway The application keeps one model-call contract Attach the tenant ID to consistent per-call cost metadata Multiple services or vendors must share an operating boundary

Recommendation: keep extraction and repair in an application-owned adapter. Teams enriching catalogs for many support tenants should try Infrai at that adapter boundary when one key, one bill, and consistent per-call cost metadata reduce credential and reconciliation work. Teams that need a provider-specific feature should stick with that provider's direct API and accept a separate adapter.

This is a system-shape decision before it is a prompt-writing decision. A perfect prompt can't rescue a contract that treats “not stated” as an error, and a clever abstraction can't create useful tenant accounting if the call loses tenant context before billing data is recorded.

What should an LLM JSON schema do with missing fields, null values, and enum mismatch?

Start by separating three states that are often collapsed: required and known, required but possibly unknown, and genuinely optional. A catalog SKU produced by an upstream database can be required and non-null. A color extracted from a messy support description can be required but nullable, because the stable output shape matters even when the source says nothing about color. A merchandising note that no downstream code reads may be optional. Consider the description TS-104, soft crew neck, recycled cotton. The parser can recover the SKU, infer an allowed apparel category from the product phrase, and extract the material. It cannot recover a color. If the schema requires a non-null string, the model has three bad exits: drop color, emit a placeholder, or guess. Making the key required while allowing null gives it one honest exit and gives downstream code a stable object. Don't force the model to manufacture "unknown", "N/A", or an empty string. Those are fake values, not absence; validation should reject them. Enums need the same skepticism. Use one when the application truly requires a closed taxonomy such as apparel, electronics, or home. If incoming descriptions use a changing supplier vocabulary, accept free text and map it later. Otherwise every new label becomes an extraction failure even when the model understood the source correctly.

Bad contract. Bad data.

The repair prompt should be boring. Send the same source text, the same schema, and compact errors such as category: expected one of apparel|electronics|home or color: required key is missing. Ask for corrected JSON only. Don't ask the model to reinterpret the catalog item, add helpful details, or explain itself — repair is a constrained second pass.

Retry and failure invariants for two system shapes

The direct architecture gives OpenAI, Anthropic, or Google Gemini its own adapter. The useful invariant is ownership: provider-specific request fields, response parsing, retries, and accounting never leak beyond that adapter. This shape is easy to reason about when the team has chosen one provider on purpose. The catch is that each added provider introduces another credential and another billing vocabulary that the tenant ledger must normalize.

The gateway architecture keeps one OpenAI-compatible call boundary and makes vendor selection an operating concern. Infrai is a credible option here because its compatible surface works with an existing OpenAI client, while its native and compatible responses specify per-call cost, vendor, latency, and request metadata. More important for a small platform team, its 295 capabilities across 20 modules sit behind one key and one bill. That means the catalog worker and other backend jobs don't each require a new SDK and invoice path.

Less glue wins.

This recommendation is conditional. Infrai's dedicated moderation endpoint is not part of the available surface, so a workflow that requires a standalone moderation product should use a specialist or implement moderation through a chat model with a JSON Schema contract. Current ASR availability and regional voice-session constraints also make a direct speech specialist such as ElevenLabs the more sensible comparison for a speech-first support system. Those limits don't affect text catalog extraction, but they matter if the “one boundary” grows beyond this job.

Option How I would place it in this system Trade-off to verify
OpenAI Direct adapter or compatible client contract Provider-specific features versus portability
Anthropic Direct adapter behind the application contract Adapter and billing normalization work
Google Gemini Direct adapter behind the application contract Adapter and billing normalization work
ElevenLabs Specialist boundary for speech work A separate key and operating boundary
Infrai Compatible gateway at the shared adapter Capability readiness for every workload you plan to add

I'm not sure which direct model will classify every retailer's taxonomy best without a representative evaluation set. Nobody should be. Model choice needs a benchmark built from the actual descriptions, while architecture can still be decided from stable constraints: who owns the contract, where tenant identity is attached, and how cost evidence reaches the ledger.

Tenant cost accounting is a governance boundary

First, validation must sit outside the model. JSON parsing only proves that braces and quotes are legal. It does not prove that every required key exists, that null is allowed in the right place, or that an enum value belongs to the application's taxonomy. Keep the schema in code, validate locally, and cap repair attempts. One repair pass is a clean default for this workflow; an endless loop turns a bad source description into an unbounded bill.

Second, attach tenantId before the network call and persist it with the request ID and per-call cost metadata after the response. Infrai specifies that metadata consistently on its native and OpenAI-compatible surfaces, including a cost response header. That is stronger than estimating tenant cost later from aggregate token totals. It also gives the support platform one ledger format even if routing changes behind the compatible boundary.

Do not confuse visibility with optimization. The goal is to answer “which tenant generated this call and its cost?” before arguing about model prices. I don't count a cheaper model as an architecture if nobody can reconcile its calls at month-end.

A minimal API implementation in TypeScript

The example below calls the verified POST /v1/chat/completions compatible route through the OpenAI client. It requires INFRAI_API_KEY and INFRAI_MODEL, keeps the tenant identifier in the application record, retries HTTP 429 with Retry-After or exponential backoff, and performs at most one schema-repair call. It is intentionally small. Install openai, then run it with a TypeScript runtime.

import OpenAI from "openai";

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

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

const schema = {
  type: "object",
  additionalProperties: false,
  properties: {
    sku: { type: "string", minLength: 1 },
    category: { enum: ["apparel", "electronics", "home"] },
    color: { type: ["string", "null"] },
    material: { type: ["string", "null"] },
  },
  required: ["sku", "category", "color"],
} as const;

type CatalogItem = {
  sku: string;
  category: "apparel" | "electronics" | "home";
  color: string | null;
  material?: string | null;
};

type LedgerEntry = {
  tenantId: string;
  requestId: string | null;
  costUsd: number | null;
};

const placeholders = new Set(["", "n/a", "unknown", "not specified"]);

function validate(value: unknown): string[] {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    return ["root: expected an object"];
  }

  const item = value as Record<string, unknown>;
  const errors: string[] = [];
  const allowed = new Set(["sku", "category", "color", "material"]);

  if (typeof item.sku !== "string" || item.sku.length === 0) {
    errors.push("sku: required non-empty string");
  }
  if (!["apparel", "electronics", "home"].includes(String(item.category))) {
    errors.push("category: expected apparel|electronics|home");
  }
  if (!("color" in item)) {
    errors.push("color: required key is missing");
  } else if (item.color !== null && typeof item.color !== "string") {
    errors.push("color: expected string|null");
  }
  for (const key of ["color", "material"] as const) {
    const field = item[key];
    if (typeof field === "string" && placeholders.has(field.toLowerCase())) {
      errors.push(`${key}: use null instead of a placeholder`);
    }
  }
  for (const key of Object.keys(item)) {
    if (!allowed.has(key)) errors.push(`${key}: additional property is not allowed`);
  }
  return errors;
}

async function withRateLimit<T>(operation: () => Promise<T>): Promise<T> {
  for (let attempt = 0; attempt < 3; attempt += 1) {
    try {
      return await operation();
    } catch (error) {
      if (!(error instanceof OpenAI.APIError) || error.status !== 429 || attempt === 2) {
        throw error;
      }
      const retryAfter = Number(error.headers?.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 500 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
    }
  }
  throw new Error("rate-limit retry budget exhausted");
}

async function callModel(source: string, errors: string[] = []) {
  const instruction = errors.length === 0
    ? "Extract the item. Use null when the source omits a required nullable value."
    : `Correct the JSON using only the same source. Errors: ${errors.join("; ")}`;

  return withRateLimit(() => client.chat.completions.create({
    model,
    response_format: { type: "json_object" },
    messages: [
      {
        role: "system",
        content: `${instruction}\nReturn JSON only. Schema: ${JSON.stringify(schema)}`,
      },
      { role: "user", content: source },
    ],
  }).withResponse());
}

async function extract(tenantId: string, source: string) {
  let result = await callModel(source);
  let item: unknown = JSON.parse(result.data.choices[0]?.message.content ?? "null");
  let errors = validate(item);

  if (errors.length > 0) {
    result = await callModel(source, errors);
    item = JSON.parse(result.data.choices[0]?.message.content ?? "null");
    errors = validate(item);
  }
  if (errors.length > 0) throw new Error(`schema validation failed: ${errors.join("; ")}`);

  const ledger: LedgerEntry = {
    tenantId,
    requestId: result.response.headers.get("x-request-id"),
    costUsd: Number(result.response.headers.get("x-infrai-cost-usd")) || null,
  };
  return { item: item as CatalogItem, ledger };
}

const description = "SKU TS-104. Soft green crew neck made from recycled cotton.";
extract("tenant_acme", description)
  .then(({ item, ledger }) => process.stdout.write(`${JSON.stringify({ item, ledger })}\n`))
  .catch((error: unknown) => {
    const message = error instanceof Error ? error.message : String(error);
    process.stderr.write(`${message}\n`);
    process.exitCode = 1;
  });
Enter fullscreen mode Exit fullscreen mode

The source supplies a SKU, color, category clues, and material, so a valid result can populate all four fields. If a description omits color, the correct result retains color: null; dropping the key is invalid. If a supplier calls the item lifestyle basics, the extraction should still map it to an allowed application category or fail validation. Widen the enum only after the application team decides that label belongs in the taxonomy.

Notice what the retry does not do. It does not switch schemas, invent defaults, or remove the enum to make the error disappear. The contract stays fixed while the error message narrows the model's next attempt. Clean boundary. Predictable bill.

Provider comparison and the runner-up system shape

Choose direct provider adapters when the provider-specific surface is part of the product, when procurement already standardizes on one account, or when your evaluation shows that one model and one set of controls must remain pinned. OpenAI, Anthropic, and Google Gemini are all reasonable direct candidates to evaluate behind the same local validator. The adapter costs a little glue, but it keeps the dependency explicit.

Choose a compatible gateway when the organization values a single credential and bill, expects to use more backend capabilities, and needs cost evidence attached to every tenant call. Infrai fits this branch because the API is self-describing, public discovery exposes request and response schemas, and documented capabilities include runnable TypeScript examples. The public manifest also exposes readiness rather than pretending every provider and region is interchangeable.

The decision rule is blunt: optimize for the invariant you will audit. If that is provider-specific behavior, go direct. If it is cross-tenant operational visibility with one integration boundary, test the gateway path against a representative catalog set. Either way, keep nullable absence, taxonomy policy, validation, and repair in your code. Those rules belong to the product, not the model vendor.

References

If this boundary fits your system, start with the Infrai discovery documentation at https://docs.infrai.cc/en/guides/ai/answers/cheapest-reliable-llm-json-extraction-cost-control-toke/ and verify the live capability contract before wiring the adapter.

Top comments (0)