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 fdf6687512..f426c99857 100644 --- a/open-sse/translator/request/claude-to-openai.ts +++ b/open-sse/translator/request/claude-to-openai.ts @@ -27,8 +27,55 @@ 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 + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => 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)); +} + +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[]; @@ -89,30 +136,31 @@ 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) => { - const name = typeof tool.name === "string" ? tool.name.trim() : ""; + if (useNativeResponsesWebSearch && isClaudeServerWebSearchTool(tool)) { + return convertClaudeServerWebSearchTool(tool); + } + + 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), }, }; }) - .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 +169,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, + useNativeResponsesWebSearch && hasClaudeServerWebSearchTool(body.tools) + ); } // Reasoning effort: map Claude-side thinking controls to OpenAI reasoning_effort. @@ -320,7 +371,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 +381,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..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( @@ -52,6 +54,136 @@ 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", "", 123, { domain: "bad.example" }], + blocked_domains: ["spam.example", false], + max_uses: 8, + user_location: { type: "approximate", country: "US" }, + }, + ], + tool_choice: { type: "tool", name: "web_search" }, + }, + true, + { _targetFormat: FORMATS.OPENAI_RESPONSES } + ); + + 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("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", + { + 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", + { + 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",