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.
This commit is contained in:
Maxim Ivanov
2026-05-30 10:23:38 +00:00
parent a7330b4fb1
commit 2b99d7ba7f
3 changed files with 67 additions and 10 deletions

View File

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

View File

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

View File

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