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.
This commit is contained in:
azox-ai-agent
2026-09-11 09:46:04 +07:00
parent af49d4972e
commit 2b3e63a470
4 changed files with 56 additions and 9 deletions

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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",