mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
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.
This commit is contained in:
@@ -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<T extends JsonRecord>(
|
||||
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<T extends JsonRecord>(
|
||||
};
|
||||
|
||||
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 {
|
||||
|
||||
74
tests/unit/web-search-fallback-format.test.ts
Normal file
74
tests/unit/web-search-fallback-format.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
assert.equal(injected.type, "function");
|
||||
const fn = injected.function as Record<string, unknown> | 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const cFn = cChoice.function as Record<string, unknown> | undefined;
|
||||
assert.equal(cFn?.name, OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME);
|
||||
});
|
||||
Reference in New Issue
Block a user