diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e47371c1a..b16b3b3ddd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - **gemini:** translate signature-less Gemini thinking model tool calls to text parts to prevent `400 "missing thought_signature"` errors (#2801 — thanks @herjarsa) - **translator:** strip `safety_identifier` from `/v1/responses` body before forwarding to Chat Completions upstream; fixes LobeHub-originated `400` errors (#2770) - **warning-cleanup:** relax node engine constraint to `>=22.0.0` and clean dependencies (keeping `marked-terminal` to prevent TUI REPL crash) (#2792 — thanks @oyi77) +- **translator:** silently drop `tool_search` built-in tool type instead of returning 400 — newer Codex clients send `tool_search` as a Responses API built-in with no Chat Completions equivalent (#2766) - **usage:** un-invert GitHub Copilot Free / limited plan quota — `limited_user_quotas` is the *remaining* count, not used, so the dashboard now shows 100% when the quota is untouched and 0% when fully exhausted (#2876 — thanks @androw) - **fix(cli):** register openclaw in the CLI tool-detector so it appears in `omniroute status` alongside its existing API and config support ([#2833](https://github.com/diegosouzapw/OmniRoute/issues/2833)) diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 8b25bbb67e..9c26c0084f 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -15,6 +15,9 @@ const COPILOT_REASONING_SUMMARY_MARKER = "_omnirouteCopilotReasoningSummary"; // Forward-compatible regex: matches web_search, web_search_20250305, and any future versioned names. const WEB_SEARCH_TOOL_TYPES = /^web_search/; +// tool_search is a Responses API built-in sent by newer Codex clients; it has no Chat Completions +// equivalent and must be silently dropped (not rejected with 400). +const TOOL_SEARCH_TOOL_TYPES = /^tool_search/; function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; @@ -72,6 +75,8 @@ export function openaiResponsesToOpenAIRequest( // Allow: function tools, tools already in Chat format (have .function property), CLI subagent tools, // namespace tools (MCP tool groups used by Codex/OpenAI Responses API), and web_search server tools // (Anthropic versioned: web_search_20250305, web_search_20250101, etc. — or plain web_search). + // tool_search is a Responses API built-in sent by newer Codex clients; silently skip it here + // (it will be filtered out during tools conversion below). if ( toolType && toolType !== "function" && @@ -79,6 +84,7 @@ export function openaiResponsesToOpenAIRequest( toolType !== "command" && toolType !== "namespace" && !WEB_SEARCH_TOOL_TYPES.test(toolType) && + !TOOL_SEARCH_TOOL_TYPES.test(toolType) && !tool.function ) { throw unsupportedFeature( @@ -257,26 +263,33 @@ export function openaiResponsesToOpenAIRequest( // Convert tools format if (Array.isArray(root.tools)) { - result.tools = root.tools.map((toolValue) => { - const tool = toRecord(toolValue); - if (tool.function) return toolValue; - const toolType = toString(tool.type); - // Pass web_search server tools through with their original type (versioned or plain). - // These have no Chat Completions equivalent; preserve as-is so upstreams that understand - // Anthropic-style web_search_YYYYMMDD naming receive the exact name they expect. - if (WEB_SEARCH_TOOL_TYPES.test(toolType)) { - return toolValue; - } - return { - type: "function", - function: { - name: toString(tool.name), - description: toString(tool.description), - parameters: tool.parameters, - strict: tool.strict, - }, - }; - }); + result.tools = root.tools + .filter((toolValue) => { + const tool = toRecord(toolValue); + const toolType = toString(tool.type); + // tool_search has no Chat Completions equivalent; drop it silently (issue #2766). + return !TOOL_SEARCH_TOOL_TYPES.test(toolType); + }) + .map((toolValue) => { + const tool = toRecord(toolValue); + if (tool.function) return toolValue; + const toolType = toString(tool.type); + // Pass web_search server tools through with their original type (versioned or plain). + // These have no Chat Completions equivalent; preserve as-is so upstreams that understand + // Anthropic-style web_search_YYYYMMDD naming receive the exact name they expect. + if (WEB_SEARCH_TOOL_TYPES.test(toolType)) { + return toolValue; + } + return { + type: "function", + function: { + name: toString(tool.name), + description: toString(tool.description), + parameters: tool.parameters, + strict: tool.strict, + }, + }; + }); } // Filter orphaned tool results (no matching tool_call in assistant messages) diff --git a/tests/unit/translator-openai-responses-req.test.ts b/tests/unit/translator-openai-responses-req.test.ts index ece658aa5d..af898ccf52 100644 --- a/tests/unit/translator-openai-responses-req.test.ts +++ b/tests/unit/translator-openai-responses-req.test.ts @@ -696,3 +696,54 @@ test("Responses -> Chat: unknown tool type still throws unsupported_feature (no (error: any) => error.statusCode === 400 && error.errorType === "unsupported_feature" ); }); + +// --- Issue #2766: tool_search built-in should be silently dropped --- + +test("Responses -> Chat: tool_search does not throw (issue #2766)", () => { + // Codex newer clients send tool_search as a Responses API built-in. + // OmniRoute must not return 400 — it should silently drop the tool_search entry. + assert.doesNotThrow(() => + openaiResponsesToOpenAIRequest( + "gpt-4o", + { + input: [{ role: "user", content: [{ type: "input_text", text: "search" }] }], + tools: [{ type: "tool_search", name: "search" }], + }, + false, + null + ) + ); +}); + +test("Responses -> Chat: tool_search is stripped from output tools array (issue #2766)", () => { + // Codex clients send tool_search alongside function tools. tool_search has no + // Chat Completions equivalent and must be dropped; function tools must remain. + const result = openaiResponsesToOpenAIRequest( + "gpt-4o", + { + input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }], + tools: [ + { type: "tool_search", name: "search" }, + { + type: "function", + name: "foo", + description: "A function", + parameters: { type: "object" }, + }, + ], + }, + false, + null + ) as Record; + + const tools = result.tools as any[]; + assert.ok(Array.isArray(tools), "tools array must be present"); + assert.equal( + tools.some((t) => t.type === "tool_search"), + false, + "tool_search must be stripped from output" + ); + assert.equal(tools.length, 1, "only the function tool must remain"); + assert.equal(tools[0].type, "function"); + assert.equal(tools[0].function.name, "foo"); +});