From 128f06d645a35c4b873f3bf5b5c15dee1b5cec27 Mon Sep 17 00:00:00 2001 From: ducphamtien-fonos Date: Fri, 18 Sep 2026 21:59:24 +0700 Subject: [PATCH] fix(translator): support Responses custom tool choice (#13128) * fix(translator): support Responses custom tool choice * fix(translator): preserve custom tools across response paths * docs(changelog): add fragment for Responses custom tool choice fix Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Pham Tien Duc Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: ducphamtien-fonos --- .../13128-responses-custom-tool-choice.md | 1 + open-sse/handlers/chatCore.ts | 2 + .../chatCore/nonStreamingClientTranslate.ts | 40 +++++++-- .../chatCore/nonStreamingProviderLeg.ts | 2 + .../translator/request/openai-responses.ts | 5 +- .../openai-responses/additionalTools.ts | 12 ++- src/lib/skills/toolLoopTypes.ts | 1 + .../non-streaming-client-translate.test.ts | 88 +++++++++++++++++++ tests/unit/non-streaming-provider-leg.test.ts | 60 +++++++++++-- tests/unit/responses-additional-tools.test.ts | 5 +- tests/unit/responses-handler.test.ts | 68 +++++++++++++- ...-openai-responses-custom-tool-1007.test.ts | 34 +++++++ 12 files changed, 299 insertions(+), 19 deletions(-) create mode 100644 changelog.d/fixes/13128-responses-custom-tool-choice.md diff --git a/changelog.d/fixes/13128-responses-custom-tool-choice.md b/changelog.d/fixes/13128-responses-custom-tool-choice.md new file mode 100644 index 0000000000..5a9f7e4c13 --- /dev/null +++ b/changelog.d/fixes/13128-responses-custom-tool-choice.md @@ -0,0 +1 @@ +- **fix(translator):** recognize `tool_choice.type: "custom"` in Responses→Chat translation and propagate custom tool names (including namespace-flattened ones) across both the streaming and non-streaming provider legs, so non-streaming Responses clients get `custom_tool_call`/raw `input` instead of `function_call`/JSON arguments ([#13128](https://github.com/diegosouzapw/OmniRoute/pull/13128)) — thanks @ducphamtien-fonos diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 0612a8fafe..098eb3a26d 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -5048,6 +5048,7 @@ export async function handleChatCore({ effectiveModel: currentModel, translatedBody: translatedBody as Record, toolNameMap, + customToolNames, requestToolIdentityMap, reasoningCacheScope, reasoningReplayHistory, @@ -5220,6 +5221,7 @@ export async function handleChatCore({ effectiveModel: currentModel, translatedBody: translatedBody as Record, toolNameMap, + customToolNames, requestToolIdentityMap, reasoningCacheScope, reasoningReplayHistory, diff --git a/open-sse/handlers/chatCore/nonStreamingClientTranslate.ts b/open-sse/handlers/chatCore/nonStreamingClientTranslate.ts index 1888d6c11c..1b680c6d8d 100644 --- a/open-sse/handlers/chatCore/nonStreamingClientTranslate.ts +++ b/open-sse/handlers/chatCore/nonStreamingClientTranslate.ts @@ -55,6 +55,7 @@ export function translateNonStreamingClientResponse( model, requestBody, responseToolNameMap, + customToolNames, requestToolIdentityMap, reasoningCacheScope, clientHeaders, @@ -123,19 +124,46 @@ export function translateNonStreamingClientResponse( } catch { // Cache capture is non-critical — never block the response } - // ── Sanitize response for SDK compatibility ──────────────────────────────── if (clientResponseFormat === FORMATS.OPENAI_RESPONSES) { translatedResponse = sanitizeResponsesApiResponse(translatedResponse); - // Restore {namespace, name} on function_call items for round-trip closure + const sanitizedOutput = translatedResponse?.output; + if (customToolNames && Array.isArray(sanitizedOutput)) { + for (const item of sanitizedOutput) { + if (item?.type !== "function_call" || !customToolNames.has(item.name)) continue; + + let rawInput = item.arguments; + if (typeof item.arguments === "string") { + try { + const parsed = JSON.parse(item.arguments); + if (parsed && typeof parsed.input === "string") rawInput = parsed.input; + } catch { + // Non-JSON arguments are already the best available raw input. + } + } else if ( + item.arguments && + typeof item.arguments === "object" && + typeof item.arguments.input === "string" + ) { + rawInput = item.arguments.input; + } + + item.type = "custom_tool_call"; + item.input = typeof rawInput === "string" ? rawInput : JSON.stringify(rawInput ?? ""); + item.status ??= "completed"; + delete item.arguments; + } + } + + // Restore {namespace, name} on function_call / custom_tool_call items for round-trip + // closure — only after custom classification above, which uses wire names. // (#7936). Falls back to splitting the flattened `mcp__`-namespaced wire // name itself when the per-request identity map has no entry — e.g. a // follow-up turn in the same session that didn't re-declare its // `type:"namespace"` tools (#12996). - const responseOutput = translatedResponse?.output; - if (Array.isArray(responseOutput)) { - for (const item of responseOutput) { - if (item?.type !== "function_call") continue; + if (Array.isArray(sanitizedOutput)) { + for (const item of sanitizedOutput) { + if (item?.type !== "function_call" && item?.type !== "custom_tool_call") continue; // `requestToolIdentityMap` is typed as Map, but // extractRequestToolIdentityMap() (chatCore/requestToolIdentity.ts) falls // back to `_toolNameMap` when no namespace tools were present — and that diff --git a/open-sse/handlers/chatCore/nonStreamingProviderLeg.ts b/open-sse/handlers/chatCore/nonStreamingProviderLeg.ts index 4dc8dec92a..37553d06b0 100644 --- a/open-sse/handlers/chatCore/nonStreamingProviderLeg.ts +++ b/open-sse/handlers/chatCore/nonStreamingProviderLeg.ts @@ -88,6 +88,7 @@ export interface ProviderLegInput { effectiveModel?: string; translatedBody?: Record; toolNameMap?: Map | null; + customToolNames?: ReadonlySet; requestToolIdentityMap?: Map | null; reasoningCacheScope?: string | null; /** Normalized OpenAI transcript reported by translateRequest for Responses-API @@ -290,6 +291,7 @@ function finishOk( input.reasoningReplayHistory ?? null, responseToolNameMap, + customToolNames: input.customToolNames, requestToolIdentityMap: input.requestToolIdentityMap ?? null, reasoningCacheScope: input.reasoningCacheScope ?? null, clientHeaders: input.clientHeaders ?? null, diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index a883250ee0..95ee429fcc 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -762,7 +762,10 @@ export function openaiResponsesToOpenAIRequest( ) { const tc = toRecord(result.tool_choice); const tcType = toString(tc.type); - if (tcType === "function" && tc.name !== undefined && !tc.function) { + // Custom/freeform tools are normalized to Chat function tools with an { input: string } + // schema above. Force the normalized function here while response-side custom-tool metadata + // restores custom_tool_call and raw input for the Responses client. + if ((tcType === "function" || tcType === "custom") && tc.name !== undefined && !tc.function) { result.tool_choice = { type: "function", function: { name: tc.name } }; } else if (tcType === "local_shell") { result.tool_choice = { type: "function", function: { name: "shell" } }; diff --git a/open-sse/translator/request/openai-responses/additionalTools.ts b/open-sse/translator/request/openai-responses/additionalTools.ts index 57e804adcb..bad00b1532 100644 --- a/open-sse/translator/request/openai-responses/additionalTools.ts +++ b/open-sse/translator/request/openai-responses/additionalTools.ts @@ -1,3 +1,5 @@ +import { flattenNamespaceToolName } from "./namespaceFlatten.ts"; + type JsonRecord = Record; function toRecord(value: unknown): JsonRecord { @@ -117,12 +119,16 @@ export function collectResponsesCustomToolNames( inputItems: unknown[] ): Set { const names = new Set(); - const visit = (tools: unknown[]) => { + const visit = (tools: unknown[], namespaceName = "") => { for (const toolValue of tools) { const tool = toRecord(toolValue); const name = toolName(toolValue); - if (tool.type === "custom" && name) names.add(name); - if (tool.type === "namespace" && Array.isArray(tool.tools)) visit(tool.tools); + if (tool.type === "custom" && name) { + names.add(flattenNamespaceToolName(namespaceName, name)); + } + if (tool.type === "namespace" && Array.isArray(tool.tools)) { + visit(tool.tools, name); + } } }; visit(collectResponsesTools(rootTools, inputItems)); diff --git a/src/lib/skills/toolLoopTypes.ts b/src/lib/skills/toolLoopTypes.ts index ad7147b94e..444364dec8 100644 --- a/src/lib/skills/toolLoopTypes.ts +++ b/src/lib/skills/toolLoopTypes.ts @@ -191,6 +191,7 @@ export interface NonStreamingClientTranslateInput { */ historyMessages?: unknown[] | null; responseToolNameMap: Map | null; + customToolNames?: ReadonlySet; requestToolIdentityMap: Map | null; reasoningCacheScope: string | null; clientHeaders: Headers | Record | null; diff --git a/tests/unit/non-streaming-client-translate.test.ts b/tests/unit/non-streaming-client-translate.test.ts index d96b931773..b2045d4717 100644 --- a/tests/unit/non-streaming-client-translate.test.ts +++ b/tests/unit/non-streaming-client-translate.test.ts @@ -270,6 +270,94 @@ test("Responses API format: sanitizeResponsesApiResponse is applied", () => { assert.equal(output[0]?.name, "get_weather", "#7936 restore original name"); }); +test("Responses API format: restores non-stream custom tool calls before namespace identity", () => { + const input = baseInput({ + responsePayloadFormat: FORMATS.OPENAI_RESPONSES, + clientResponseFormat: FORMATS.OPENAI_RESPONSES, + sourceFormat: FORMATS.OPENAI_RESPONSES, + responseBody: { + id: "resp_custom", + object: "response", + status: "completed", + output: [ + { + id: "fc_call_1", + type: "function_call", + call_id: "call_1", + name: "functions__exec", + arguments: '{"input":"printf \'nonstream-ok\\\\n\'"}', + }, + ], + usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, + }, + customToolNames: new Set(["functions__exec"]), + requestToolIdentityMap: new Map([ + ["functions__exec", { namespace: "functions", name: "exec" }], + ]), + }); + + const result = translateNonStreamingClientResponse(input); + const output = result.response.output as Array>; + assert.deepEqual(output[0], { + id: "fc_call_1", + type: "custom_tool_call", + call_id: "call_1", + name: "exec", + input: "printf 'nonstream-ok\\n'", + status: "completed", + namespace: "functions", + }); +}); + +test("Responses API format: classifies custom calls synthesized from Kiro chat output", () => { + const input = baseInput({ + responsePayloadFormat: "kiro", + clientResponseFormat: FORMATS.OPENAI_RESPONSES, + sourceFormat: FORMATS.OPENAI_RESPONSES, + responseBody: { + id: "chatcmpl_custom", + object: "chat.completion", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_kiro_1", + type: "function", + function: { + name: "functions__exec", + arguments: '{"input":"printf \'kiro-nonstream-ok\\\\n\'"}', + }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }, + customToolNames: new Set(["functions__exec"]), + requestToolIdentityMap: new Map([ + ["functions__exec", { namespace: "functions", name: "exec" }], + ]), + }); + + const result = translateNonStreamingClientResponse(input); + const output = result.response.output as Array>; + assert.deepEqual(output[0], { + id: "fc_call_kiro_1", + type: "custom_tool_call", + call_id: "call_kiro_1", + name: "exec", + input: "printf 'kiro-nonstream-ok\\n'", + status: "completed", + namespace: "functions", + }); +}); + test("#12370: alias-shaped requestToolIdentityMap must not blank out function_call name", () => { // extractRequestToolIdentityMap() falls back to the `_toolNameMap` side channel // when no namespace tools are present. For Gemini/Claude pivots that side diff --git a/tests/unit/non-streaming-provider-leg.test.ts b/tests/unit/non-streaming-provider-leg.test.ts index 5001396f64..f4f17e0a38 100644 --- a/tests/unit/non-streaming-provider-leg.test.ts +++ b/tests/unit/non-streaming-provider-leg.test.ts @@ -84,6 +84,54 @@ test("200 JSON: returns ok with usage and receipt", async () => { assert.equal(result.receipt.termination, "completed"); }); +test("Responses custom tool metadata survives request-body translation in provider leg", async () => { + const upstreamBody = { + id: "resp_custom", + object: "response", + status: "completed", + output: [ + { + id: "fc_call_1", + type: "function_call", + call_id: "call_1", + name: "functions__exec", + arguments: '{"input":"printf \'nonstream-ok\\\\n\'"}', + }, + ], + usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, + }; + const result = await runNonStreamingProviderLeg( + baseInput({ + // Request conversion has already downgraded the source declaration by this seam. + sourceBody: { + model: "gpt-5.6-sol", + tools: [{ type: "function", function: { name: "functions__exec" } }], + }, + sourceFormat: "openai-responses", + targetFormat: "openai-responses", + clientResponseFormat: "openai-responses", + translatedBody: { model: "gpt-5.6-sol" }, + customToolNames: new Set(["functions__exec"]), + requestToolIdentityMap: new Map([ + ["functions__exec", { namespace: "functions", name: "exec" }], + ]), + executeProviderRequest: async () => makeExecutorResult(upstreamBody), + }) + ); + + assert.equal(result.kind, "ok"); + if (result.kind !== "ok") return; + assert.deepEqual(result.response.output[0], { + id: "fc_call_1", + type: "custom_tool_call", + call_id: "call_1", + name: "exec", + input: "printf 'nonstream-ok\\n'", + status: "completed", + namespace: "functions", + }); +}); + test("runProviderExecution is called once with policy; first send skips executeProviderRequest", async () => { let pipelineCalls = 0; let executorCalls = 0; @@ -860,11 +908,7 @@ test("dynamic connection: ID changes between initial and retry -> 409 on retry p assert.equal(result.result.status, 409); assert.equal(result.result.errorCode, "LEASE_CONNECTION_MISMATCH"); } - assert.equal( - executorCallCount, - 1, - "retry executor must not run after the lease already moved" - ); + assert.equal(executorCallCount, 1, "retry executor must not run after the lease already moved"); }); /* -- fallback with real parsed response ----------------------------------- */ @@ -1070,7 +1114,11 @@ test("empty-content fallback with invalid SSE body is 502, not 200 empty", async }); const result = await runNonStreamingProviderLeg(input); assert.ok(executorCallCount >= 2, "should attempt fallback"); - assert.equal(result.kind, "error", "invalid SSE on fallback must not finishOk the empty original"); + assert.equal( + result.kind, + "error", + "invalid SSE on fallback must not finishOk the empty original" + ); if (result.kind !== "error") return; assert.equal(result.result.status, 502); assert.equal(result.result.errorCode, "invalid_sse_payload"); diff --git a/tests/unit/responses-additional-tools.test.ts b/tests/unit/responses-additional-tools.test.ts index abb8774b2f..eff22527ae 100644 --- a/tests/unit/responses-additional-tools.test.ts +++ b/tests/unit/responses-additional-tools.test.ts @@ -246,7 +246,10 @@ test("Responses custom metadata includes additional and namespaced custom tools" ], }, ]; - assert.deepEqual([...collectResponsesCustomToolNames([], input)].sort(), ["apply_diff", "exec"]); + assert.deepEqual([...collectResponsesCustomToolNames([], input)].sort(), [ + "exec", + "server__apply_diff", + ]); }); test("Responses source format enables custom metadata independently of model apiFormat", () => { diff --git a/tests/unit/responses-handler.test.ts b/tests/unit/responses-handler.test.ts index 0791c5b9c6..3246e11466 100644 --- a/tests/unit/responses-handler.test.ts +++ b/tests/unit/responses-handler.test.ts @@ -429,6 +429,68 @@ test("handleResponsesCore rejects invalid Responses API input that cannot be tra ); }); +test("handleResponsesCore restores a top-level custom tool with automatic selection", async () => { + const { result, call } = await invokeResponsesCore({ + body: { + model: "gpt-5.6-sol", + input: 'You must call functions__exec with exactly: text("ok")', + tools: [ + { + type: "custom", + name: "functions__exec", + description: "Execute freeform code", + }, + ], + stream: false, + }, + responseFactory: () => + buildToolCallSseResponse("functions__exec", '{"input":"text(\\"ok\\")"}'), + }); + + assert.equal(call.body.tools[0].type, "function"); + assert.equal(call.body.tools[0].function.name, "functions__exec"); + const sse = await result.response.text(); + assert.match(sse, /"type":"custom_tool_call"/); + assert.match(sse, /"call_id":"call_1"/); + assert.match(sse, /"input":"text\(\\"ok\\"\)"/); + assert.match(sse, /event: response\.completed/); + assert.doesNotMatch(sse, /"type":"function_call","arguments"/); +}); + +test("handleResponsesCore maps forced custom tool_choice and preserves its lifecycle", async () => { + const { result, call } = await invokeResponsesCore({ + body: { + model: "gpt-5.6-sol", + input: 'Call functions__exec with exactly: text("ok")', + tools: [ + { + type: "custom", + name: "functions__exec", + description: "Execute freeform code", + }, + ], + tool_choice: { + type: "custom", + name: "functions__exec", + }, + stream: false, + }, + responseFactory: () => + buildToolCallSseResponse("functions__exec", '{"input":"text(\\"ok\\")"}'), + }); + + assert.deepEqual(call.body.tool_choice, { + type: "function", + function: { name: "functions__exec" }, + }); + const sse = await result.response.text(); + assert.match(sse, /"type":"custom_tool_call"/); + assert.match(sse, /"call_id":"call_1"/); + assert.match(sse, /"input":"text\(\\"ok\\"\)"/); + assert.match(sse, /event: response\.completed/); + assert.doesNotMatch(sse, /"type":"function_call","arguments"/); +}); + test("handleResponsesCore restores custom tools declared through additional_tools", async () => { const { result, call } = await invokeResponsesCore({ body: { @@ -480,7 +542,7 @@ test("handleResponsesCore preserves top-level tool precedence for custom-name co }); test("handleResponsesCore restores custom tools nested in namespaces", async () => { - const { result } = await invokeResponsesCore({ + const { result, call } = await invokeResponsesCore({ body: { model: "gpt-4o-mini", input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "ping" }] }], @@ -492,11 +554,13 @@ test("handleResponsesCore restores custom tools nested in namespaces", async () }, ], }, - responseFactory: () => buildToolCallSseResponse("exec", '{"input":"pong"}'), + responseFactory: () => buildToolCallSseResponse("commands__exec", '{"input":"pong"}'), }); + assert.equal(call.body.tools[0].function?.name, "commands__exec"); const sse = await result.response.text(); assert.match(sse, /"type":"custom_tool_call"/); + assert.match(sse, /"input":"pong"/); assert.doesNotMatch(sse, /"type":"function_call","arguments"/); }); diff --git a/tests/unit/translator-openai-responses-custom-tool-1007.test.ts b/tests/unit/translator-openai-responses-custom-tool-1007.test.ts index 53b9e243b4..e8c1f9b2eb 100644 --- a/tests/unit/translator-openai-responses-custom-tool-1007.test.ts +++ b/tests/unit/translator-openai-responses-custom-tool-1007.test.ts @@ -54,6 +54,40 @@ test("Responses -> Chat: custom tool is normalized to a { input: string } functi }); }); +test("Responses -> Chat: forced custom tool_choice maps to the normalized function tool", () => { + const result = openaiResponsesToOpenAIRequest( + "gpt-5.6-sol", + { + input: 'Call functions__exec with exactly: text("ok")', + tools: [ + { + type: "custom", + name: "functions__exec", + description: "Execute freeform code", + }, + ], + tool_choice: { + type: "custom", + name: "functions__exec", + }, + }, + false, + {} + ); + + assert.deepEqual(result.tool_choice, { + type: "function", + function: { name: "functions__exec" }, + }); + assert.equal(result.tools[0].function.name, "functions__exec"); + assert.deepEqual(result.tools[0].function.parameters, { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], + additionalProperties: false, + }); +}); + // Request side: custom_tool_call / custom_tool_call_output input items round-trip. test("Responses -> Chat: custom_tool_call + output items map to tool_calls and tool role (#1007)", () => { const result = openaiResponsesToOpenAIRequest(