ADR-128: Provider-native structured output
- Status
-
Accepted
- Date
-
2026-08-05
Context
ADR-082 built structured completions on a prompt instruction plus local
validation and one repair round-trip, and named native, per-provider
enforcement as a follow-up behind a schema-compatibility normaliser.
ADR-126 delivered the missing precondition: a named, pre-flighted schema
subset. What remained was the transport — the recon found that only the
OpenAI adapter emitted any response_ at all; the other six
adapters silently dropped the option, so even plain JSON mode was
prompt-only on six of seven providers.
Decision
Chat gains with (declared outside the
constructor, like suppress — the
@phpstan-/Tool constraint). Both
complete methods attach the already-pre-flighted schema;
it reaches the adapter as response_ in the flat options array.
Each adapter emits its provider's dialect:
- OpenAI, Groq, Mistral, OpenRouter share
Open. A schema that qualifies for OpenAI's strict mode (conservative profile: object root, every objectAi Response Format Trait additionalwith all properties required, allowlisted keywords only) is sent as `Properties: false response_` — strict mode 400s on schemas outside its rules, and a provider error for a valid ADR-126 schema is the failure mode this profile exists to prevent. Plainformat: {type: json_ schema, strict: true}``; any other schema degrades to `` {type: json_ object} response_now emits JSON mode on all four (previously: OpenAI only).format: 'json' - Gemini sets
generationand, when the root is expressible,Config. response Mime Type: application/ json responsein Gemini's dialect. The dialect conversion only ever widens (drops keywords it cannot express — a partially-convertedSchema enumwould narrow below the real schema and block valid values, so inexpressible keywords are dropped whole). - Ollama sends the schema verbatim as the top-level
formatfield (likethink), orformat: 'json'for plain JSON mode. - Claude has no response-format parameter; the native idiom is a single
forced tool whose
input_is the schema, and whoseschema tool_input is returned as the JSON string every other provider returns. Object-root schemas only (Claude's requirement); anything else stays prompt-only.use
The invariant that makes all of this safe: native enforcement narrows what the model can emit; the local strict validation (ADR-126) remains authoritative on the response. Native emission may be weaker than the schema (Gemini dialect, json_object fallback) but must never be stronger; the prompt instruction and the repair round-trip stay untouched.
Consequences
completenow gets real JSON mode on Groq, Mistral, OpenRouter, Ollama and Gemini instead of relying on the prompt alone — fewer "Failed to decode JSON response" failures, no API change.Json () - Streaming is deliberately out of scope: structured completions never
stream (ADR-062), so
streamdoes not emit schemas.Chat Completion () - The strict-mode profile and the Gemini dialect are conservative by design; widening them (more keywords, union types) is additive and needs no API change.
- Ollama receives the full subset schema; its grammar conversion handles the ADR-126 keyword set, but an exotic schema a future Ollama version rejects would surface as a provider error — accepted, since Ollama is the local reference provider and the schema was pre-flighted.
- On Claude, a structured completion's
finishreflects the forced tool call (Reason tool_) rather thancalls stop; callers ofcompleteconsume the decoded array, not the reason.Structured* () - ADR-082's "no native parameter at all" statement about Claude is superseded by the tool-forcing idiom; its prompt+repair architecture stands.