Skip to content

Evaluation and modelsGuide

Structured outputs across providers: JSON schema reliability

A 200 response is not a parsed object. Structured outputs fail by truncation, refusal, dropped parameters, unsupported schema keywords and plain wrong values — and each provider enforces a different subset of JSON Schema. A guide to measuring and containing it.

By INFRO Editorial

Published

9 min read

Stacked translucent isometric plates joined by glowing amber connectors on a dark ground, representing layers of structured-output validation

In short

Schema-constrained decoding removes most syntax errors on providers that support it, but not truncation, refusals, unsupported keywords, silently dropped parameters or wrong values. Write schemas to the subset every provider in your chain enforces, validate every response client-side, classify each failure, and measure parse and schema pass rates per model and provider before you fail over between them.

Key takeaways

  • Constrained decoding makes output match a schema; it does not stop truncation, refusals or wrong values, so every response still needs client-side validation.
  • Each provider enforces a different JSON Schema subset. Anthropic, for example, documents no support for minimum, maximum or pattern, so put those rules in your validator.
  • Check finish_reason before parsing. Truncated JSON is fixed with a higher token limit, not a retry.
  • A fallback to a provider that ignores or drops the schema turns a reliable path into prose. Qualify every model in the chain for the mode you use.
  • Classify failures into transport, dropped parameter, truncation, refusal, syntax, schema and semantic, and track each rate per model and provider.

The most expensive structured-output bug is the one that returns 200 OK. The request succeeded, the provider billed it, and the payload is truncated mid-object, or is prose because the serving provider ignored your schema, or is perfectly shaped JSON with a date in the wrong year. None of those raise an HTTP error. All of them reach the code that trusted the response.

Schema-constrained decoding solved the syntax problem for the providers that offer it. It did not solve reliability, and the gap widens as soon as more than one provider can serve a request.

What each provider actually enforces

Three providers, three request shapes, three schema subsets.

Vendor docs, 2026-09-29OpenAIAnthropicGoogle Gemini
Request fieldresponse_format json_schema, strictoutput_config.format json_schemaResponse JSON schema with mime type application/json
Schema enforced●during decoding●constrained sampling◐valid JSON; validate values
Numeric / string boundsNot stated here○minimum, maximum, pattern and length bounds unsupported●minimum, maximum; minItems, maxItems
First-use latencyExtra on first request per schemaGrammar compiled, cached 24 hNot stated
Refusal signalrefusal fieldstop_reasonNot stated
Through INFRO●forwarded unchanged○response_format dropped●forwarded on the OpenAI-compatible endpoint
Structured-output modes as each vendor documents them, 2026-09-29, and what happens to the request through INFRO. "Not stated" means the page did not say.

OpenAI. Structured Outputs constrains the response to a supplied JSON Schema, and OpenAI distinguishes it from JSON mode: both produce valid JSON, but only Structured Outputs ensures schema adherence1. It supports much, not all, of JSON Schema; the first request with a new schema has extra latency while the schema is processed; a safety refusal arrives in a separate refusal field instead of your object; and a response cut off by the token limit is incomplete rather than invalid, which you must handle1.

Anthropic. Claude constrains output with compiled grammars, through output_config.format for JSON responses and strict: true for tool inputs; the older output_format parameter is deprecated2. The documented subset excludes recursive schemas, numeric constraints (minimum, maximum, multipleOf), string constraints (minLength, maxLength, pattern) and external $ref; additionalProperties must be false, and array minItems accepts only 0 or 1. Compiled grammars are cached for 24 hours from last use, and changing only name or description does not invalidate the cache. A response that stops for max_tokens can be incomplete JSON2.

Google Gemini. Gemini takes a JSON Schema with the application/json MIME type, supports a subset that does include minimum, maximum, minItems, maxItems and prefixItems, warns that very large or deeply nested schemas may be rejected, and tells you to validate values in your application3. Its OpenAI-compatible endpoint also documents structured output4.

The consequence: a schema that uses pattern or maximum is enforced on one provider and not on another. Write schemas to the intersection, and enforce everything else in your own validator.

A taxonomy of failures

Treat "the JSON was bad" as seven different failures, because each has a different fix.

  1. 1

    Transport
    Timeout, 429, 5xx. Not a structured-output problem; retry policy handles it.
  2. 2

    Parameter dropped or refused
    The provider ignored or rejected the schema. A 400 carries the provider's message; a drop returns prose.
  3. 3

    Truncation
    finish_reason length or stop_reason max_tokens. The JSON is cut off; raise the limit, do not retry as is.
  4. 4

    Refusal
    The model declined. The payload is a refusal, not your object.
  5. 5

    Syntax
    Not parseable JSON — possible in JSON mode or when nothing enforced a schema.
  6. 6

    Schema
    Parses, but a key, type or enum is wrong.
  7. 7

    Semantic
    Valid shape, wrong value: a date in the wrong year, a total that does not add up.
