diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index d9f889e2704..9cb18ad8e5a 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -261,7 +261,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) for i := 0; i < len(toolResults); i++ { toolResult := toolResults[i] // Special handling: map Claude web search tool to Codex web_search - if isClaudeWebSearchToolType(toolResult.Get("type").String()) { + if isClaudeWebSearchTool(toolResult) { template, _ = sjson.SetRawBytes(template, "tools.-1", convertClaudeWebSearchToolToCodex(toolResult)) continue } @@ -355,6 +355,44 @@ func isClaudeWebSearchToolType(toolType string) bool { return toolType == "web_search_20250305" || toolType == "web_search_20260209" } +// isClaudeWebSearchTool reports whether a Claude tool declaration should be +// mapped to the Codex Responses builtin web_search tool. It matches both the +// typed Anthropic server tools (web_search_20250305 / web_search_20260209) and +// the plain function-style "WebSearch" tool emitted by recent Claude Code +// releases (2.1.177+), which declares a normal input_schema instead of a typed +// tool and would otherwise be forwarded to Codex as a regular function tool. +func isClaudeWebSearchTool(tool gjson.Result) bool { + if isClaudeWebSearchToolType(tool.Get("type").String()) { + return true + } + return isClaudeCodeWebSearchFunctionTool(tool) +} + +// isClaudeCodeWebSearchFunctionTool detects the function-style WebSearch tool +// that Claude Code declares as {"name":"WebSearch","input_schema":{...}}. The +// schema is fingerprinted by its query plus allowed_domains / blocked_domains +// filter properties so unrelated custom tools (for example a user tool named +// "web_search") are not misclassified as the builtin web search tool. +func isClaudeCodeWebSearchFunctionTool(tool gjson.Result) bool { + if tool.Get("name").String() != "WebSearch" { + return false + } + // Typed server tools carry an explicit type and are handled separately; + // only plain custom/function declarations reach this branch. + switch tool.Get("type").String() { + case "", "custom": + default: + return false + } + props := tool.Get("input_schema.properties") + if !props.IsObject() { + return false + } + return props.Get("query").Exists() && + props.Get("allowed_domains").Exists() && + props.Get("blocked_domains").Exists() +} + func buildClaudeWebSearchToolNameSet(tools gjson.Result) map[string]struct{} { names := map[string]struct{}{} if !tools.IsArray() { @@ -362,8 +400,7 @@ func buildClaudeWebSearchToolNameSet(tools gjson.Result) map[string]struct{} { } tools.ForEach(func(_, tool gjson.Result) bool { - toolType := tool.Get("type").String() - if !isClaudeWebSearchToolType(toolType) { + if !isClaudeWebSearchTool(tool) { return true } diff --git a/internal/translator/codex/claude/codex_claude_request_test.go b/internal/translator/codex/claude/codex_claude_request_test.go index eab12e4764d..56785d9d4ae 100644 --- a/internal/translator/codex/claude/codex_claude_request_test.go +++ b/internal/translator/codex/claude/codex_claude_request_test.go @@ -332,6 +332,97 @@ func TestConvertClaudeRequestToCodex_WebSearchToolChoiceUsesDeclaredTypedToolNam } } +func TestConvertClaudeRequestToCodex_ClaudeCodeWebSearchFunctionToolMapping(t *testing.T) { + // Recent Claude Code (>= 2.1.177) declares WebSearch as a plain function tool + // with an input_schema instead of a typed web_search_* server tool. It must + // still map to the Codex Responses builtin web_search tool, while ordinary + // function tools alongside it are preserved. + inputJSON := `{ + "model": "gpt-5.5", + "tools": [ + { + "name": "Bash", + "description": "Run a shell command", + "input_schema": {"type":"object","properties":{"command":{"type":"string"}},"required":["command"]} + }, + { + "name": "WebSearch", + "description": "Search the web. Returns result blocks with titles and URLs.", + "input_schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "query": {"type": "string", "minLength": 2, "description": "The search query to use"}, + "allowed_domains": {"type": "array", "items": {"type": "string"}}, + "blocked_domains": {"type": "array", "items": {"type": "string"}} + }, + "required": ["query"] + } + } + ], + "messages": [{"role": "user", "content": "search the latest Go release"}] + }` + + result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + + if got := resultJSON.Get("tools.#").Int(); got != 2 { + t.Fatalf("tools length = %d, want 2. Output: %s", got, string(result)) + } + webSearchTool := resultJSON.Get(`tools.#(type=="web_search")`) + if !webSearchTool.Exists() { + t.Fatalf("expected a builtin web_search tool. Output: %s", string(result)) + } + // The builtin tool must not leak the function schema fields. + if webSearchTool.Get("parameters").Exists() || webSearchTool.Get("input_schema").Exists() || webSearchTool.Get("name").Exists() { + t.Fatalf("builtin web_search tool should not carry name/parameters/input_schema. Output: %s", string(result)) + } + if resultJSON.Get(`tools.#(name=="WebSearch")`).Exists() { + t.Fatalf("WebSearch must not be forwarded as a function tool. Output: %s", string(result)) + } + // The ordinary Bash tool must still be forwarded as a function tool. + bashTool := resultJSON.Get(`tools.#(name=="Bash")`) + if !bashTool.Exists() { + t.Fatalf("expected Bash to remain a function tool. Output: %s", string(result)) + } + if got := bashTool.Get("type").String(); got != "function" { + t.Fatalf("Bash tool type = %q, want function. Output: %s", got, string(result)) + } + if got := bashTool.Get("parameters.properties.command.type").String(); got != "string" { + t.Fatalf("Bash tool parameters not preserved. Output: %s", string(result)) + } +} + +func TestConvertClaudeRequestToCodex_ClaudeCodeWebSearchToolChoiceMapsToBuiltin(t *testing.T) { + inputJSON := `{ + "model": "gpt-5.5", + "tools": [ + { + "name": "WebSearch", + "description": "Search the web.", + "input_schema": { + "type": "object", + "properties": { + "query": {"type": "string"}, + "allowed_domains": {"type": "array", "items": {"type": "string"}}, + "blocked_domains": {"type": "array", "items": {"type": "string"}} + }, + "required": ["query"] + } + } + ], + "tool_choice": {"type": "tool", "name": "WebSearch"}, + "messages": [{"role": "user", "content": "hello"}] + }` + + result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + + if got := resultJSON.Get("tool_choice.type").String(); got != "web_search" { + t.Fatalf("tool_choice.type = %q, want web_search. Output: %s", got, string(result)) + } +} + func TestConvertClaudeRequestToCodex_AssistantThinkingSignatureToReasoningItem(t *testing.T) { signature := validCodexReasoningSignature() inputJSON := `{