Structured outputs across OpenAI, Claude, and Gemini look similar until you try to support all three behind one Python API.
Each provider can produce JSON that follows a schema. Each has a Python SDK. Each Python ecosystem can start from a Pydantic model, but the path from that model to the provider wire format is different. That makes the problem look solved -- until the application needs to switch providers, run the same test against more than one model, or handle a refusal and a truncated response consistently.
Then the differences become application code: request shapes, SDK helper names, supported JSON Schema vocabulary, local references, and where a parsed result lives. "Structured output" is one feature name, not one portable contract.
The adapter boundary
I ran into this while building llm-api-adapter, an open-source Python library that provides one interface for OpenAI, Anthropic, Google, Mistral, and xAI APIs.
One of its goals is deliberately narrow: application code defines a structured-output contract once; provider adapters deal with the differences underneath.
response = adapter.chat(
messages=messages,
response_model=ProductAnalysis,
)
result = response.parsed_model
The interesting part is not this API. It is what has to happen underneath to make the same contract meaningful across providers.
The experiment: one Core portable schema
This article uses one Pydantic model and sends the same task to OpenAI, Claude, and Gemini.
from typing import Literal
from pydantic import BaseModel, ConfigDict
class Aspect(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
sentiment: Literal["positive", "negative", "mixed"]
class ProductAnalysis(BaseModel):
model_config = ConfigDict(extra="forbid")
product_name: str
summary: str
strengths: list[Aspect]
concerns: list[Aspect]
The contract is intentionally modest. It has an object root, strict nested objects, required fields, strings, arrays, an enum, and a Pydantic-generated local $defs reference for Aspect.
That is the point. I am not testing whether arbitrary JSON Schema is portable. I am testing a Core portable profile: a useful intersection that the library validates before a request is sent.
In v0.9.2, that profile includes:
- object roots and strict objects (
additionalProperties: false); - required properties; nullable raw-schema properties are represented as
type: ["string", "null"]rather than omitted properties; -
string,number,integer,boolean,object, andarraytypes; - one schema in
items, plus non-emptyenumvalues; - direct local
#/$defs/<name>references, which are inlined before provider serialization.
It intentionally excludes general composition such as anyOf, arbitrary unions, external or recursive references, and conditional JSON Schema keywords. That boundary is a design choice, not a temporary workaround.
For the examples below, the input is the same everywhere:
review = """
The TrailBottle is light and keeps water cold all day. The lid leaks in a bag,
though, and the finish scratches easily.
"""
Native OpenAI implementation
OpenAI's Python SDK has a parsing helper for the Responses API. It turns a Pydantic class into a strict JSON Schema request and puts parsed output on the output text item.
from openai import OpenAI
client = OpenAI()
response = client.responses.parse(
model="gpt-5", # choose a structured-output-capable model
input=review,
text_format=ProductAnalysis,
)
message = response.output[0]
assert message.type == "message"
result = None
for item in message.content:
if item.type == "refusal":
raise RuntimeError(item.refusal)
if item.type == "output_text" and item.parsed is not None:
result = item.parsed
break
if result is None:
raise RuntimeError("OpenAI did not return a parsed structured result")
This is pleasant code when OpenAI is the only provider. But OpenAI's response is an output-item list, not a universal response type. A refusal is also a distinct response content type, so code that assumes an output_text item needs an explicit terminal-state path. OpenAI documents both the Pydantic helper and refusal handling, and also states that strict structured output supports only a JSON Schema subset. OpenAI Python example and Structured Outputs overview.
Native Claude implementation
Claude has the same high-level capability, but the call and result shape are different. Its Python helper is messages.parse() and returns the typed value in parsed_output.
from anthropic import Anthropic
client = Anthropic()
response = client.messages.parse(
model="claude-opus-5", # choose a structured-output-capable model
max_tokens=500,
messages=[{"role": "user", "content": review}],
output_format=ProductAnalysis,
)
result = response.parsed_output
if result is None:
raise RuntimeError("Claude did not return a parsed structured result")
Underneath, Claude's native request uses output_config.format with type: "json_schema"; the SDK helper translates output_format to that shape. Claude's SDK may also transform unsupported schema constraints before sending them and validate against the original model afterward. That is a valid provider-specific design, but it is not a behavior an adapter can silently generalize without changing the portable contract. Claude structured-output documentation
Native Gemini implementation
Gemini exposes the same capability through a different current API shape. In the Interactions API, the response format is top-level, the Pydantic model is converted to JSON Schema explicitly, and the returned JSON text is validated explicitly.
from google import genai
client = genai.Client()
interaction = client.interactions.create(
model="gemini-3.7-flash", # choose a structured-output-capable model
input=review,
response_format=[
{
"type": "text",
"mime_type": "application/json",
"schema": ProductAnalysis.model_json_schema(),
}
],
)
result = ProductAnalysis.model_validate_json(interaction.output_text)
The names differ again: input, response_format, schema, and output_text. Gemini also supports a subset of JSON Schema, including nullable properties expressed with a type array. Its SDK also supports Pydantic response_schema and .parsed on the older GenerateContent surface; the explicit Interactions form above is useful here because it makes the provider-specific lifecycle visible. Gemini structured-output documentation and Gemini migration guide.
None of these examples is difficult. The maintenance cost appears when business logic must know all three result locations and request dialects.
Comparison: similar feature, different contracts
Native Python / Pydantic path
-
OpenAI:
responses.parse(..., text_format=...) -
Claude:
messages.parse(..., output_format=...) -
Gemini:
model_json_schema()→model_validate_json()in Interactions -
llm-api-adapter v0.9.2:
response_model=...
Raw JSON Schema request
-
OpenAI:
text.format/response_format -
Claude:
output_config.format -
Gemini: top-level
response_format[].schema -
llm-api-adapter v0.9.2:
json_schema=...
Where the parsed result appears
-
OpenAI: an output text item's
.parsed -
Claude:
.parsed_output -
Gemini:
interaction.output_text, followed by application validation -
llm-api-adapter v0.9.2:
.parsed_modelor.parsed_json
Schema portability boundary
-
Nullable raw field: Gemini documents type arrays; the other native APIs support provider-specific subsets. The adapter accepts
type: ["T", "null"], while keeping the field required. -
Nullable Pydantic field: native SDK behavior varies. A normal Pydantic
str | NoneproducesanyOf, so it is intentionally outside the adapter's portableresponse_modelprofile. -
Nested Pydantic models: native SDK behavior varies. The adapter supports them when every object uses
extra="forbid", and inlines direct local$defs. -
References: every provider supports a provider-specific subset. The adapter accepts only direct local
#/$defs/...references; external and recursive references fail before the request.
Terminal lifecycle and streaming
-
Refusal or incomplete output: OpenAI, Claude, and Gemini expose provider-native terminal states. The adapter normalizes them as
refusalandincomplete_reason; parsed fields remainNone. -
Streaming structured output: each provider exposes its own event stream. The adapter exposes the parsed terminal result in
on_done.
This comparison is intentionally not a capability ranking. Native APIs can be richer than the portable profile. The adapter's job is to provide a contract whose meaning does not change when the selected provider changes.
What llm-api-adapter actually normalizes
At the call site, the application sees this:
Application
|
| response_model=ProductAnalysis
v
llm-api-adapter
|
+-- OpenAIAdapter
+-- AnthropicAdapter
+-- GoogleAdapter
v
Provider API
The adapter layer does four concrete jobs.
Prepare the schema without mutating the caller's model. It keeps the original Pydantic model for final validation, creates a provider-ready copy of its JSON Schema, and inlines direct local
$defsreferences.Validate the Core portable profile before an HTTP request. A schema outside the profile fails locally with
JSONSchemaErrorand a schema path. The adapter does not add required fields, remove constraints, or turn an unsupported union into something that only resembles the original contract.Build the provider wire representation. OpenAI receives strict JSON Schema through its Chat Completions or Responses shape; Claude receives
output_config.format; Gemini receives JSON response configuration. In v0.9.2, the Google adapter targets the GenerateContent wire shape (generationConfig.responseMimeTypeplusresponseJsonSchema), while the newer native Interactions surface shown above remains provider-specific. For Gemini, only non-semantic$idand$schemametadata are removed from the portable wire schema.Normalize the terminal response. A completed structured result becomes
parsed_json; whenresponse_modelwas used it also becomesparsed_model. Refusals and incomplete generations are exposed separately and are never parsed as successful JSON.
The application code is then independent of the provider:
import os
from llm_api_adapter.models.messages.chat_message import UserMessage
from llm_api_adapter.universal_adapter import UniversalLLMAPIAdapter
adapter = UniversalLLMAPIAdapter(
organization="openai", # or "anthropic" or "google"
model="gpt-5",
api_key=os.environ["OPENAI_API_KEY"],
)
response = adapter.chat(
messages=[UserMessage(review)],
response_model=ProductAnalysis,
max_tokens=500,
)
if response.refusal is not None:
raise RuntimeError(f"Model refused the request: {response.refusal}")
elif response.incomplete_reason is not None:
raise RuntimeError(f"Incomplete generation: {response.incomplete_reason}")
else:
analysis = response.parsed_model
assert analysis is not None
print(analysis.summary)
Changing to Claude or Gemini changes the organization, model, and credential -- not the business logic or structured-output lifecycle.
For a raw schema, the same boundary is explicit:
analysis_schema = {
"type": "object",
"properties": {
"product_name": {"type": "string"},
"summary": {"type": "string"},
"confidence": {"type": ["number", "null"]},
},
"required": ["product_name", "summary", "confidence"],
"additionalProperties": False,
}
response = adapter.chat(
messages=[UserMessage(review)],
json_schema=analysis_schema,
max_tokens=500,
)
data = response.parsed_json
parsed_json means valid JSON was received for a completed non-refusal response. Raw schemas are not additionally validated locally against every JSON Schema rule; response_model adds final Pydantic validation.
What I deliberately do not normalize
An adapter becomes misleading when it pretends providers are identical.
llm-api-adapter therefore does not promise to make these things portable:
- arbitrary JSON Schema, including general unions, conditionals, and recursive references;
- provider-native schema extensions or a provider's newest feature on day one;
- the newer Google Interactions surface as if it were already a provider-neutral API;
- reasoning controls, model limits, and other provider-specific capabilities;
- a Pydantic model whose generated schema falls outside the Core portable profile.
The last point is worth making explicit. Strict nested Pydantic models work well in the profile. A normal Pydantic nullable annotation such as nickname: str | None, however, generates anyOf by default. Since anyOf is intentionally outside the profile, v0.9.2 rejects it instead of rewriting its semantics. Use a raw nullable schema when that exact representation is required, or use a native provider API when you need a richer model shape.
That is a feature of the contract: failure is early and honest.
When should you use an adapter?
Use an adapter when:
- your application supports more than one provider;
- you want to change providers without rewriting business logic;
- you need shared contracts for messages, tools, structured output, streaming, and terminal errors;
- the useful part of your schema fits a documented portable profile.
Use a native API when:
- your application is intentionally tied to one provider;
- it depends heavily on provider-specific features;
- you need newly released native functionality immediately;
- your JSON Schema needs features outside the Core portable profile.
The second list is not a failure mode for an adapter. It is the boundary that keeps an adapter honest.
Repository and reproducible examples
The implementation described here is available in llm-api-adapter. The repository documents the Core portable JSON Schema profile and includes deterministic conformance tests for structured output across supported organizations, plus opt-in live E2E coverage for configured models.
The larger lesson is simple:
I am not trying to make arbitrary JSON Schema portable across LLM providers. I define a useful portable subset and preserve its semantics across them.
That is a smaller promise than "one JSON Schema, three APIs." It is also a promise an adapter can actually keep.
Top comments (0)