Seven failure classes, from the network to the meaning of a field. Classify every failed response into exactly one.
ClassSignalFix
TransportException, 429, 5xx, timeoutRetry policy and failover; not a schema problem
Parameter dropped or refused400 with the provider's message; or prose where JSON was expectedChange the schema or the provider; do not retry
Truncationfinish_reason lengthRaise max_tokens or shrink the object
RefusalA refusal field or refusal textHandle as a product outcome
Syntaxjson.loads failsOne repair attempt; investigate if frequent
SchemaParses; validator rejectsOne repair attempt; tighten descriptions
SemanticValidates; a business rule failsRule-specific handling; review the prompt
The failure classes and what to do about each.

Two classes cause most production confusion. Truncation looks like a syntax error but is fixed by a token budget, never by retrying the same request. Dropped parameters look like a model misbehaving but are a routing problem: the request reached a provider that did not apply your schema.

Designing schemas that survive a provider change

  • Make every property required, and express optional fields as a nullable type such as ["string", "null"]. OpenAI's examples and Anthropic's rules both expect this shape12.
  • Set additionalProperties: false on every object; Anthropic requires it2.
  • Keep numeric ranges, string lengths and patterns out of the schema you send, or accept that they are enforced only on some providers. Put them in your validator either way.
  • Avoid recursive schemas and complex values inside enum2.
  • Use description on fields. It steers content without adding prompt text, and on Anthropic changing it does not force a grammar recompile2.
  • Keep the object small enough to finish within max_tokens with a margin. Long arrays are the usual cause of truncation.

A schema test harness

Measure before you trust. The harness below runs a set of inputs against each model or route, classifies every response into the taxonomy, and prints rates you can compare.

schema_suite.py
import json
import os
from collections import Counter

import openai
from openai import OpenAI
from pydantic import BaseModel, ValidationError

client = OpenAI(base_url="https://api.infro.io/v1", api_key=os.environ["INFRO_API_KEY"])


class Invoice(BaseModel):
    invoice_number: str
    vendor: str
    due_date: str | None
    total: float


SCHEMA = {
    "type": "json_schema",
    "json_schema": {
        "name": "invoice_extraction",
        "strict": True,
        "schema": {
            "type": "object",
            "properties": {
                "invoice_number": {"type": "string"},
                "vendor": {"type": "string"},
                "due_date": {"type": ["string", "null"], "description": "ISO 8601 date"},
                "total": {"type": "number"},
            },
            "required": ["invoice_number", "vendor", "due_date", "total"],
            "additionalProperties": False,
        },
    },
}


def business_rules(inv: Invoice, expected: dict) -> bool:
    return inv.invoice_number == expected["invoice_number"] and abs(inv.total - expected["total"]) < 0.01


def classify(model: str, text_in: str, expected: dict) -> str:
    try:
        res = client.chat.completions.create(
            model=model,
            messages=[
                {"role": "system", "content": "Extract the invoice fields. Reply in JSON."},
                {"role": "user", "content": text_in},
            ],
            response_format=SCHEMA,
            max_tokens=400,
        )
    except openai.BadRequestError:
        return "parameter_refused"
    except openai.APIError:
        return "transport"

    choice = res.choices[0]
    if choice.finish_reason == "length":
        return "truncation"
    if getattr(choice.message, "refusal", None):
        return "refusal"
    text = choice.message.content or ""
    try:
        data = json.loads(text)
    except json.JSONDecodeError:
        return "syntax" if text.lstrip().startswith(("{", "[")) else "parameter_dropped"
    try:
        invoice = Invoice.model_validate(data)
    except ValidationError:
        return "schema"
    return "ok" if business_rules(invoice, expected) else "semantic"


CASES = [
    ("Invoice #4821 from Acme Corp, due 2026-03-15, total $1,240.50.",
     {"invoice_number": "4821", "total": 1240.50}),
    ("Acme Corp bill 4822. Amount payable: 99 dollars. No due date given.",
     {"invoice_number": "4822", "total": 99.0}),
]

for model in ["extraction-primary", "extraction-standby"]:  # routes, or model ids
    counts = Counter(classify(model, text, exp) for text, exp in CASES * 25)
    total = sum(counts.values())
    print(model, {k: f"{v / total:.1%}" for k, v in counts.most_common()})

Run it with a few hundred real inputs, including the awkward ones: missing fields, two amounts, text in another language. The number that matters is not whether a model can produce the object once, but the rate of each failure class over many inputs, per model and per provider.

One repair attempt, then stop

For syntax and schema failures, one repair turn costs little and often suffices: send the model its own output and the validation error, and ask for a corrected object. Cap it at one attempt, count it separately, and never repair a truncation or a refusal.

repair.ts
import OpenAI from "openai";
import { z } from "zod";

const Invoice = z.object({
  invoice_number: z.string(),
  vendor: z.string(),
  due_date: z.string().nullable(),
  total: z.number().nonnegative(), // enforced here, whatever the provider supports
});

