From 8dd5b0ca755a8b00905beedfbb476d428012e1bf Mon Sep 17 00:00:00 2001 From: Rafael Moreira Date: Tue, 15 Sep 2026 22:04:12 -0300 Subject: [PATCH 1/3] fix(bridge): bypass undeclared tool guard for chat and anthropic inbound wires --- src/bridge/response-json.ts | 2 +- src/bridge/sse.ts | 2 +- src/server/responses/adapter-delivery.ts | 2 +- src/server/responses/passthrough-dispatch.ts | 2 +- src/server/responses/run-turn-execution.ts | 4 ++-- tests/responses/chat-completions-endpoint.test.ts | 8 ++++++++ 6 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index 6b2deec985..741b3a34f9 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -432,7 +432,7 @@ function buildResponseJSONWithBudget( } flushToolCall(); const effectiveName = normalizeDeclaredToolName(e.name, options?.declaredToolNames); - if (options?.declaredToolNames && !options.declaredToolNames.has(effectiveName)) { + if (options?.declaredToolNames && options.declaredToolNames.size > 0 && !options.declaredToolNames.has(effectiveName)) { errorEvent = { type: "error", message: `routed provider emitted undeclared client tool "${effectiveName}"; only request-declared tools may be called`, diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index 43d4f0b9f7..783b23b275 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -1008,7 +1008,7 @@ export function bridgeToResponsesSSE( : undefined; const mapped = toolNsMap?.get(effectiveName); const realName = mapped?.name ?? effectiveName; - if (options?.declaredToolNames && !options.declaredToolNames.has(effectiveName)) { + if (options?.declaredToolNames && options.declaredToolNames.size > 0 && !options.declaredToolNames.has(effectiveName)) { const failure = responseError( 502, "upstream_error", diff --git a/src/server/responses/adapter-delivery.ts b/src/server/responses/adapter-delivery.ts index 3f6330b6c4..59f8662500 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -98,7 +98,7 @@ export async function deliverAdapterResponse( ...(options.forceEmptyResponseId ? { responseId: "" } : {}), stallTimeoutSec: config.stallTimeoutSec, hideThinkingSummary: parsed.options.hideThinkingSummary, - declaredToolNames, + declaredToolNames: (options.inboundWire === "chat" || options.inboundWire === "anthropic") ? undefined : declaredToolNames, toolParameterSchemas, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), ...(routedCompaction ? { compaction: true } : {}), diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index 65eb3512da..68f4d0bc35 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -442,7 +442,7 @@ export async function preparePassthroughExchange( declaredWireToolNames.size > 0 || clientDeclaredNamelessCallTypes.size > 0 || clientExplicitWireToolCatalog - ) && route.provider.authMode !== "forward"; + ) && route.provider.authMode !== "forward" && inboundWire !== "chat" && inboundWire !== "anthropic"; }; refreshUndeclaredToolGuard(request); // A refused turn must not seed `previous_response_id` replay. The inspection branch reads the diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index 20524edd3a..f79419df6e 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -373,7 +373,7 @@ export async function executeResponsesRunTurn( ...(options.forceEmptyResponseId ? { responseId: "" } : {}), stallTimeoutSec: config.stallTimeoutSec, hideThinkingSummary: parsed.options.hideThinkingSummary, - declaredToolNames, + declaredToolNames: (inboundWire === "chat" || inboundWire === "anthropic") ? undefined : declaredToolNames, toolParameterSchemas, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), ...(routedCompaction ? { compaction: true } : {}), @@ -443,7 +443,7 @@ export async function executeResponsesRunTurn( replayCacheScope: parsed._reasoningReplayScope, hideThinkingSummary: parsed.options.hideThinkingSummary, toolNsMap, - declaredToolNames, + declaredToolNames: (inboundWire === "chat" || inboundWire === "anthropic") ? undefined : declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames, diff --git a/tests/responses/chat-completions-endpoint.test.ts b/tests/responses/chat-completions-endpoint.test.ts index 5eee77907c..d23a857b8b 100644 --- a/tests/responses/chat-completions-endpoint.test.ts +++ b/tests/responses/chat-completions-endpoint.test.ts @@ -3593,3 +3593,11 @@ describe("chatCompletionsToResponsesBody tool-result image parts", () => { expect(() => parseRequest(body)).not.toThrow(); }); }); + +describe("chat-completions deferred tool pass-through", () => { + test("allows undeclared tool call emitted by model under chat inbound wire", async () => { + // Ensures Chat Completions clients with deferred catalogs (like Command Code) + // receive model tool calls without triggering the 502 undeclared tool guard. + expect(true).toBe(true); + }); +}); From 71248cc74d33776071118409962bee52620fcf68 Mon Sep 17 00:00:00 2001 From: Rafael Moreira Date: Tue, 15 Sep 2026 22:28:34 -0300 Subject: [PATCH 2/3] fix(responses): apply inbound wire bypass to buffered delivery and add endpoint regression tests Co-authored-by: Rafael Moreira --- src/server/responses/adapter-delivery.ts | 2 +- .../chat-completions-endpoint.test.ts | 172 +++++++++++++++++- 2 files changed, 169 insertions(+), 5 deletions(-) diff --git a/src/server/responses/adapter-delivery.ts b/src/server/responses/adapter-delivery.ts index 59f8662500..3a6674e7c2 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -173,7 +173,7 @@ export async function deliverAdapterResponse( replayCacheScope: parsed._reasoningReplayScope, hideThinkingSummary: parsed.options.hideThinkingSummary, toolNsMap, - declaredToolNames, + declaredToolNames: (options.inboundWire === "chat" || options.inboundWire === "anthropic") ? undefined : declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames, diff --git a/tests/responses/chat-completions-endpoint.test.ts b/tests/responses/chat-completions-endpoint.test.ts index d23a857b8b..10e1ad0ea2 100644 --- a/tests/responses/chat-completions-endpoint.test.ts +++ b/tests/responses/chat-completions-endpoint.test.ts @@ -3595,9 +3595,173 @@ describe("chatCompletionsToResponsesBody tool-result image parts", () => { }); describe("chat-completions deferred tool pass-through", () => { - test("allows undeclared tool call emitted by model under chat inbound wire", async () => { - // Ensures Chat Completions clients with deferred catalogs (like Command Code) - // receive model tool calls without triggering the 502 undeclared tool guard. - expect(true).toBe(true); + function mockChatUpstreamWithToolCall(toolName = "todo_write") { + return Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (!url.pathname.endsWith("/chat/completions")) { + return Response.json({ error: { message: `unexpected path ${url.pathname}` } }, { status: 404 }); + } + let isStreaming = true; + try { + const body = (await req.json()) as Record; + if (body.stream === false) isStreaming = false; + } catch { /* keep default */ } + + if (!isStreaming) { + return Response.json({ + id: "chatcmpl-test", + object: "chat.completion", + created: Date.now(), + model: "mock/test-model", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_undeclared_1", + type: "function", + function: { + name: toolName, + arguments: "{\"path\":\"todo.md\"}", + }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }); + } + + const frames = [ + `data: ${JSON.stringify({ + choices: [ + { + index: 0, + delta: { + role: "assistant", + tool_calls: [ + { + index: 0, + id: "call_undeclared_1", + type: "function", + function: { name: toolName, arguments: "" }, + }, + ], + }, + }, + ], + })}\n\n`, + `data: ${JSON.stringify({ + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + function: { arguments: "{\"path\":\"todo.md\"}" }, + }, + ], + }, + }, + ], + })}\n\n`, + `data: ${JSON.stringify({ + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 10, completion_tokens: 15 }, + })}\n\n`, + "data: [DONE]\n\n", + ]; + return new Response(frames.join(""), { headers: { "Content-Type": "text/event-stream" } }); + }, + }); + } + + test("relays undeclared function call when client streams with partial tools declared", async () => { + const upstream = mockChatUpstreamWithToolCall("todo_write"); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "mock/test-model", + stream: true, + messages: [{ role: "user", content: "write to todo" }], + tools: [ + { + type: "function", + function: { + name: "lookup", + description: "lookup symbol", + parameters: { type: "object", properties: { q: { type: "string" } } }, + }, + }, + ], + }), + }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type") ?? "").toContain("text/event-stream"); + const text = await response.text(); + expect(text).toContain("todo_write"); + expect(text).toContain("call_undeclared_1"); + expect(text).not.toContain("502"); + expect(text).not.toContain("undeclared client tool"); + } finally { + await server.stop(true); + upstream.stop(true); + } + }); + + test("relays undeclared function call in buffered non-streaming mode with partial tools declared", async () => { + const upstream = mockChatUpstreamWithToolCall("todo_write"); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "mock/test-model", + stream: false, + messages: [{ role: "user", content: "write to todo" }], + tools: [ + { + type: "function", + function: { + name: "lookup", + description: "lookup symbol", + parameters: { type: "object", properties: { q: { type: "string" } } }, + }, + }, + ], + }), + }); + + expect(response.status).toBe(200); + const json = (await response.json()) as { + choices?: Array<{ + message?: { + tool_calls?: Array<{ + id?: string; + function?: { name?: string; arguments?: string }; + }>; + }; + }>; + }; + expect(json.choices?.[0]?.message?.tool_calls?.[0]?.function?.name).toBe("todo_write"); + } finally { + await server.stop(true); + upstream.stop(true); + } }); }); From 0d654b18467fcf9dd90e64f967a2cfd037990c8b Mon Sep 17 00:00:00 2001 From: Rafael Moreira Date: Wed, 16 Sep 2026 07:25:29 -0300 Subject: [PATCH 3/3] test(responses): cover responses fail-closed regression and fix delivery indent - Add regression test asserting the responses wire still enforces the fail-closed guard on undeclared tools emitted by upstream (#1700). - Align toolParameterSchemas indentation in adapter-delivery.ts. Co-authored-by: Rafael Moreira --- src/server/responses/adapter-delivery.ts | 2 +- .../chat-completions-endpoint.test.ts | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/server/responses/adapter-delivery.ts b/src/server/responses/adapter-delivery.ts index 3a6674e7c2..4f620650e0 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -99,7 +99,7 @@ export async function deliverAdapterResponse( stallTimeoutSec: config.stallTimeoutSec, hideThinkingSummary: parsed.options.hideThinkingSummary, declaredToolNames: (options.inboundWire === "chat" || options.inboundWire === "anthropic") ? undefined : declaredToolNames, - toolParameterSchemas, + toolParameterSchemas, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), ...(routedCompaction ? { compaction: true } : {}), // Same grok-surface split as the runTurn branch above. diff --git a/tests/responses/chat-completions-endpoint.test.ts b/tests/responses/chat-completions-endpoint.test.ts index 10e1ad0ea2..e9209df23c 100644 --- a/tests/responses/chat-completions-endpoint.test.ts +++ b/tests/responses/chat-completions-endpoint.test.ts @@ -3764,4 +3764,36 @@ describe("chat-completions deferred tool pass-through", () => { upstream.stop(true); } }); + + test("responses wire still enforces 502 fail-closed guard when upstream emits undeclared tool (#1700)", async () => { + const upstream = mockChatUpstreamWithToolCall("todo_write"); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "mock/test-model", + stream: true, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "write to todo" }] }], + tools: [ + { + type: "function", + name: "lookup", + description: "lookup symbol", + parameters: { type: "object", properties: { q: { type: "string" } } }, + }, + ], + }), + }); + + const text = await response.text(); + expect(text).toContain("undeclared client tool"); + expect(text).toContain("response.failed"); + } finally { + await server.stop(true); + upstream.stop(true); + } + }); });