From 2b3e63a470d0bc35f608819f9b4480c9fc7b587f Mon Sep 17 00:00:00 2001 From: azox-ai-agent Date: Fri, 11 Sep 2026 09:46:04 +0700 Subject: [PATCH] fix(translator): skip replayed web_search_call metadata in Responses-to-Chat OmniRoute's web-search fallback emits a native web_search_call output item alongside function_call/function_call_output. Responses clients keep that item in conversation history and replay it in the next request's input. When the follow-up turn routes to a Chat Completions target (Claude), the translator hit its default unsupported-feature branch and returned a deterministic HTTP 400: Unsupported Responses API feature: input item type 'web_search_call' cannot be represented in Chat Completions Skip the replayed metadata next to tool_search_call/tool_search_result. The paired function_call_output still carries the search results, so no context is lost and the sources are not duplicated into assistant history. --- .../fixes/responses-web-search-call-replay.md | 1 + .../translator/request/openai-responses.ts | 18 ++++---- .../responses-chat-translation-gaps.test.ts | 1 - .../responses-tool-search-call-skip.test.ts | 45 +++++++++++++++++++ 4 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 changelog.d/fixes/responses-web-search-call-replay.md diff --git a/changelog.d/fixes/responses-web-search-call-replay.md b/changelog.d/fixes/responses-web-search-call-replay.md new file mode 100644 index 0000000000..4578b5122b --- /dev/null +++ b/changelog.d/fixes/responses-web-search-call-replay.md @@ -0,0 +1 @@ +Responses-to-Chat fallback now skips replayed `web_search_call` metadata while preserving the paired function result, preventing deterministic HTTP 400 failures on follow-up turns routed to Chat Completions providers. diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 18e8f41940..69932b714c 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -468,14 +468,16 @@ export function openaiResponsesToOpenAIRequest( continue; } - // Skip tool_search_call items. These are Responses-API-only metadata items - // emitted by Codex's dynamic tool-search optimization: they record that the - // model queried a subset of available tools, but carry no content that Chat - // Completions can represent. Throwing here would break every multi-turn - // conversation where Codex previously used tool_search (the whole session - // would carry tool_search_call items forward in `input`). Skipping matches - // the reasoning-item policy: display-only metadata, no chat side-effect. - if (itemType === "tool_search_call" || itemType === "tool_search_result") { + // Skip Responses-only search metadata. tool_search_call/tool_search_result + // are Codex's dynamic tool-discovery items; web_search_call is emitted by + // OmniRoute's web-search fallback alongside function_call_output, which + // already carries the result for Chat Completions. Replayed metadata has no + // lossless Chat representation and must not fail a follow-up turn. + if ( + itemType === "tool_search_call" || + itemType === "tool_search_result" || + itemType === "web_search_call" + ) { continue; } diff --git a/tests/unit/responses-chat-translation-gaps.test.ts b/tests/unit/responses-chat-translation-gaps.test.ts index b7892b1478..e2db44a269 100644 --- a/tests/unit/responses-chat-translation-gaps.test.ts +++ b/tests/unit/responses-chat-translation-gaps.test.ts @@ -125,7 +125,6 @@ test("Responses -> Chat rejects input item types without a lossless Chat equival { type: "item_reference", id: "item_123" }, { type: "computer_call_output", call_id: "call_1", output: {} }, { type: "mcp_call", name: "remote", arguments: "{}" }, - { type: "web_search_call", id: "search_1" }, { unexpected: true }, ]) { assert.throws( diff --git a/tests/unit/responses-tool-search-call-skip.test.ts b/tests/unit/responses-tool-search-call-skip.test.ts index f94b5f6df4..eb68718470 100644 --- a/tests/unit/responses-tool-search-call-skip.test.ts +++ b/tests/unit/responses-tool-search-call-skip.test.ts @@ -58,6 +58,51 @@ test("tool_search_result input item is silently skipped", () => { assert.equal(messages[0].role, "user"); }); +test("web_search_call replay is skipped while its function result is preserved", () => { + const body = { + model: "test-model", + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "Find docs" }] }, + { + type: "function_call", + call_id: "call_search", + name: "omniroute_web_search", + arguments: '{"query":"OmniRoute docs"}', + }, + { + type: "function_call_output", + call_id: "call_search", + output: '{"success":true,"results":[{"url":"https://example.com","title":"Example"}]}', + }, + { + type: "web_search_call", + id: "ws_call_search", + status: "completed", + action: { + type: "web_search", + query: "OmniRoute docs", + sources: [{ title: "Example", url: "https://example.com", caption: "Result" }], + }, + }, + ], + stream: false, + }; + let result; + assert.doesNotThrow(() => { + result = translateRequest("openai-responses", "openai", "test-model", body, false); + }, "web_search_call replay must not throw"); + const messages = ( + result as { messages?: Array<{ role?: string; tool_calls?: unknown; content?: unknown }> } + ).messages; + assert.ok(Array.isArray(messages)); + assert.equal(messages.length, 3, "web_search_call metadata must not create a duplicate message"); + assert.deepEqual( + messages.map((message) => message.role), + ["user", "assistant", "tool"] + ); + assert.equal(messages[2].content, body.input[2].output); +}); + test("multiple tool_search_call items interspersed with messages are skipped in order", () => { const body = { model: "test-model",