From 75d0d7299acf07752eb6b4a88cb188c078a69836 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 21 May 2026 03:59:06 -0300 Subject: [PATCH] fix(translator): inject web_search tool in Responses-API flat shape (#2390) The omniroute_web_search fallback tool was always built in Chat Completions nested shape ({type, function:{name}}). On the Responses->Responses passthrough path nothing flattens it, so Codex/relay upstreams rejected it with 'Missing required parameter: tools[0].name'. buildFallbackTool and the tool_choice injection now emit the flat Responses-API shape ({type, name}) when the target provider speaks the Responses API. --- open-sse/services/webSearchFallback.ts | 37 +++++++--- tests/unit/web-search-fallback-format.test.ts | 74 +++++++++++++++++++ 2 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 tests/unit/web-search-fallback-format.test.ts diff --git a/open-sse/services/webSearchFallback.ts b/open-sse/services/webSearchFallback.ts index 89b18deb00..62f3f06ca5 100644 --- a/open-sse/services/webSearchFallback.ts +++ b/open-sse/services/webSearchFallback.ts @@ -109,14 +109,23 @@ function buildFallbackParameters(tool: JsonRecord): JsonRecord { }; } -function buildFallbackTool(tool: JsonRecord): JsonRecord { +function buildFallbackTool(tool: JsonRecord, targetFormat?: string | null): JsonRecord { + const name = OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME; + const description = buildFallbackDescription(tool); + const parameters = buildFallbackParameters(tool); + + // Responses API expects FLAT function tools ({ type, name, parameters }), whereas + // Chat Completions expects NESTED ({ type, function: { name, parameters } }). On the + // Responses→Responses passthrough path nothing flattens the injected tool, so a nested + // shape reaches the upstream as `tools[0].function.name` and is rejected with + // "Missing required parameter: 'tools[0].name'." (issue #2390). + if (targetFormat === FORMATS.OPENAI_RESPONSES) { + return { type: "function", name, description, parameters }; + } + return { type: "function", - function: { - name: OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME, - description: buildFallbackDescription(tool), - parameters: buildFallbackParameters(tool), - }, + function: { name, description, parameters }, }; } @@ -182,8 +191,12 @@ export function prepareWebSearchFallbackBody( return true; }); + const isResponsesTarget = options.targetFormat === FORMATS.OPENAI_RESPONSES; + if (!toolNames.has(OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME)) { - preservedTools.unshift(buildFallbackTool(toRecord(builtInSearchTools[0]))); + preservedTools.unshift( + buildFallbackTool(toRecord(builtInSearchTools[0]), options.targetFormat) + ); } const nextBody: T = { @@ -192,10 +205,12 @@ export function prepareWebSearchFallbackBody( }; if (isBuiltInWebSearchToolChoice(body.tool_choice)) { - nextBody.tool_choice = { - type: "function", - function: { name: OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME }, - } as T["tool_choice"]; + // Match the injected tool shape: flat for Responses API, nested for Chat Completions. + nextBody.tool_choice = ( + isResponsesTarget + ? { type: "function", name: OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME } + : { type: "function", function: { name: OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME } } + ) as T["tool_choice"]; } return { diff --git a/tests/unit/web-search-fallback-format.test.ts b/tests/unit/web-search-fallback-format.test.ts new file mode 100644 index 0000000000..605aa1f7dd --- /dev/null +++ b/tests/unit/web-search-fallback-format.test.ts @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { prepareWebSearchFallbackBody, OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME } = + await import("../../open-sse/services/webSearchFallback.ts"); + +// Regression for #2390: when the target is a Responses-API provider, the injected +// omniroute_web_search tool must use the FLAT function shape ({ type, name }), not the +// nested Chat Completions shape ({ type, function: { name } }). On the Responses→Responses +// passthrough path nothing flattens it, so a nested tool reaches the upstream as +// tools[0].function.name and is rejected with "Missing required parameter: 'tools[0].name'". + +function makeBody() { + return { + model: "gpt-5.5", + messages: [{ role: "user", content: "search the web" }], + tools: [{ type: "web_search" }], + }; +} + +test("#2390 web_search fallback is FLAT for Responses API target", () => { + const { body, fallback } = prepareWebSearchFallbackBody(makeBody(), { + targetFormat: "openai-responses", + nativeCodexPassthrough: false, + }); + + assert.equal(fallback.enabled, true); + const injected = body.tools[0] as Record; + assert.equal(injected.type, "function"); + assert.equal(injected.name, OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME); + assert.equal( + injected.function, + undefined, + "Responses API tool must not be nested under .function" + ); + assert.ok(injected.parameters, "flat tool keeps top-level parameters"); +}); + +test("#2390 web_search fallback stays NESTED for Chat Completions target", () => { + const { body, fallback } = prepareWebSearchFallbackBody(makeBody(), { + targetFormat: "openai", + nativeCodexPassthrough: false, + }); + + assert.equal(fallback.enabled, true); + const injected = body.tools[0] as Record; + assert.equal(injected.type, "function"); + const fn = injected.function as Record | undefined; + assert.ok(fn, "Chat Completions tool must be nested under .function"); + assert.equal(fn?.name, OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME); + assert.equal( + injected.name, + undefined, + "Chat Completions tool must not expose a flat top-level name" + ); +}); + +test("#2390 tool_choice matches the injected tool shape per target format", () => { + const responses = prepareWebSearchFallbackBody( + { ...makeBody(), tool_choice: { type: "web_search" } }, + { targetFormat: "openai-responses", nativeCodexPassthrough: false } + ); + const rChoice = responses.body.tool_choice as Record; + assert.equal(rChoice.name, OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME); + assert.equal(rChoice.function, undefined); + + const chat = prepareWebSearchFallbackBody( + { ...makeBody(), tool_choice: { type: "web_search" } }, + { targetFormat: "openai", nativeCodexPassthrough: false } + ); + const cChoice = chat.body.tool_choice as Record; + const cFn = cChoice.function as Record | undefined; + assert.equal(cFn?.name, OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME); +});