From 2b99d7ba7fe0fe82e23ca8125dd313609d3ecd95 Mon Sep 17 00:00:00 2001 From: Maxim Ivanov Date: Sat, 30 May 2026 10:23:38 +0000 Subject: [PATCH] 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",