Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// `<listed>:<tag>` 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;
Expand Down
34 changes: 33 additions & 1 deletion tests/openai-chat-hardening.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 `<listed>:<tag>` 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<string, unknown>;

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