Support structured outputs on the chat routes - #118
Open
EmBista wants to merge 1 commit into
Open
Conversation
EmBista
force-pushed
the
structured-outputs
branch
3 times, most recently
from
August 16, 2026 14:49
fec487d to
a93341e
Compare
The Responses API carries structured outputs at `text.format`, but neither chat-compat route ever built one. `response_format` (and Ollama's `format`) were read off the request and dropped. Upstream never saw a schema, answered 200, and the model returned whatever shape it liked — silently, since the reply is still valid JSON, just not the requested one. - map a json_schema `response_format` to `text.format`, accepting both the nested Chat Completions spelling and the flat Responses one - map Ollama's `format` when it holds a schema - fall back to `legacy` reasoning-compat when the caller asks for JSON, since think-tags mode prepends `<think>…</think>` to the very content that was asked to be JSON. `legacy` keeps content untouched and puts the reasoning in sibling string fields, the shape openai-compatible clients already read. `json_object`, and Ollama's bare "json", are deliberately not forwarded: upstream refuses that format unless the input mentions "json", so sending it would turn requests that pass today into 400s. They still get the compat fallback, so their content is JSON a client can actually parse — the override keys off what the caller asked for, not off what was forwarded. The fallback applies only when the server is in think-tags mode. The other compat modes already keep reasoning out of content, and forcing legacy over `--reasoning-compat o3` would change the type of `message.reasoning`. Requests that send neither field are unaffected: no `text` is added to the upstream payload and reasoning still rides in think tags. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EmBista
force-pushed
the
structured-outputs
branch
from
August 16, 2026 14:58
a93341e to
3185ada
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ChatMock reads
response_formatoff the request and then drops it. Upstream never sees a schema, answers 200, and the model returns whatever shape it likes — so a caller asking for structured output gets valid JSON that isn't the JSON they asked for:Nothing errors and nothing warns, so it looks like it worked until something reads a field that isn't there. Clients that validate the reply against the schema — the Vercel AI SDK's
generateObject, say — fail at the parse step instead, with no hint that the schema was never sent.Why it happened
Chat Completions calls this
response_format; the Responses API carries it attext.format. The chat routes build the upstream payload field by field, and there was no mapping between the two spellings, so the field had nowhere to go. Nothing was missing upstream:/v1/responsesalready forwardstextuntouched, and posting a Responses payload through ChatMock's own authenticated client confirms the backend acceptstext.formatjson_schema.There is a second half. Once a schema does reach the model the content is supposed to be JSON, but the default
think-tagsmode prepends<think>…</think>to it — a prefix no JSON parser accepts.Changes
response_format→text.format, accepting both the nested Chat Completions spelling and the flat Responses oneformat→ the same, when it holds a schemalegacyso the content stays parseable. This is a precedence call and worth your say-so:think-tagsis a deliberate setting, and it is being overridden per-request. My reasoning is that the two cannot both be satisfied — no client wants content that is at once valid JSON and prefixed with<think>— so the request-specific ask beats the server default, and reasoning is relocated rather than lost (message.reasoning_summary). Note the collision already exists onmain: any client that prompt-injects a schema gets think tags glued to its JSON today, silently. What changes here is that a declaredresponse_formatfinally tells ChatMock the content is a machine contract. Happy to put it behind a flag instead. It applies only inthink-tagsmode: the others already keep reasoning out of content, as upstream does by itself, and forcinglegacyovero3would change the type ofmessage.reasoningjson_object, and Ollama's bareformat: "json", are deliberately not forwarded. Upstream refuses that format unless the input happens to mention "json", so sending it would turn requests that pass today into 400s for a reason the caller cannot see. They still get the compat fallback, soollama.chat(..., format="json")now returns content a JSON parser accepts — the override keys off what the caller asked for, not off what was forwarded.How to try locally
Run against the live backend on gpt-5.6-sol, gpt-5.6-luna, gpt-5.6-terra and gpt-daybreak-blue-latest, streaming and non-streaming, on both routes, and across all four
--reasoning-compatmodes.Checklist notes
main. No issue reference — opening this cold; happy to file one first if you prefer that order.textto the upstream payload and still gets its reasoning in think tags. Three tests pin that.current_app.config, so the handler's own local could never affect them; they now read the local, same value unless a request overrides it.start_upstream_request, so they pass whether or not the schema ever reaches the payload — I had exactly that, a green suite over a feature that did nothing. Those tests patchrequests.postand assert the real outbound payload. Each hunk was checked by reverting it and confirming a test fails.strict: truewith a schema whose properties are not allrequirednow gets upstream's 400 rather than a silent 200 — note the Vercel AI SDK sets that flag by default once structured outputs are enabled. And on the Ollama route reasoning is dropped rather than relocated when JSON is requested; that is pre-existinglegacybehaviour which a schema request now reaches.Disclosure
AI was used to write this patch (Claude Code). Everything it claims was verified against the live backend and the test suite rather than taken on faith, but review it as you would any patch from a stranger. Happy to rework anything, drop the
strictforwarding if you would rather this PR changed no status codes at all, or close it if it is not a direction you want the project to go.