From 5600dcd2d2b26ff51f7b368b708fa3fe20f3a268 Mon Sep 17 00:00:00 2001 From: Maxim Ivanov Date: Sat, 30 May 2026 08:51:04 +0000 Subject: [PATCH 1/3] fix(claude): map WebSearch to Responses web_search Translate Claude Code web_search_YYYYMMDD server tools to the native OpenAI Responses web_search tool and preserve filters/location. Convert forced Claude tool_choice for web_search to the native Responses tool choice while leaving ordinary custom functions unchanged. Closes #2936 --- .../translator/request/claude-to-openai.ts | 58 +++++++++++++--- .../unit/translator-claude-to-openai.test.ts | 67 +++++++++++++++++++ 2 files changed, 115 insertions(+), 10 deletions(-) diff --git a/open-sse/translator/request/claude-to-openai.ts b/open-sse/translator/request/claude-to-openai.ts index fdf6687512..dda41d3aef 100644 --- a/open-sse/translator/request/claude-to-openai.ts +++ b/open-sse/translator/request/claude-to-openai.ts @@ -27,6 +27,41 @@ function normalizeOpenAIReasoningEffort(effort: unknown): string | undefined { return normalized || undefined; } +function isClaudeServerWebSearchTool(tool: unknown): tool is JsonRecord { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false; + const record = tool as JsonRecord; + return ( + record.name === "web_search" && + typeof record.type === "string" && + /^web_search_\d{8}$/.test(record.type) + ); +} + +function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map((entry) => String(entry || "").trim()).filter(Boolean); +} + +function convertClaudeServerWebSearchTool(tool: JsonRecord): JsonRecord { + const allowedDomains = toStringArray(tool.allowed_domains); + const blockedDomains = toStringArray(tool.blocked_domains); + const filters: JsonRecord = {}; + if (allowedDomains.length > 0) filters.allowed_domains = allowedDomains; + if (blockedDomains.length > 0) filters.blocked_domains = blockedDomains; + + return { + type: "web_search", + ...(Object.keys(filters).length > 0 ? { filters } : {}), + ...(tool.user_location && typeof tool.user_location === "object" && !Array.isArray(tool.user_location) + ? { user_location: tool.user_location } + : {}), + }; +} + +function hasClaudeServerWebSearchTool(tools: unknown): boolean { + return Array.isArray(tools) && tools.some((tool) => isClaudeServerWebSearchTool(tool)); +} + // Convert Claude request to OpenAI format export function claudeToOpenAIRequest(model, body, stream) { const result: { @@ -93,6 +128,10 @@ export function claudeToOpenAIRequest(model, body, stream) { if (body.tools && Array.isArray(body.tools)) { const normalizedTools = body.tools .map((tool) => { + if (isClaudeServerWebSearchTool(tool)) { + return convertClaudeServerWebSearchTool(tool); + } + const name = typeof tool.name === "string" ? tool.name.trim() : ""; if (!name) return null; // skip tools with empty/invalid name @@ -105,14 +144,7 @@ export function claudeToOpenAIRequest(model, body, stream) { }, }; }) - .filter( - ( - tool - ): tool is { - type: "function"; - function: { name: string; description: string; parameters: unknown }; - } => Boolean(tool) - ); + .filter((tool): tool is JsonRecord => Boolean(tool)); if (normalizedTools.length > 0) { result.tools = normalizedTools; @@ -121,7 +153,10 @@ export function claudeToOpenAIRequest(model, body, stream) { // Tool choice if (body.tool_choice) { - result.tool_choice = convertToolChoice(body.tool_choice); + result.tool_choice = convertToolChoice( + body.tool_choice, + hasClaudeServerWebSearchTool(body.tools) + ); } // Reasoning effort: map Claude-side thinking controls to OpenAI reasoning_effort. @@ -320,7 +355,7 @@ function convertClaudeMessage(msg) { } // Convert tool choice -function convertToolChoice(choice) { +function convertToolChoice(choice, hasServerWebSearch = false) { if (!choice) return "auto"; if (typeof choice === "string") return choice; @@ -330,6 +365,9 @@ function convertToolChoice(choice) { case TOOL_CHOICE_ANY: return "required"; case "tool": + if (hasServerWebSearch && choice.name === "web_search") { + return { type: "web_search" }; + } return { type: "function", function: { name: choice.name } }; default: return "auto"; diff --git a/tests/unit/translator-claude-to-openai.test.ts b/tests/unit/translator-claude-to-openai.test.ts index 9c082f1c93..5a9e084186 100644 --- a/tests/unit/translator-claude-to-openai.test.ts +++ b/tests/unit/translator-claude-to-openai.test.ts @@ -52,6 +52,73 @@ test("Claude -> OpenAI maps system blocks, parameters, tool declarations and too }); }); + + +test("Claude -> OpenAI maps Claude server WebSearch to native Responses web_search", () => { + const result = claudeToOpenAIRequest( + "gpt-5.5", + { + messages: [{ role: "user", content: "Search docs" }], + tools: [ + { + type: "web_search_20250305", + name: "web_search", + allowed_domains: ["docs.anthropic.com", ""], + blocked_domains: ["spam.example"], + max_uses: 8, + user_location: { type: "approximate", country: "US" }, + }, + ], + tool_choice: { type: "tool", name: "web_search" }, + }, + true + ); + + assert.deepEqual(result.tools, [ + { + type: "web_search", + filters: { + allowed_domains: ["docs.anthropic.com"], + blocked_domains: ["spam.example"], + }, + user_location: { type: "approximate", country: "US" }, + }, + ]); + assert.deepEqual(result.tool_choice, { type: "web_search" }); +}); + +test("Claude -> OpenAI leaves ordinary web_search function tools as functions", () => { + const result = claudeToOpenAIRequest( + "gpt-4o", + { + messages: [{ role: "user", content: "Search docs" }], + tools: [ + { + type: "custom", + name: "web_search", + description: "User-defined search function", + input_schema: { type: "object", properties: { query: { type: "string" } } }, + }, + ], + tool_choice: { type: "tool", name: "web_search" }, + }, + false + ); + + assert.deepEqual(result.tools[0], { + type: "function", + function: { + name: "web_search", + description: "User-defined search function", + parameters: { type: "object", properties: { query: { type: "string" } } }, + }, + }); + assert.deepEqual(result.tool_choice, { + type: "function", + function: { name: "web_search" }, + }); +}); + test("Claude -> OpenAI converts assistant text and both base64 and URL images", () => { const result = claudeToOpenAIRequest( "gpt-4o", From a7330b4fb1ad37035c5fc97fa06d917b871c5a4e Mon Sep 17 00:00:00 2001 From: Maxim Ivanov Date: Sat, 30 May 2026 08:58:15 +0000 Subject: [PATCH 2/3] fix: harden Claude WebSearch tool parsing --- .../translator/request/claude-to-openai.ts | 13 +++++++--- .../unit/translator-claude-to-openai.test.ts | 26 +++++++++++++++++-- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/open-sse/translator/request/claude-to-openai.ts b/open-sse/translator/request/claude-to-openai.ts index dda41d3aef..cb682b16a7 100644 --- a/open-sse/translator/request/claude-to-openai.ts +++ b/open-sse/translator/request/claude-to-openai.ts @@ -39,7 +39,10 @@ function isClaudeServerWebSearchTool(tool: unknown): tool is JsonRecord { function toStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; - return value.map((entry) => String(entry || "").trim()).filter(Boolean); + return value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean); } function convertClaudeServerWebSearchTool(tool: JsonRecord): JsonRecord { @@ -132,15 +135,17 @@ export function claudeToOpenAIRequest(model, body, stream) { return convertClaudeServerWebSearchTool(tool); } - const name = typeof tool.name === "string" ? tool.name.trim() : ""; + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return null; + const record = tool as JsonRecord; + const name = typeof record.name === "string" ? record.name.trim() : ""; if (!name) return null; // skip tools with empty/invalid name return { type: "function", function: { name, - description: typeof tool.description === "string" ? tool.description : "", // fix: never null (#276) - parameters: normalizeToolSchema(tool.input_schema), + description: typeof record.description === "string" ? record.description : "", // fix: never null (#276) + parameters: normalizeToolSchema(record.input_schema), }, }; }) diff --git a/tests/unit/translator-claude-to-openai.test.ts b/tests/unit/translator-claude-to-openai.test.ts index 5a9e084186..c14bdfccc3 100644 --- a/tests/unit/translator-claude-to-openai.test.ts +++ b/tests/unit/translator-claude-to-openai.test.ts @@ -63,8 +63,8 @@ test("Claude -> OpenAI maps Claude server WebSearch to native Responses web_sear { type: "web_search_20250305", name: "web_search", - allowed_domains: ["docs.anthropic.com", ""], - blocked_domains: ["spam.example"], + allowed_domains: ["docs.anthropic.com", "", 123, { domain: "bad.example" }], + blocked_domains: ["spam.example", false], max_uses: 8, user_location: { type: "approximate", country: "US" }, }, @@ -87,6 +87,28 @@ test("Claude -> OpenAI maps Claude server WebSearch to native Responses web_sear assert.deepEqual(result.tool_choice, { type: "web_search" }); }); +test("Claude -> OpenAI skips invalid tool payloads without crashing", () => { + const result = claudeToOpenAIRequest( + "gpt-4o", + { + messages: [{ role: "user", content: "hi" }], + tools: [null, "bad", 42, [], { name: "", input_schema: { type: "object" } }, { name: "ok" }], + }, + false + ); + + assert.deepEqual(result.tools, [ + { + type: "function", + function: { + name: "ok", + description: "", + parameters: { type: "object", properties: {} }, + }, + }, + ]); +}); + test("Claude -> OpenAI leaves ordinary web_search function tools as functions", () => { const result = claudeToOpenAIRequest( "gpt-4o", From 2b99d7ba7fe0fe82e23ca8125dd313609d3ecd95 Mon Sep 17 00:00:00 2001 From: Maxim Ivanov Date: Sat, 30 May 2026 10:23:38 +0000 Subject: [PATCH 3/3] fix: gate Claude WebSearch native mapping by target format Only translate Claude Code web_search_YYYYMMDD server tools to native Responses web_search when the final target is OpenAI Responses. Keep the Chat Completions target on function-tool shape and cover the full translateRequest path. --- open-sse/translator/index.ts | 15 ++++--- .../translator/request/claude-to-openai.ts | 17 +++++-- .../unit/translator-claude-to-openai.test.ts | 45 ++++++++++++++++++- 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 123221eb84..65341d5393 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -183,12 +183,15 @@ export function translateRequest( const toOpenAI = getRequestTranslator(sourceFormat, FORMATS.OPENAI); if (toOpenAI) { // Forward Copilot UA marker to source→openai translators only. - const step1Credentials = options?.copilotClient - ? { - ...(credentials && typeof credentials === "object" ? credentials : {}), - _copilotClient: true, - } - : credentials; + const hasTargetHint = targetFormat != null; + const step1Credentials = + options?.copilotClient || hasTargetHint + ? { + ...(credentials && typeof credentials === "object" ? credentials : {}), + ...(options?.copilotClient ? { _copilotClient: true } : {}), + ...(hasTargetHint ? { _targetFormat: targetFormat } : {}), + } + : credentials; result = toOpenAI(model, result, stream, step1Credentials); // Log OpenAI intermediate format reqLogger?.logOpenAIRequest?.(result); diff --git a/open-sse/translator/request/claude-to-openai.ts b/open-sse/translator/request/claude-to-openai.ts index cb682b16a7..f426c99857 100644 --- a/open-sse/translator/request/claude-to-openai.ts +++ b/open-sse/translator/request/claude-to-openai.ts @@ -65,8 +65,17 @@ function hasClaudeServerWebSearchTool(tools: unknown): boolean { return Array.isArray(tools) && tools.some((tool) => isClaudeServerWebSearchTool(tool)); } +function shouldUseNativeResponsesWebSearch(credentials: unknown): boolean { + return ( + credentials !== null && + typeof credentials === "object" && + !Array.isArray(credentials) && + (credentials as JsonRecord)._targetFormat === FORMATS.OPENAI_RESPONSES + ); +} + // Convert Claude request to OpenAI format -export function claudeToOpenAIRequest(model, body, stream) { +export function claudeToOpenAIRequest(model, body, stream, credentials: unknown = null) { const result: { model: string; messages: JsonRecord[]; @@ -127,11 +136,13 @@ export function claudeToOpenAIRequest(model, body, stream) { // Fix missing tool responses - OpenAI requires every tool_call to have a response fixMissingToolResponses(result.messages); + const useNativeResponsesWebSearch = shouldUseNativeResponsesWebSearch(credentials); + // Tools if (body.tools && Array.isArray(body.tools)) { const normalizedTools = body.tools .map((tool) => { - if (isClaudeServerWebSearchTool(tool)) { + if (useNativeResponsesWebSearch && isClaudeServerWebSearchTool(tool)) { return convertClaudeServerWebSearchTool(tool); } @@ -160,7 +171,7 @@ export function claudeToOpenAIRequest(model, body, stream) { if (body.tool_choice) { result.tool_choice = convertToolChoice( body.tool_choice, - hasClaudeServerWebSearchTool(body.tools) + useNativeResponsesWebSearch && hasClaudeServerWebSearchTool(body.tools) ); } diff --git a/tests/unit/translator-claude-to-openai.test.ts b/tests/unit/translator-claude-to-openai.test.ts index c14bdfccc3..c5a01a9db1 100644 --- a/tests/unit/translator-claude-to-openai.test.ts +++ b/tests/unit/translator-claude-to-openai.test.ts @@ -3,6 +3,8 @@ import assert from "node:assert/strict"; const { claudeToOpenAIRequest } = await import("../../open-sse/translator/request/claude-to-openai.ts"); +const { translateRequest } = await import("../../open-sse/translator/index.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); test("Claude -> OpenAI maps system blocks, parameters, tool declarations and tool choice", () => { const result = claudeToOpenAIRequest( @@ -71,7 +73,8 @@ test("Claude -> OpenAI maps Claude server WebSearch to native Responses web_sear ], tool_choice: { type: "tool", name: "web_search" }, }, - true + true, + { _targetFormat: FORMATS.OPENAI_RESPONSES } ); assert.deepEqual(result.tools, [ @@ -87,6 +90,46 @@ test("Claude -> OpenAI maps Claude server WebSearch to native Responses web_sear assert.deepEqual(result.tool_choice, { type: "web_search" }); }); +test("translateRequest maps Claude server WebSearch natively only for Responses targets", () => { + const body = { + messages: [{ role: "user", content: "Search docs" }], + tools: [{ type: "web_search_20250305", name: "web_search" }], + tool_choice: { type: "tool", name: "web_search" }, + }; + + const responses = translateRequest( + FORMATS.CLAUDE, + FORMATS.OPENAI_RESPONSES, + "gpt-5.5", + structuredClone(body), + true + ); + assert.deepEqual(responses.tools, [{ type: "web_search" }]); + assert.deepEqual(responses.tool_choice, { type: "web_search" }); + + const chat = translateRequest( + FORMATS.CLAUDE, + FORMATS.OPENAI, + "gpt-4o", + structuredClone(body), + true + ); + assert.deepEqual(chat.tools, [ + { + type: "function", + function: { + name: "web_search", + description: "", + parameters: { type: "object", properties: {} }, + }, + }, + ]); + assert.deepEqual(chat.tool_choice, { + type: "function", + function: { name: "web_search" }, + }); +}); + test("Claude -> OpenAI skips invalid tool payloads without crashing", () => { const result = claudeToOpenAIRequest( "gpt-4o",