From 333a24c905170d3e9d4e956ed79cba3ab3754ff8 Mon Sep 17 00:00:00 2001 From: Nguyen Thanh Dat Date: Tue, 18 Aug 2026 20:51:52 +0700 Subject: [PATCH] fix(chat): keep the structured-output opt-out exact on the native chat wire `noStructuredOutputModels` is documented, in every locale, as "Exact model IDs whose `openai-chat` endpoint rejects `response_format`. Only an exact requested-model match omits the field; structured-output translation stays enabled for every other `openai-chat` model." The Responses ingress enforces that, and tests/openai-chat-hardening.test.ts already pins a `:tag` sibling keeping the field there. The native Chat passthrough added in #1467 matched through `modelInList` instead, which also matches the pre-colon prefix. On a provider that serves Ollama-style tags -- ollama-cloud ships `gpt-oss:120b`, `qwen3-coder:480b`, `qwen3.5:397b`, `gemma4:31b` -- a `noStructuredOutputModels: ["gpt-oss"]` entry therefore stripped `response_format` from `gpt-oss:120b` on /v1/chat/completions while /v1/responses kept it. The caller asked for JSON and silently got prose, on a model the operator never opted out. That is the failure #1424 called out when it chose the exact boundary: a wider match "would silently return prose for siblings that support JSON Schema". The sibling gates on the lines above keep `modelInList` -- `noVisionModels` is documented as tolerating an Ollama `:size` tag, this one is not -- so the comment now says why this gate differs. Four tests, next to the existing Responses-side assertions so the two ingresses read as a pair: exact id opts out, a `:tag` sibling does not, the full `:tag` id does when listed, and an unrelated model is untouched. The `:tag` sibling case fails on current dev. Co-Authored-By: Claude Opus 5 --- src/adapters/openai-chat.ts | 6 ++++- tests/openai-chat-hardening.test.ts | 34 ++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index a6d13fa1f4d..b93222d6ac2 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -116,7 +116,11 @@ export function buildOpenAIChatPassthroughRequest( delete body.presence_penalty; delete body.frequency_penalty; } - if (modelInList(provider.noStructuredOutputModels, modelId)) delete body.response_format; + // Exact match, unlike the gates above: `noStructuredOutputModels` is documented as + // "only an exact requested-model match omits the field" (#1424), and the Responses + // ingress enforces exactly that. A prefix match here would strip response_format from + // `:` siblings the operator never opted out, silently returning prose. + if (provider.noStructuredOutputModels?.includes(modelId)) delete body.response_format; if (provider.chatServiceTier && rawBody.service_tier !== undefined) { body.service_tier = rawBody.service_tier; diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index 19b0e6be405..f7435e0a283 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../src/adapters/openai-chat"; +import { buildOpenAIChatPassthroughRequest, createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../src/adapters/openai-chat"; import { stripResponsesOnlyEncryptedMarker } from "../src/adapters/responses-tool-schema"; import { getDebugLogEntries, resetDebugLogBufferForTests } from "../src/lib/debug-log-buffer"; import { resetDebugSettingsForTests } from "../src/lib/debug-settings"; @@ -792,4 +792,36 @@ describe("openai-chat response_format emission", () => { json_schema: { name: "answer", schema: { type: "object" }, strict: true }, }); }); + + // The native Chat ingress reads the same provider option and must draw the same + // boundary. It used to match through modelInList, so a `:` sibling + // lost response_format on this wire while keeping it on Responses. + describe("native chat passthrough draws the same exact boundary", () => { + const passthrough = (modelId: string, noStructuredOutputModels: string[]) => + JSON.parse(buildOpenAIChatPassthroughRequest( + provider({ noStructuredOutputModels }), + { messages: [{ role: "user", content: "hi" }], response_format: { type: "json_object" } }, + modelId, + false, + ).body as string) as Record; + + test("omits response_format for the exact listed id", () => { + expect(passthrough("test-model", ["test-model"]).response_format).toBeUndefined(); + }); + + test("keeps response_format for a :tag sibling the operator never listed", () => { + expect(passthrough("test-model:structured", ["test-model"]).response_format) + .toEqual({ type: "json_object" }); + }); + + test("listing the full :tag id opts that id out", () => { + expect(passthrough("test-model:structured", ["test-model:structured"]).response_format) + .toBeUndefined(); + }); + + test("leaves an unrelated model untouched", () => { + expect(passthrough("supported-model", ["test-model"]).response_format) + .toEqual({ type: "json_object" }); + }); + }); });