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..4f620650e0 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -98,8 +98,8 @@ export async function deliverAdapterResponse( ...(options.forceEmptyResponseId ? { responseId: "" } : {}), stallTimeoutSec: config.stallTimeoutSec, hideThinkingSummary: parsed.options.hideThinkingSummary, - declaredToolNames, - toolParameterSchemas, + declaredToolNames: (options.inboundWire === "chat" || options.inboundWire === "anthropic") ? undefined : declaredToolNames, + toolParameterSchemas, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), ...(routedCompaction ? { compaction: true } : {}), // Same grok-surface split as the runTurn branch above. @@ -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/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..e9209df23c 100644 --- a/tests/responses/chat-completions-endpoint.test.ts +++ b/tests/responses/chat-completions-endpoint.test.ts @@ -3593,3 +3593,207 @@ describe("chatCompletionsToResponsesBody tool-result image parts", () => { expect(() => parseRequest(body)).not.toThrow(); }); }); + +describe("chat-completions deferred tool pass-through", () => { + 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); + } + }); + + 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); + } + }); +});