diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 8f2fa222..0da32935 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -57,9 +57,9 @@ var oauthToolRenameMap = map[string]string{ "glob": "Glob", "grep": "Grep", "task": "Task", - "webfetch": "WebFetch", - "todowrite": "TodoWrite", - "question": "Question", + "webfetch": "WebFetch", + "todowrite": "TodoWrite", + "question": "Question", "skill": "Skill", "ls": "LS", "todoread": "TodoRead", @@ -192,6 +192,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r bodyForTranslation := body bodyForUpstream := body oauthToken := isClaudeOAuthToken(apiKey) + oauthToolNamesRemapped := false if oauthToken && !auth.ToolPrefixDisabled() { bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix) } @@ -199,7 +200,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // tools without official counterparts. This prevents Anthropic from // fingerprinting the request as third-party via tool naming patterns. if oauthToken { - bodyForUpstream = remapOAuthToolNames(bodyForUpstream) + bodyForUpstream, oauthToolNamesRemapped = remapOAuthToolNames(bodyForUpstream) } // Enable cch signing by default for OAuth tokens (not just experimental flag). // Claude Code always computes cch; missing or invalid cch is a detectable fingerprint. @@ -297,7 +298,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r data = stripClaudeToolPrefixFromResponse(data, claudeToolPrefix) } // Reverse the OAuth tool name remap so the downstream client sees original names. - if isClaudeOAuthToken(apiKey) { + if isClaudeOAuthToken(apiKey) && oauthToolNamesRemapped { data = reverseRemapOAuthToolNames(data) } var param any @@ -373,6 +374,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A bodyForTranslation := body bodyForUpstream := body oauthToken := isClaudeOAuthToken(apiKey) + oauthToolNamesRemapped := false if oauthToken && !auth.ToolPrefixDisabled() { bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix) } @@ -380,7 +382,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A // tools without official counterparts. This prevents Anthropic from // fingerprinting the request as third-party via tool naming patterns. if oauthToken { - bodyForUpstream = remapOAuthToolNames(bodyForUpstream) + bodyForUpstream, oauthToolNamesRemapped = remapOAuthToolNames(bodyForUpstream) } // Enable cch signing by default for OAuth tokens (not just experimental flag). if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) { @@ -474,7 +476,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() { line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix) } - if isClaudeOAuthToken(apiKey) { + if isClaudeOAuthToken(apiKey) && oauthToolNamesRemapped { line = reverseRemapOAuthToolNamesFromStreamLine(line) } // Forward the line as-is to preserve SSE format @@ -504,7 +506,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() { line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix) } - if isClaudeOAuthToken(apiKey) { + if isClaudeOAuthToken(apiKey) && oauthToolNamesRemapped { line = reverseRemapOAuthToolNamesFromStreamLine(line) } chunks := sdktranslator.TranslateStream( @@ -561,7 +563,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut } // Remap tool names for OAuth token requests to avoid third-party fingerprinting. if isClaudeOAuthToken(apiKey) { - body = remapOAuthToolNames(body) + body, _ = remapOAuthToolNames(body) } url := fmt.Sprintf("%s/v1/messages/count_tokens?beta=true", baseURL) @@ -1018,7 +1020,8 @@ func isClaudeOAuthToken(apiKey string) bool { // It operates on: tools[].name, tool_choice.name, and all tool_use/tool_reference // references in messages. Removed tools' corresponding tool_result blocks are preserved // (they just become orphaned, which is safe for Claude). -func remapOAuthToolNames(body []byte) []byte { +func remapOAuthToolNames(body []byte) ([]byte, bool) { + renamed := false // 1. Rewrite tools array in a single pass (if present). // IMPORTANT: do not mutate names first and then rebuild from an older gjson // snapshot. gjson results are snapshots of the original bytes; rebuilding from a @@ -1027,42 +1030,43 @@ func remapOAuthToolNames(body []byte) []byte { tools := gjson.GetBytes(body, "tools") if tools.Exists() && tools.IsArray() { - var toolsJSON strings.Builder - toolsJSON.WriteByte('[') - toolCount := 0 - tools.ForEach(func(_, tool gjson.Result) bool { - // Keep Anthropic built-in tools (web_search, code_execution, etc.) unchanged. - if tool.Get("type").Exists() && tool.Get("type").String() != "" { + var toolsJSON strings.Builder + toolsJSON.WriteByte('[') + toolCount := 0 + tools.ForEach(func(_, tool gjson.Result) bool { + // Keep Anthropic built-in tools (web_search, code_execution, etc.) unchanged. + if tool.Get("type").Exists() && tool.Get("type").String() != "" { + if toolCount > 0 { + toolsJSON.WriteByte(',') + } + toolsJSON.WriteString(tool.Raw) + toolCount++ + return true + } + + name := tool.Get("name").String() + if oauthToolsToRemove[name] { + return true + } + + toolJSON := tool.Raw + if newName, ok := oauthToolRenameMap[name]; ok && newName != name { + updatedTool, err := sjson.Set(toolJSON, "name", newName) + if err == nil { + toolJSON = updatedTool + renamed = true + } + } + if toolCount > 0 { toolsJSON.WriteByte(',') } - toolsJSON.WriteString(tool.Raw) + toolsJSON.WriteString(toolJSON) toolCount++ return true - } - - name := tool.Get("name").String() - if oauthToolsToRemove[name] { - return true - } - - toolJSON := tool.Raw - if newName, ok := oauthToolRenameMap[name]; ok { - updatedTool, err := sjson.Set(toolJSON, "name", newName) - if err == nil { - toolJSON = updatedTool - } - } - - if toolCount > 0 { - toolsJSON.WriteByte(',') - } - toolsJSON.WriteString(toolJSON) - toolCount++ - return true - }) - toolsJSON.WriteByte(']') - body, _ = sjson.SetRawBytes(body, "tools", []byte(toolsJSON.String())) + }) + toolsJSON.WriteByte(']') + body, _ = sjson.SetRawBytes(body, "tools", []byte(toolsJSON.String())) } // 2. Rename tool_choice if it references a known tool @@ -1073,8 +1077,9 @@ func remapOAuthToolNames(body []byte) []byte { // The chosen tool was removed from the tools array, so drop tool_choice to // keep the payload internally consistent and fall back to normal auto tool use. body, _ = sjson.DeleteBytes(body, "tool_choice") - } else if newName, ok := oauthToolRenameMap[tcName]; ok { + } else if newName, ok := oauthToolRenameMap[tcName]; ok && newName != tcName { body, _ = sjson.SetBytes(body, "tool_choice.name", newName) + renamed = true } } @@ -1091,15 +1096,17 @@ func remapOAuthToolNames(body []byte) []byte { switch partType { case "tool_use": name := part.Get("name").String() - if newName, ok := oauthToolRenameMap[name]; ok { + if newName, ok := oauthToolRenameMap[name]; ok && newName != name { path := fmt.Sprintf("messages.%d.content.%d.name", msgIndex.Int(), contentIndex.Int()) body, _ = sjson.SetBytes(body, path, newName) + renamed = true } case "tool_reference": toolName := part.Get("tool_name").String() - if newName, ok := oauthToolRenameMap[toolName]; ok { + if newName, ok := oauthToolRenameMap[toolName]; ok && newName != toolName { path := fmt.Sprintf("messages.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int()) body, _ = sjson.SetBytes(body, path, newName) + renamed = true } case "tool_result": // Handle nested tool_reference blocks inside tool_result.content[] @@ -1110,9 +1117,10 @@ func remapOAuthToolNames(body []byte) []byte { nestedContent.ForEach(func(nestedIndex, nestedPart gjson.Result) bool { if nestedPart.Get("type").String() == "tool_reference" { nestedToolName := nestedPart.Get("tool_name").String() - if newName, ok := oauthToolRenameMap[nestedToolName]; ok { + if newName, ok := oauthToolRenameMap[nestedToolName]; ok && newName != nestedToolName { nestedPath := fmt.Sprintf("messages.%d.content.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int(), nestedIndex.Int()) body, _ = sjson.SetBytes(body, nestedPath, newName) + renamed = true } } return true @@ -1125,7 +1133,7 @@ func remapOAuthToolNames(body []byte) []byte { }) } - return body + return body, renamed } // reverseRemapOAuthToolNames reverses the tool name mapping for non-stream responses. diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 2cf969bb..f456064d 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -1949,3 +1949,45 @@ func TestNormalizeClaudeTemperatureForThinking_AfterForcedToolChoiceKeepsOrigina t.Fatalf("temperature = %v, want 0", got) } } + +func TestRemapOAuthToolNames_TitleCase_NoReverseNeeded(t *testing.T) { + body := []byte(`{"tools":[{"name":"Bash","description":"Run shell commands","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}}}}],"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`) + + out, renamed := remapOAuthToolNames(body) + if renamed { + t.Fatalf("renamed = true, want false") + } + if got := gjson.GetBytes(out, "tools.0.name").String(); got != "Bash" { + t.Fatalf("tools.0.name = %q, want %q", got, "Bash") + } + + resp := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"cmd":"ls"}}]}`) + reversed := resp + if renamed { + reversed = reverseRemapOAuthToolNames(resp) + } + if got := gjson.GetBytes(reversed, "content.0.name").String(); got != "Bash" { + t.Fatalf("content.0.name = %q, want %q", got, "Bash") + } +} + +func TestRemapOAuthToolNames_Lowercase_ReverseApplied(t *testing.T) { + body := []byte(`{"tools":[{"name":"bash","description":"Run shell commands","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}}}}],"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`) + + out, renamed := remapOAuthToolNames(body) + if !renamed { + t.Fatalf("renamed = false, want true") + } + if got := gjson.GetBytes(out, "tools.0.name").String(); got != "Bash" { + t.Fatalf("tools.0.name = %q, want %q", got, "Bash") + } + + resp := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"cmd":"ls"}}]}`) + reversed := resp + if renamed { + reversed = reverseRemapOAuthToolNames(resp) + } + if got := gjson.GetBytes(reversed, "content.0.name").String(); got != "bash" { + t.Fatalf("content.0.name = %q, want %q", got, "bash") + } +}