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
30 changes: 30 additions & 0 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
translatorBudget?: TranslatorBudget;
/**
* Conversation identity for the reasoning replay cache (issue #950).
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1359,6 +1378,8 @@ function buildResponseJSONWithBudget(
options?: {
hideThinkingSummary?: boolean;
toolNsMap?: Map<string, { namespace: string; name: string }>;
/** Request-visible tool names. When present, an upstream call outside this set fails closed. */
declaredToolNames?: ReadonlySet<string>;
freeformToolNames?: Set<string>;
toolSearchToolNames?: Set<string>;
/** Remote compaction v2 turn — append one synthetic compaction output item (see bridgeToResponsesSSE). */
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions src/server/responses/collaboration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,18 +102,22 @@ import type { TranslatorBudget } from "../../lib/translator-budget";

export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: TranslatorBudget): {
toolNsMap: Map<string, { namespace: string; name: string }>;
declaredToolNames: Set<string>;
freeformToolNames: Set<string>;
toolSearchToolNames: Set<string>;
} {
const toolNsMap = new Map<string, { namespace: string; name: string }>();
const declaredToolNames = new Set<string>();
const freeformToolNames = new Set<string>();
const toolSearchToolNames = new Set<string>();
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 });
}
Expand All @@ -126,7 +130,7 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
toolSearchToolNames.add(t.name);
}
}
return { toolNsMap, freeformToolNames, toolSearchToolNames };
return { toolNsMap, declaredToolNames, freeformToolNames, toolSearchToolNames };
}


Expand Down
10 changes: 7 additions & 3 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AdapterEvent> = queue.stream();
Expand All @@ -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 => {
Expand Down Expand Up @@ -3147,6 +3148,7 @@ async function handleResponsesInner(
replayCacheScope: parsed._reasoningReplayScope,
hideThinkingSummary: parsed.options.hideThinkingSummary,
toolNsMap,
declaredToolNames,
freeformToolNames,
toolSearchToolNames,
...(routedCompaction ? { compaction: true } : {}),
Expand Down Expand Up @@ -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,
Expand All @@ -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 => {
Expand Down Expand Up @@ -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 } : {}),
Expand Down
8 changes: 8 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions tests/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).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" },
Expand Down
4 changes: 4 additions & 0 deletions tests/responses-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
Expand Down
17 changes: 17 additions & 0 deletions tests/responses-stream-tool-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@ async function collectSse(stream: ReadableStream<Uint8Array>): 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<string, unknown>;
expect((failed.error as Record<string, unknown>).message).toContain("undeclared client tool");
expect((failed.error as Record<string, unknown>).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" },
Expand Down
Loading