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
This commit is contained in:
Maxim Ivanov
2026-05-30 08:51:04 +00:00
parent 53c8ffcc34
commit 5600dcd2d2
2 changed files with 115 additions and 10 deletions

View File

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

View File

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