diff --git a/src/bridge.ts b/src/bridge.ts index b6a61632af..c117b738f7 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -186,6 +186,8 @@ export function bridgeToResponsesSSE( * from this callback instead of re-parsing the bridged SSE. */ onUsage?: (usage: OcxUsage | undefined) => void; + /** Request-visible tool names. When present, an upstream call outside this set fails closed. */ + declaredToolNames?: ReadonlySet; translatorBudget?: TranslatorBudget; /** * Conversation identity for the reasoning replay cache (issue #950). @@ -974,6 +976,23 @@ export function bridgeToResponsesSSE( if (currentToolCall) closeCurrentToolCall(); const mapped = toolNsMap?.get(event.name); const realName = mapped?.name ?? event.name; + if (options?.declaredToolNames && !options.declaredToolNames.has(event.name)) { + const failure = responseError( + 502, + "upstream_error", + `routed provider emitted undeclared client tool "${event.name}"; only request-declared tools may be called`, + ); + emit("response.failed", { + response: { + ...responseSnapshot("failed", finishedItems), + error: failure, + last_error: failure, + }, + }); + reportTerminal("failed"); + terminalEvent = true; + break; + } const ns = mapped?.namespace; const toolSearch = toolSearchToolNames?.has(realName) ?? false; const freeform = !toolSearch && (freeformToolNames?.has(realName) ?? false); @@ -1359,6 +1378,8 @@ function buildResponseJSONWithBudget( options?: { hideThinkingSummary?: boolean; toolNsMap?: Map; + /** Request-visible tool names. When present, an upstream call outside this set fails closed. */ + declaredToolNames?: ReadonlySet; freeformToolNames?: Set; toolSearchToolNames?: Set; /** Remote compaction v2 turn — append one synthetic compaction output item (see bridgeToResponsesSSE). */ @@ -1641,6 +1662,15 @@ function buildResponseJSONWithBudget( rememberReasoningForCall(e.id, rawReasoningForNextToolCall, replayCacheScope); } flushToolCall(); + if (options?.declaredToolNames && !options.declaredToolNames.has(e.name)) { + errorEvent = { + type: "error", + message: `routed provider emitted undeclared client tool "${e.name}"; only request-declared tools may be called`, + status: 502, + errorType: "upstream_error", + }; + break; + } currentToolCallId = e.id; budget?.openCall(e.id); currentToolCallName = e.name; diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index 21579f7667..daca2414ce 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -102,18 +102,22 @@ import type { TranslatorBudget } from "../../lib/translator-budget"; export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: TranslatorBudget): { toolNsMap: Map; + declaredToolNames: Set; freeformToolNames: Set; toolSearchToolNames: Set; } { const toolNsMap = new Map(); + const declaredToolNames = new Set(); const freeformToolNames = new Set(); const toolSearchToolNames = new Set(); const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice); for (const t of parsed.context.tools ?? []) { // Upstream output is untrusted: only restore calls for tools the caller authorized. if (!toolAllowed(t)) continue; + const wireName = namespacedToolName(t.namespace, t.name); + budget?.chargeRetained(new TextEncoder().encode(wireName).byteLength, { kind: "retained_collectors" }); + declaredToolNames.add(wireName); if (t.namespace) { - const wireName = namespacedToolName(t.namespace, t.name); budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([wireName, t.namespace, t.name])).byteLength, { kind: "retained_collectors" }); toolNsMap.set(wireName, { namespace: t.namespace, name: t.name }); } @@ -126,7 +130,7 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato toolSearchToolNames.add(t.name); } } - return { toolNsMap, freeformToolNames, toolSearchToolNames }; + return { toolNsMap, declaredToolNames, freeformToolNames, toolSearchToolNames }; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 7fd2b87b03..caf6e147c7 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -3075,7 +3075,7 @@ async function handleResponsesInner( } }; - const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; + const { toolNsMap, declaredToolNames, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; if (parsed.stream) { void runTurn(); let eventSource: AsyncIterable = queue.stream(); @@ -3101,6 +3101,7 @@ async function handleResponsesInner( ...(options.forceEmptyResponseId ? { responseId: "" } : {}), stallTimeoutSec: config.stallTimeoutSec, hideThinkingSummary: parsed.options.hideThinkingSummary, + declaredToolNames, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), ...(routedCompaction ? { compaction: true } : {}), onUsage: usage => { @@ -3147,6 +3148,7 @@ async function handleResponsesInner( replayCacheScope: parsed._reasoningReplayScope, hideThinkingSummary: parsed.options.hideThinkingSummary, toolNsMap, + declaredToolNames, freeformToolNames, toolSearchToolNames, ...(routedCompaction ? { compaction: true } : {}), @@ -3835,7 +3837,7 @@ async function handleResponsesInner( continuation: fetchTerminalGuardContinuation, }) : initialEventStream; - const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; + const { toolNsMap, declaredToolNames, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; const sseStream = bridgeToResponsesSSE( eventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, () => upstream.abort(), 2_000, @@ -3845,6 +3847,7 @@ async function handleResponsesInner( ...(options.forceEmptyResponseId ? { responseId: "" } : {}), stallTimeoutSec: config.stallTimeoutSec, hideThinkingSummary: parsed.options.hideThinkingSummary, + declaredToolNames, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), ...(routedCompaction ? { compaction: true } : {}), onUsage: usage => { @@ -3895,13 +3898,14 @@ async function handleResponsesInner( } finally { cleanupUpstreamAbort(); } - const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; + const { toolNsMap, declaredToolNames, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; let providerState: OcxProviderContinuationState | undefined; const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, { translatorBudget, replayCacheScope: parsed._reasoningReplayScope, hideThinkingSummary: parsed.options.hideThinkingSummary, toolNsMap, + declaredToolNames, freeformToolNames, toolSearchToolNames, ...(routedCompaction ? { compaction: true } : {}), diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 1b09e8fdde..d50b8b09f1 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -22,6 +22,14 @@ executor contract. Main-request migration must not treat that branch as fixed-tr provider, lets the selected adapter speak the upstream protocol, then bridges adapter events back to Responses-compatible streaming output. +[Decision Log] +- 목적과 의도: Prevent routed models from turning invented or neighboring-agent tool names into client-executable Responses calls. +- 기존 구현 및 제약 조건: The request catalog already controlled custom-tool restoration and the non-OpenAI prompt nudge, but an undeclared upstream name still fell through as an ordinary `function_call`; Codex then reduced the mismatch to a bare `aborted` result. +- 검토한 주요 대안: Rely only on prompt guidance; automatically translate undeclared `apply_patch` into Code Mode; validate returned names against the request-visible catalog at the final bridge. +- 선택한 방식: Retain the allowed wire-name set with the existing bridge maps and fail the turn with an explicit compatibility error before emitting any undeclared tool item. +- 다른 대안 대신 이 방식을 선택한 이유: Model guidance is not an enforcement boundary, while automatic translation would invent executable caller intent and arguments after generation. +- 장점, 단점 및 영향: Streaming and non-streaming routed responses now fail closed with an actionable provider-contract error; providers that emit aliases they never advertised must correct their adapter mapping instead of relying on client abort behavior. + The option-aware `openai` provider uses `openai-responses` with `authMode: "forward"`. Pool mode resolves main plus added accounts through affinity/quota/cooldown ownership; Direct forwards only the allowed Codex/OpenAI auth/session headers from the current request and short-circuits pool diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 8480292525..b28a5c68cd 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -342,6 +342,19 @@ describe("Responses bridge reasoning and usage parity", () => { expect(json.status).toBe("completed"); }); + test("non-streaming bridge fails closed when upstream calls an undeclared tool", () => { + const json = buildResponseJSON([ + { type: "tool_call_start", id: "call_bad", name: "apply_patch" }, + { type: "tool_call_delta", arguments: '{"input":"*** Begin Patch"}' }, + { type: "tool_call_end" }, + { type: "done" }, + ], "deepseek/deepseek-v4-flash", { declaredToolNames: new Set(["exec"]) }); + + expect(json.status).toBe("failed"); + expect(json.output).toEqual([]); + expect((json.error as Record).message).toContain("undeclared client tool"); + }); + test("raw reasoning closes before later text output and preserves ordering", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ { type: "reasoning_raw_delta", text: "raw" }, diff --git a/tests/responses-parser.test.ts b/tests/responses-parser.test.ts index 5b35e8b10d..1db57b8024 100644 --- a/tests/responses-parser.test.ts +++ b/tests/responses-parser.test.ts @@ -124,23 +124,27 @@ describe("Responses parser", () => { expect([...maps.toolNsMap]).toEqual([ ["mcp__tools__safe", { namespace: "mcp__tools", name: "safe" }], ]); + expect([...maps.declaredToolNames]).toEqual(["mcp__tools__safe", "apply_patch"]); expect([...maps.freeformToolNames]).toEqual(["apply_patch"]); expect([...maps.toolSearchToolNames]).toEqual([]); parsed.options.toolChoice = { allowedTools: ["mcp__tools__safe"], mode: "required" }; maps = buildToolBridgeMaps(parsed); expect([...maps.toolNsMap.keys()]).toEqual(["mcp__tools__safe"]); + expect([...maps.declaredToolNames]).toEqual(["mcp__tools__safe"]); expect([...maps.freeformToolNames]).toEqual([]); parsed.options.toolChoice = { name: "tool_search" }; maps = buildToolBridgeMaps(parsed); expect([...maps.toolNsMap]).toEqual([]); + expect([...maps.declaredToolNames]).toEqual(["tool_search"]); expect([...maps.freeformToolNames]).toEqual([]); expect([...maps.toolSearchToolNames]).toEqual(["tool_search"]); parsed.options.toolChoice = "none"; maps = buildToolBridgeMaps(parsed); expect([...maps.toolNsMap]).toEqual([]); + expect([...maps.declaredToolNames]).toEqual([]); expect([...maps.freeformToolNames]).toEqual([]); expect([...maps.toolSearchToolNames]).toEqual([]); }); diff --git a/tests/responses-stream-tool-events.test.ts b/tests/responses-stream-tool-events.test.ts index 5fe63dd3bc..d85ede35e0 100644 --- a/tests/responses-stream-tool-events.test.ts +++ b/tests/responses-stream-tool-events.test.ts @@ -27,6 +27,23 @@ async function collectSse(stream: ReadableStream): Promise<{ event?: } describe("Responses streaming tool event contract", () => { + test("undeclared upstream tool names fail closed with a compatibility error", async () => { + const frames = await collectSse(bridgeToResponsesSSE(replay([ + { type: "tool_call_start", id: "call_bad", name: "apply_patch" }, + { type: "tool_call_delta", arguments: '{"input":"*** Begin Patch"}' }, + { type: "tool_call_end" }, + { type: "done" }, + ]), "deepseek/deepseek-v4-flash", undefined, undefined, undefined, undefined, undefined, { + declaredToolNames: new Set(["exec"]), + })); + + expect(frames.some(frame => frame.event === "response.output_item.added")).toBe(false); + expect(frames.some(frame => frame.event === "response.completed")).toBe(false); + const failed = frames.find(frame => frame.event === "response.failed")?.data.response as Record; + expect((failed.error as Record).message).toContain("undeclared client tool"); + expect((failed.error as Record).message).toContain("apply_patch"); + }); + test("adapter tool events produce OpenAI-compatible streamed function-call frames", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ { type: "tool_call_start", id: "call_1", name: "read_file" },