Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions internal/translator/codex/claude/codex_claude_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -355,15 +355,52 @@ 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() {
return names
}

tools.ForEach(func(_, tool gjson.Result) bool {
toolType := tool.Get("type").String()
if !isClaudeWebSearchToolType(toolType) {
if !isClaudeWebSearchTool(tool) {
return true
}

Expand Down
91 changes: 91 additions & 0 deletions internal/translator/codex/claude/codex_claude_request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := `{
Expand Down
Loading