diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index a650eba72..6ae97db63 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -136,6 +136,26 @@ function stripItemIdsWhenUnstored(body: unknown): unknown { return changed ? { ...body, input } : body; } +/** + * Replayed web_search_call actions carry a singular `query`, but DeepSeek's Responses route + * requires `queries` and 400s without it (#930). Add the plural next to the singular. Key path + * only: forward mode keeps the hosted shape. + */ +function normalizeWebSearchCallActions(body: unknown): unknown { + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + + let changed = false; + const input = body.input.map(item => { + if (!isPlainObject(item) || item.type !== "web_search_call") return item; + const action = item.action; + if (!isPlainObject(action) || typeof action.query !== "string" || action.queries !== undefined) return item; + changed = true; + return { ...item, action: { ...action, queries: [action.query] } }; + }); + + return changed ? { ...body, input } : body; +} + /** * Replace proxy-minted compaction items (`encrypted_content` starting with `ocx1:`) with plain * user messages before forwarding to the ChatGPT backend. Our envelope is transparent base64, not @@ -1140,6 +1160,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): parsed._openAiVirtualSelectedModelId, ); outBody = normalizeImageGenClientTools(outBody); + outBody = normalizeWebSearchCallActions(outBody); } if (forward || parsed._previousResponseInputExpanded === true) { outBody = repairOversizedReplayCallIds(outBody); diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 34b281842..90037008f 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -104,6 +104,59 @@ describe("DeepSeek Responses endpoint contract", () => { }); }); +/** Issue #930: DeepSeek's Responses route requires `queries` on replayed web_search_call actions. */ +describe("web_search_call action replay (#930)", () => { + const keyProvider = { + adapter: "openai-responses", + baseUrl: "https://api.deepseek.com", + authMode: "key" as const, + apiKey: "sk-test", + responsesPath: "/responses", + }; + const forwardProvider = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.example/backend-api/codex", + authMode: "forward" as const, + }; + + function forwardedInput(provider: typeof keyProvider | typeof forwardProvider, input: unknown[]): Record[] { + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "deepseek-v4-flash", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "deepseek-v4-flash", stream: true, input }, + }, { headers: new Headers({ authorization: "Bearer t" }) }) as { body: string }; + return (JSON.parse(request.body) as { input: Record[] }).input; + } + + test("the key path adds queries next to the recorded singular query", () => { + const input = forwardedInput(keyProvider, [ + { type: "web_search_call", id: "ws_x", status: "completed", action: { type: "search", query: "test" } }, + ]); + expect(input[0].action).toEqual({ type: "search", query: "test", queries: ["test"] }); + }); + + test("forward mode keeps the hosted shape", () => { + const input = forwardedInput(forwardProvider, [ + { type: "web_search_call", id: "ws_x", status: "completed", action: { type: "search", query: "test" } }, + ]); + expect(input[0].action).toEqual({ type: "search", query: "test" }); + }); + + test("existing queries, actionless calls, and other items pass through untouched", () => { + const ready = { type: "search", queries: ["a", "b"] }; + const input = forwardedInput(keyProvider, [ + { type: "web_search_call", id: "ws_a", status: "completed", action: ready }, + { type: "web_search_call", id: "ws_b", status: "completed" }, + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ]); + expect(input[0].action).toEqual(ready); + expect("action" in input[1]).toBe(false); + expect(input[2].type).toBe("message"); + }); +}); + describe("OpenAI Responses passthrough sanitization", () => { test("normalizes top-level function schemas in the serialized raw body (#745)", () => { const validParameters = {