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" }); + }); + }); });