export async function extract(client: OpenAI, model: string, input: string) {
  const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
    { role: "system", content: "Extract the invoice fields. Reply in JSON." },
    { role: "user", content: input },
  ];
  for (let attempt = 0; attempt < 2; attempt++) {
    const res = await client.chat.completions.create({
      model,
      messages,
      response_format: { type: "json_object" },
      max_tokens: 400,
    });
    const choice = res.choices[0];
    if (choice.finish_reason === "length") throw new Error("truncated: raise max_tokens");
    const text = choice.message.content ?? "";
    let problem: string;
    try {
      const parsed = Invoice.safeParse(JSON.parse(text));
      if (parsed.success) return { invoice: parsed.data, repaired: attempt > 0 };
      problem = parsed.error.message;
    } catch (err) {
      problem = `Invalid JSON: ${(err as Error).message}`;
    }
    messages.push({ role: "assistant", content: text });
    messages.push({ role: "user", content: `That output failed validation: ${problem}. Return the corrected JSON only.` });
  }
  throw new Error("validation failed after one repair");
}

Request

Send with a schema

Strict where the serving provider supports it; the schema in the prompt where it does not.

Check the stop reason

length / max_tokens → raise the limit. Refusal → handle as a refusal.

Parse and validate

JSON parse, then your schema (pydantic, zod, a JSON Schema validator), then business rules.

One repair attempt

Send the error and the bad output back once; count it.

Record the outcome

Parse, schema and repair rates are the reliability numbers you compare.
The order of checks for every structured response. Each stage maps to a failure class, so the counts fall out of the pipeline.

Fallbacks can quietly remove the schema

Failover protects availability, and it can break structured output in a way no error reports. If the primary enforces your schema and the fallback does not, a provider outage turns validated JSON into best-effort JSON or prose, at exactly the moment you are least likely to be watching.

Parameters are part of this too. Anthropic now returns a 400 when temperature, top_p or top_k is set to a non-default value on Claude Opus 4.7 and later models5, so an extraction request carrying the common temperature: 0 fails outright when it falls back to one of those models. Test each fallback with the exact request your application sends.

Structured outputs through INFRO

INFRO accepts response_format on POST /v1/chat/completions in both standard modes, json_object and json_schema. On OpenAI-protocol connections — OpenAI, Azure OpenAI, the Gemini API's OpenAI-compatible endpoint, Groq, Together, Fireworks and others — it is forwarded unchanged; where a provider uses OpenAI's Responses protocol it is translated to that protocol's equivalent. INFRO never rewrites or repairs what comes back: invalid JSON arrives in a normal 200, and a provider that rejects the schema produces a 400 carrying the provider's own message. See structured outputs.

That makes chain design explicit. If a route or a fallbacks list mixes an OpenAI-protocol primary with an Anthropic standby, the standby will not apply your schema. Either keep structured-output routes on providers that enforce the mode you use, or make the client validator and the one-shot repair above part of every call so a standby's answer is still checked. The response's model, provider and connection fields name what actually served, so your harness can attribute each failure class to the provider that produced it.

INFRO's model catalog has a json capability flag for strict schema support, but the documentation notes it is not recorded per model yet — an empty capabilities array means not recorded, not unsupported — so the vendor's documentation and your own test run are the sources of truth. Request records keep finish_reason per request, and request traces let you filter to truncated responses when a schema change starts producing them.

Checklist

  • Every property required, optional fields nullable, additionalProperties: false everywhere.
  • Ranges, lengths and patterns enforced in your validator, not only in the schema.
  • finish_reason checked before parsing; truncation handled by the token budget.
  • Refusals handled as a product outcome, not retried.
  • Every response parsed and validated client-side, including on fallbacks.
  • At most one repair attempt, counted separately.
  • Each model and provider in the chain tested with the exact request, including sampling parameters.
  • Failure rates tracked per class, per model, per provider.

Questions readers ask

Does JSON mode guarantee my schema?
No. JSON mode produces syntactically valid JSON, but nothing enforces which keys appear. Only schema-constrained modes such as OpenAI's strict Structured Outputs enforce the shape, and only on models whose vendor supports them.

From the team that builds it

Where this shows up in INFRO

INFRO is a control plane in front of the provider accounts you already hold. The capabilities below are the parts of it this article touches, each with the documentation that defines its behaviour.

INFRO is in private early access. Provider usage is billed to you by your providers; INFRO charges a software subscription and takes 0% of model spend. See plans.

Sources

Numbered in order of first citation. Each was opened and checked against the claim on the date shown.

  1. 1.Structured Outputs (opens in a new tab) — OpenAI. Accessed Sep 29, 2026.
  2. 2.Structured outputs (opens in a new tab) — Anthropic. Accessed Sep 29, 2026.
  3. 3.Structured output (opens in a new tab) — Google. Accessed Sep 29, 2026.
  4. 4.OpenAI compatibility (opens in a new tab) — Google. Accessed Sep 29, 2026.
  5. 5.Model deprecations — API parameter deprecations (opens in a new tab) — Anthropic. Accessed Sep 29, 2026.

About this article

Written from OpenAI's, Anthropic's and Google's structured-output documentation, opened on 2026-09-29, and from INFRO's public structured-outputs documentation and the gateway's provider adapters, which the authors build. The harness is a design for readers to run; no pass rates were measured for this article.

Last checked against its sources on . The next review is due by . No separate named reviewer is recorded for this article.

How articles are written, sourced and corrected is set out in the editorial standards.