Skip to content

feat(openai): support structured output via response_format - #930

Merged
rynfar merged 4 commits into
mainfrom
feat/openai-structured-output-merged
Sep 4, 2026
Merged

feat(openai): support structured output via response_format#930
rynfar merged 4 commits into
mainfrom
feat/openai-structured-output-merged

Conversation

@rynfar

@rynfar rynfar commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Supersedes #898 by @diwakar-s-maurya, cherry-picked so the original commit and authorship are preserved. main requires signed commits, so fork PRs sit at mergeStateStatus: BLOCKED regardless of CI — nothing the contributor could resolve from their side.

What it does

/v1/chat/completions dropped response_format entirely, so a request asking for schema-valid JSON got prose back. This translates it into the output_config.format that the internal /v1/messages hop already understands, so enforcement is the SDK's native structured output — schema validation and retry included — rather than a second implementation. The Anthropic spelling output_config.format is accepted at this endpoint too.

Errors are raised at the endpoint boundary in the client's own dialect, so an OpenAI caller sees response_format.json_schema.schema rather than a field it never sent.

Also drops a root-level $schema before handing the schema to the SDK. Anything but the draft-07 URI makes the model fail to submit its result, which burns the turn budget and surfaces as a 500 — and that includes the 2020-12 URI that zod v4's z.toJSONSchema() emits by default, so every schema from the zod/Vercel AI SDK toolchain hit it.

One fix on top

translateResponseFormat read .type off the value before any validation, so "response_format": null threw a TypeError out of a handler with no try/catch around the translation:

TypeError: null is not an object (evaluating 'format.type')
    at translateResponseFormat (src/proxy/openai.ts:496:31)

Plenty of OpenAI-compatible clients serialize an unset optional as null instead of omitting the key, so this made a plain chat request that worked before structured output existed return a 500. Null is now omission, and a non-object response_format is forwarded untouched so parseOutputFormat rejects it with a 400 naming the client's field instead of silently ignoring it.

Verified the regression test fails without the fix and passes with it.

Validation

  • npm test — 3036 pass, 1 skip, 0 fail
  • npm run typecheck — clean

diwakar-s-maurya and others added 4 commits September 4, 2026 12:45
The OpenAI endpoint dropped `response_format` entirely. Requests asking
for schema-valid JSON got prose back with no error

Translate `response_format` into the `output_config.format` that the
internal /v1/messages hop already understands. Enforcement is the SDK's
native structured output, including schema validation and retry, so this
is plumbing rather than a second implementation. Streaming works too:
the SDK buffers until the result validates, then emits it as one delta.

- `json_object` has no Anthropic equivalent — there is no schema-less JSON
mode — so it is rejected rather than widened into a permissive object
schema that would promise an enforcement the request never gets.
- `strict` and `name` are dropped: the SDK always validates, which is
never weaker than strict asked for, and `name` is a client-side label.
Tools remain incompatible with structured output, unchanged.

Errors name the field the caller actually sent. parseOutputFormat takes
the paths to report, and the OpenAI route validates before the hop —
otherwise a client sending `response_format` is told about
`output_config.format`, a field it never wrote.

Also strip a root-level `$schema` before handing the schema to the SDK.
Anything but the draft-07 URI made the model fail to submit its result,
returning HTTP 500 ("no structured_output result") after burning the
turn budget — and zod v4's `z.toJSONSchema()` emits the 2020-12 URI by
default, so every schema from that toolchain hit it. This affected the
native /v1/messages endpoint equally. The keyword only declares a
dialect and constrains nothing, so it is dropped rather than translated.
Many OpenAI-compatible clients serialize an unset optional as JSON null
rather than dropping the key. translateResponseFormat read `.type` off the
value before any validation, so `"response_format": null` threw a
TypeError out of the /v1/chat/completions handler, which has no try/catch
around the translation — turning a plain chat request that worked before
structured output existed into a 500.

Treat null as omission, and forward a non-object response_format untouched
so parseOutputFormat rejects it with a 400 naming the client's own field
instead of it being silently ignored.

The boundary check in server.ts skips null for the same reason, which also
keeps the error dialect honest: a null alongside an Anthropic-style
output_config.format would otherwise report failures against
`response_format.*`, a field the client did not meaningfully send.
The existing test asserts only what reaches the SDK, and its request
actually ends in a 500: the mock yields no result carrying
structured_output, so nothing downstream of the SDK boundary runs. The
feature's whole point — schema-valid JSON in the response — had no
coverage in either mode.

Add a non-streaming case asserting the parsed JSON in
choices[0].message.content, and a streaming case asserting the same value
reassembled from the content deltas.

Also restore translateOpenAiToAnthropic's JSDoc, which was left stranded
above translateResponseFormat when that helper was inserted, so the
"returns null if the request has no messages" contract that server.ts
relies on documented the wrong function.
Structured-output mode buffers the SDK's wire events and replaces the
response content, so it cannot coexist with a client-driven tool loop --
the tool_use turn gets swallowed. /v1/messages has always rejected the
combination and continues to; nothing has depended on it working there.

/v1/chat/completions is different. It dropped response_format entirely
before structured output existed, so tool calling worked, and OpenAI
permits both fields together -- LangChain agents and LiteLLM send them
routinely. Routing those requests into the same rejection turned a
working tool loop into a 400.

Reject only when nothing the caller asked for can be honoured. With tools
present the tools are honoured and the unsatisfiable schema is dropped and
logged. json_object on its own stays a 400: there is no schema-less JSON
mode to fall back to, and handing prose to a caller about to JSON.parse it
is a worse failure than an actionable error.
@rynfar
rynfar force-pushed the feat/openai-structured-output-merged branch from 60eb91c to 1b3f94a Compare September 4, 2026 18:45
@rynfar
rynfar merged commit db9546c into main Sep 4, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants