diff --git a/src/adapters/base.ts b/src/adapters/base.ts index e71e0db42..8789a0346 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -46,6 +46,8 @@ export interface AdapterRequest { method: string; headers: Record; body: string; + /** Custom-tool names actually lowered to upstream function calls while building this request. */ + convertedRoutedCustomToolNames?: ReadonlySet; /** Releases observation of a serialized request body after its final fetch attempt settles. */ releaseBodyObservation?: () => void; /** Exact reasoning parameter emitted by the adapter, for request-log diagnostics only. */ diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index be95fc915..27268a481 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1361,6 +1361,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): } const forward = provider.authMode === "forward"; + let convertedRoutedCustomToolNames: Set | undefined; const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; let outBody = stripPreviousResponseId( parsed._rawBody, @@ -1408,7 +1409,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = promoteClientLoadedTools(outBody); } if (provider.authMode !== "forward") { - outBody = rewriteRoutedCustomToolsForUpstream(outBody).body; + const rewritten = rewriteRoutedCustomToolsForUpstream(outBody); + outBody = rewritten.body; + convertedRoutedCustomToolNames = rewritten.names; } const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true }))))))); const body = JSON.stringify(stripDisabledReasoningSummaries( @@ -1426,6 +1429,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): headers, body, releaseBodyObservation, + ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}), }; }, diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 67b30c86e..ca928e21c 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -228,7 +228,7 @@ import { payloadRewriteAsBlockRewrite, relaySseWithBlockRewrite, } from "../sse-payload-rewrite"; -import { collectRoutedCustomToolNames, restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat"; +import { restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat"; import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair"; import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair"; import { responsesJsonToSseStream } from "../responses-json-events"; @@ -2130,9 +2130,7 @@ async function handleResponsesInner( const imageGenCallAliases = route.provider.authMode === "forward" ? new Map() : imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget); - const routedCustomToolNames = route.provider.authMode === "forward" - ? new Set() - : collectRoutedCustomToolNames(parsed._rawBody); + const routedCustomToolNames = new Set(); // Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with // previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex // REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY @@ -2161,6 +2159,11 @@ async function handleResponsesInner( releaseCodexAuthContextProbeLease(authCtx); throw error; } + if (route.provider.authMode !== "forward") { + for (const name of request.convertedRoutedCustomToolNames ?? []) { + if (toolBridgeMaps.freeformToolNames.has(name)) routedCustomToolNames.add(name); + } + } recordAdapterReasoning(logCtx, request); const actualHostKey = upstreamHostHealthKey( route.providerName, diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index f085e457c..5f1b6447e 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -27,6 +27,7 @@ Responses-compatible streaming output. - 기존 구현 및 제약 조건: 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. +- 보완된 경계: Key-auth Responses passthrough restores a routed custom call only when the adapter actually lowered that name after request normalization and the caller's `tool_choice` still authorizes it. Native `apply_patch` and tools replaced by hosted-provider policy stay in their upstream function-call form. - 다른 대안 대신 이 방식을 선택한 이유: 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. diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index b68e9b4d9..952610fa5 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -702,6 +702,248 @@ describe("routed Responses custom-tool compatibility", () => { } }); + test("handleResponses does not restore routed custom calls excluded by request policy", async () => { + const savedFetch = globalThis.fetch; + const upstreamItem = { + type: "function_call", + id: "fc_exec", + call_id: "call_exec", + name: "exec", + arguments: "{\"input\":\"ignored policy\"}", + status: "completed", + }; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + const execTool = { + type: "custom", + name: "exec", + description: "Run JavaScript", + format: { type: "grammar", syntax: "lark" }, + }; + const ordinaryTool = { + type: "function", + name: "ordinary", + description: "Ordinary function", + parameters: { type: "object" }, + }; + const cases: Array<{ + name: string; + stream: boolean; + tools: Array>; + toolChoice?: unknown; + metadata?: unknown; + }> = [ + { + name: "streaming none", + stream: true, + tools: [execTool], + toolChoice: "none", + }, + { + name: "streaming allowlist", + stream: true, + tools: [execTool, ordinaryTool], + toolChoice: { + type: "allowed_tools", + mode: "required", + tools: [{ type: "function", name: "ordinary" }], + }, + }, + { + name: "named ordinary function", + stream: false, + tools: [execTool, ordinaryTool], + toolChoice: { type: "function", name: "ordinary" }, + }, + { + name: "custom-looking metadata without a declared tool", + stream: false, + tools: [ordinaryTool], + metadata: { nested: { type: "custom", name: "exec" } }, + }, + ]; + + globalThis.fetch = (async (_input, init) => { + const outboundBody = JSON.parse(String(init?.body)) as { stream?: boolean }; + if (outboundBody.stream === true) { + const upstream = [ + frame("response.output_item.added", { + output_index: 0, + item: { ...upstreamItem, arguments: "", status: "in_progress" }, + }), + frame("response.function_call_arguments.done", { + output_index: 0, + item_id: "fc_exec", + arguments: upstreamItem.arguments, + }), + frame("response.output_item.done", { output_index: 0, item: upstreamItem }), + frame("response.completed", { + response: { id: "resp_policy", status: "completed", output: [upstreamItem] }, + }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + } + return new Response(JSON.stringify({ + id: "resp_policy", + status: "completed", + output: [upstreamItem], + }), { headers: { "content-type": "application/json" } }); + }) as typeof fetch; + + try { + for (const policyCase of cases) { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: policyCase.stream, + input: [{ role: "user", content: [{ type: "input_text", text: policyCase.name }] }], + tools: policyCase.tools, + ...(policyCase.toolChoice !== undefined ? { tool_choice: policyCase.toolChoice } : {}), + ...(policyCase.metadata !== undefined ? { metadata: policyCase.metadata } : {}), + }), + }), config, { model: "", provider: "" }); + + if (policyCase.stream) { + const clientSse = await response.text(); + expect(clientSse).toContain('"type":"function_call"'); + expect(clientSse).toContain('"id":"fc_exec"'); + expect(clientSse).toContain("response.function_call_arguments.done"); + expect(clientSse).not.toContain("custom_tool_call"); + expect(clientSse).not.toContain("ctc_exec"); + } else { + const body = await response.json() as { output: Array> }; + expect(body.output[0]).toEqual(upstreamItem); + } + } + } finally { + globalThis.fetch = savedFetch; + } + }); + + test("handleResponses preserves native apply_patch calls that were never converted", async () => { + const savedFetch = globalThis.fetch; + const upstreamItem = { + type: "function_call", + id: "fc_patch", + call_id: "call_patch", + name: "apply_patch", + arguments: "{\"patch\":\"*** Begin Patch\"}", + status: "completed", + }; + globalThis.fetch = (async () => new Response(JSON.stringify({ + id: "resp_patch", + status: "completed", + output: [upstreamItem], + }), { headers: { "content-type": "application/json" } })) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: false, + input: [{ role: "user", content: [{ type: "input_text", text: "patch" }] }], + tools: [{ + type: "custom", + name: "apply_patch", + description: "Apply a patch", + format: { type: "grammar", syntax: "lark" }, + }], + }), + }), config, { model: "", provider: "" }); + const body = await response.json() as { output: Array> }; + + expect(body.output[0]).toEqual(upstreamItem); + } finally { + globalThis.fetch = savedFetch; + } + }); + + test("handleResponses does not restore a custom image tool replaced by hosted preference", async () => { + const savedFetch = globalThis.fetch; + let outboundBody: Record | undefined; + const upstreamItem = { + type: "function_call", + id: "fc_image", + call_id: "call_image", + name: "image_gen.generate", + arguments: "{}", + status: "completed", + }; + globalThis.fetch = (async (_input, init) => { + outboundBody = JSON.parse(String(init?.body)) as Record; + return new Response(JSON.stringify({ + id: "resp_image", + status: "completed", + output: [upstreamItem], + }), { headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + modelPreferHostedTools: { "deepseek-v4-flash": ["image_generation"] }, + }, + }, + } as OcxConfig; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: false, + input: [{ role: "user", content: [{ type: "input_text", text: "draw" }] }], + tools: [{ + type: "custom", + name: "image_gen.generate", + description: "Generate an image", + format: { type: "grammar", syntax: "lark" }, + }], + }), + }), config, { model: "", provider: "" }); + const body = await response.json() as { output: Array> }; + const outboundTools = outboundBody?.tools as Array> | undefined; + + expect(outboundTools).toEqual([{ type: "image_generation" }]); + expect(body.output[0]).toEqual(upstreamItem); + } finally { + globalThis.fetch = savedFetch; + } + }); + test("handleResponses leaves custom tools native for forward-auth passthrough", async () => { const savedFetch = globalThis.fetch; let outboundBody: Record | undefined;