From 8f909f4f337aff6594200f13dda17dc42b1cd590 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 1 Nov 2026 06:44:59 +0000 Subject: [PATCH] fix(responses): reject lossy direct Chat tool choices --- scripts/test-layout/layout.json | 1 + src/server/responses/request-prepare.ts | 6 +++ tests/fixtures/test-layout-expected.json | 3 +- .../responses-direct-chat-tool-choice.test.ts | 38 +++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 tests/responses/responses-direct-chat-tool-choice.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 79ff88d6093..6c54fd1cf42 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -131,6 +131,7 @@ "worker-embed-coverage.test.ts": "lib", "jev-stats.test.ts": "usage", "responses-devin-401-replay.test.ts": "responses", + "responses-direct-chat-tool-choice.test.ts": "responses", "responses-oauth-refresh-capacity.test.ts": "responses", "responses-grok-devin-preflight.test.ts": "responses", "deepseek-quota-currency.test.ts": "providers", diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 8a7c561933a..51c1232900a 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -68,6 +68,7 @@ import { previousResponseProviderState, } from "../../responses/state"; import { formatErrorResponse } from "../../bridge"; +import { chatCanPreserveMandatoryToolChoice } from "./combo-required-tool-contract"; import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error"; import type { OcxParsedRequest } from "../../types"; import { buildToolBridgeMaps } from "./collaboration"; @@ -1438,6 +1439,11 @@ export async function prepareResponsesRequest( claudeGoAffinity: options.claudeGoAffinity, droidDefaultEffort: options.droidDefaultEffort, }); + if (route.provider.adapter === "openai-chat" + && !chatCanPreserveMandatoryToolChoice(parsed._rawBody, route.provider, route.modelId)) { + return formatErrorResponse(400, "invalid_request_error", + "This mandatory tool_choice cannot be preserved for a translated Chat destination."); + } try { assertPolicyNormalizedRoute(policyScope, admittedPolicyRoute, route); } catch (error) { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 74bcb8a8604..0235c4dcd09 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -300,7 +300,8 @@ "openai-chat-tool-call-id-remint.test.ts": "adapters/openai", "coding-agent-json-lines-scaling.test.ts": "providers", "usage-snapshot-digest-reuse.test.ts": "usage", "worker-embed.test.ts": "lib", "worker-embed-coverage.test.ts": "lib", "jev-stats.test.ts": "usage", - "responses-devin-401-replay.test.ts": "responses", "responses-oauth-refresh-capacity.test.ts": "responses", "responses-grok-devin-preflight.test.ts": "responses", + "responses-devin-401-replay.test.ts": "responses", + "responses-direct-chat-tool-choice.test.ts": "responses", "responses-oauth-refresh-capacity.test.ts": "responses", "responses-grok-devin-preflight.test.ts": "responses", "abort-idle-deadline.test.ts": "lib", "tool-envelope-echo-whole-line.test.ts": "adapters", "abort-race.test.ts": "adapters", "account-import.test.ts": "server", "account-import-project-fallback.test.ts": "server", diff --git a/tests/responses/responses-direct-chat-tool-choice.test.ts b/tests/responses/responses-direct-chat-tool-choice.test.ts new file mode 100644 index 00000000000..bed60460fa1 --- /dev/null +++ b/tests/responses/responses-direct-chat-tool-choice.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test"; +import { handleResponses } from "../../src/server/responses"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +test("direct translated Chat rejects an unrepresentable computer_use choice before send", async () => { + let sends = 0; + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://chat.fixture.invalid/v1", + apiKey: "fixture-key", + fetch: (async () => { + sends += 1; + throw new Error("unexpected upstream send"); + }) as typeof fetch, + }; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { fixture: provider }, + } as OcxConfig; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/model", + input: "Use the computer only.", + tools: [ + { type: "computer_use" }, + { type: "function", name: "shell", parameters: { type: "object", properties: {} } }, + ], + tool_choice: { type: "computer_use" }, + }), + }), config, { model: "", provider: "" }); + + expect(response.status).toBe(400); + expect(await response.text()).toContain("mandatory tool_choice cannot be preserved"); + expect(sends).toBe(0); +});