All three frontier APIs now answer the same question the same way: hand them a JSON Schema and they hand you back JSON that provably matches it. Claude, GPT-5.6, and Grok 4.5 each use constrained decoding — the model is forced to emit tokens that satisfy the schema — so the old ritual of "prompt for JSON, scrape the code fence, hope, retry" is finally optional. The contract is even the same dialect on all three: JSON Schema draft 2020-12.
So you'd expect one schema to drop into all three. It nearly does — and the place it doesn't is a single line that most people copy straight from OpenAI's documentation. Here's the whole picture, in the order you'll hit it.
Three doors into the same room#
The schema is the same idea everywhere; each provider takes it through a different parameter.
Claude takes a top-level output_config.format, or — the older, equally valid path — a tool marked "strict": true, whose input_schema the model is forced to fill. The SDK wraps both in a Pydantic helper:
from pydantic import BaseModel
from anthropic import Anthropic
class Contact(BaseModel):
name: str
email: str
demo_requested: bool
client = Anthropic()
resp = client.messages.parse(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user", "content": "John Smith, john@example.com, wants a demo."}],
output_format=Contact, # SDK helper → raw param is output_config.format
)
print(resp.parsed_output) # a Contact instance
GPT-5.6 takes response_format.json_schema with strict: true on Chat Completions — but OpenAI now steers new code to the Responses API, where the same definition moved to text.format. Same Pydantic ergonomics:
from openai import OpenAI
client = OpenAI()
resp = client.responses.parse(
model="gpt-5.6-terra", # bare alias `gpt-5.6` routes to Sol
input=[{"role": "user", "content": "John Smith, john@example.com, wants a demo."}],
text_format=Contact,
)
print(resp.output_parsed)
Grok 4.5 is OpenAI-compatible: point the OpenAI SDK at https://api.x.ai/v1 and pass the same response_format. The code is the OpenAI code with two lines changed:
client = OpenAI(api_key=XAI_KEY, base_url="https://api.x.ai/v1")
r = client.chat.completions.parse(
model="grok-4.5",
messages=[{"role": "user", "content": "John Smith, john@example.com, wants a demo."}],
response_format=Contact,
)
print(r.choices[0].message.parsed)
Three SDKs, one mental model: define a Pydantic model, call .parse(), read a typed object. If your schemas were trivial, you'd stop reading here. They aren't.
The one line that turns a soft problem into a 400#
The moment your object has a nested object, portability cracks — and it cracks hardest between the two providers that look most alike.
OpenAI's strict mode requires additionalProperties: false on every object in the schema, nested ones included. Its SDK sanitizer will inject that line for you. Ship the resulting schema to Grok, and you get an HTTP 400 — because xAI accepts additionalProperties: false only on the root object and rejects it on nested objects outright. This isn't a hypothetical: it's a documented break in the wild, and it's nasty precisely because it's a hard error, not a quiet degrade. The schema that OpenAI demands is the schema that Grok refuses.
The trap isn't that the three APIs are different. It's that OpenAI and Grok are almost identical — same SDK, same response_format — so you assume the schema ports too, and the one field that doesn't fails loudly on the provider you tested last.
Two more asymmetries you'll meet right after:
- Optional fields. OpenAI strict mode has no concept of an optional key — every property must appear in
required. You express "maybe absent" as a null-union:"type": ["string", "null"]. Anthropic and xAI accept the same pattern, so null-unions are the portable way. Omitting a field fromrequiredto mean "optional" works on Claude and breaks on OpenAI. - Recursion. OpenAI allows a schema to reference itself via
$ref(a tree of comments, say). Anthropic does not support recursive schemas. If your data is genuinely recursive, you'll flatten it or cap the depth before it ports to Claude.
And one rule that's the same everywhere, stated as a warning because it surprises people: validation keywords don't validate. minLength, maxLength, pattern, minimum, maximum, minItems — constrained decoding guarantees the shape (types, required keys, enum membership), not your business rules. OpenAI's strict mode rejects these keywords; Anthropic and xAI ignore them. Either way, they guarantee nothing. (This is a cousin of the problem we hit with MCP tool schemas, where oneOf and $ref are legal but unenforced — a schema the transport accepts is not a schema the model was constrained to.) If a string must be an ISO date or a total must be non-negative, validate it in your own code after parsing.
Write one schema, keep a ten-line shim#
You don't want three schemas drifting out of sync. You want one base schema and a tiny per-provider transform. The portable base:
- Flat-ish objects. Nesting is fine; recursion is not (Anthropic).
- Every field in
required. Optionality via["type", "null"]unions (OpenAI strict, portable). additionalProperties: falseon the root only in the base — then add it to nested objects for OpenAI, and leave it off nested objects for Grok.- No validation keywords. Keep them in a separate Pydantic/
jsonschemapass that runs after the model returns. - Modest enums, no exotic string formats. Enums are safe everywhere; keep them small.
The per-provider step is genuinely small — for OpenAI, walk the schema and set additionalProperties: false on every object; for Grok, ensure it's set on the root and absent everywhere else; for Claude, drop any recursion and hand it to output_config.format. Ten lines, one place, versioned with the schema. That beats maintaining three near-identical files whose diffs are exactly the traps above. (If you're generating those schemas from evolving types, the discipline in versioning an agent's tool schemas applies here too — the schema is an interface, and it breaks silently.)
The decision, made plainly#
If you're on one provider, use its native strict/structured mode and the Pydantic .parse() helper, and stop thinking about JSON validity — constrained decoding already won that fight. The concept-level tradeoffs (when structured output is even the right tool versus function calling, and its small accuracy tax) we covered in JSON mode vs function calling vs constrained decoding.
If you're routing across two or three — for cost, for fallback, for a cheap-tier split — write the base schema by the rules above and put the per-provider transform behind your model client so no caller ever sees it. Then the only thing left to handle is what happens when the model doesn't return clean JSON: truncation, refusals, and safety stops each surface differently across these three, and each wants a different response. That's a playbook of its own — what to do when structured output breaks.



