From e8d1b79cb3743778c87c6ccdf75cb5c92e2cdf7e Mon Sep 17 00:00:00 2001 From: wykk-12138 Date: Thu, 9 Apr 2026 20:15:16 +0800 Subject: [PATCH] fix(claude): remap OAuth tool names to Claude Code style to avoid third-party fingerprint detection A/B testing confirmed that Anthropic uses tool name fingerprinting to detect third-party clients on OAuth traffic. OpenCode-style lowercase names like 'bash', 'read', 'todowrite' trigger extra-usage billing, while Claude Code TitleCase names like 'Bash', 'Read', 'TodoWrite' pass through normally. Changes: - Add oauthToolRenameMap: maps lowercase tool names to Claude Code equivalents - Add oauthToolsToRemove: removes 'question' and 'skill' (no Claude Code counterpart) - remapOAuthToolNames: renames tools, removes blacklisted ones, updates tool_choice and messages - reverseRemapOAuthToolNames/reverseRemapOAuthToolNamesFromStreamLine: reverse map for responses - Apply in Execute(), ExecuteStream(), and CountTokens() for OAuth token requests --- internal/runtime/executor/claude_executor.go | 258 +++++++++++++++++++ 1 file changed, 258 insertions(+) diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index ef18316c..7d7396c3 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -45,6 +45,41 @@ type ClaudeExecutor struct { // Previously "proxy_" was used but this is a detectable fingerprint difference. const claudeToolPrefix = "" +// oauthToolRenameMap maps OpenCode-style (lowercase) tool names to Claude Code-style +// (TitleCase) names. Anthropic uses tool name fingerprinting to detect third-party +// clients on OAuth traffic. Renaming to official names avoids extra-usage billing. +// Tools without a Claude Code equivalent (e.g. "question", "skill") are removed entirely. +var oauthToolRenameMap = map[string]string{ + "bash": "Bash", + "read": "Read", + "write": "Write", + "edit": "Edit", + "glob": "Glob", + "grep": "Grep", + "task": "Task", + "webfetch": "WebFetch", + "todowrite": "TodoWrite", + "ls": "LS", + "todoread": "TodoRead", + "notebookedit": "NotebookEdit", +} + +// oauthToolRenameReverseMap is the inverse of oauthToolRenameMap for response decoding. +var oauthToolRenameReverseMap = func() map[string]string { + m := make(map[string]string, len(oauthToolRenameMap)) + for k, v := range oauthToolRenameMap { + m[v] = k + } + return m +}() + +// oauthToolsToRemove lists tool names that have no Claude Code equivalent and must +// be stripped from OAuth requests to avoid third-party fingerprinting. +var oauthToolsToRemove = map[string]bool{ + "question": true, + "skill": true, +} + // Anthropic-compatible upstreams may reject or even crash when Claude models // omit max_tokens. Prefer registered model metadata before using a fallback. const defaultModelMaxTokens = 1024 @@ -161,6 +196,12 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if oauthToken && !auth.ToolPrefixDisabled() { bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix) } + // Remap third-party tool names to Claude Code equivalents and remove + // tools without official counterparts. This prevents Anthropic from + // fingerprinting the request as third-party via tool naming patterns. + if oauthToken { + bodyForUpstream = 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. if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) { @@ -256,6 +297,10 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() { data = stripClaudeToolPrefixFromResponse(data, claudeToolPrefix) } + // Reverse the OAuth tool name remap so the downstream client sees original names. + if isClaudeOAuthToken(apiKey) { + data = reverseRemapOAuthToolNames(data) + } var param any out := sdktranslator.TranslateNonStream( ctx, @@ -332,6 +377,12 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if oauthToken && !auth.ToolPrefixDisabled() { bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix) } + // Remap third-party tool names to Claude Code equivalents and remove + // tools without official counterparts. This prevents Anthropic from + // fingerprinting the request as third-party via tool naming patterns. + if oauthToken { + bodyForUpstream = remapOAuthToolNames(bodyForUpstream) + } // Enable cch signing by default for OAuth tokens (not just experimental flag). if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) { bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream) @@ -424,6 +475,9 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() { line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix) } + if isClaudeOAuthToken(apiKey) { + line = reverseRemapOAuthToolNamesFromStreamLine(line) + } // Forward the line as-is to preserve SSE format cloned := make([]byte, len(line)+1) copy(cloned, line) @@ -451,6 +505,9 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() { line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix) } + if isClaudeOAuthToken(apiKey) { + line = reverseRemapOAuthToolNamesFromStreamLine(line) + } chunks := sdktranslator.TranslateStream( ctx, to, @@ -503,6 +560,10 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() { body = applyClaudeToolPrefix(body, claudeToolPrefix) } + // Remap tool names for OAuth token requests to avoid third-party fingerprinting. + if isClaudeOAuthToken(apiKey) { + body = remapOAuthToolNames(body) + } url := fmt.Sprintf("%s/v1/messages/count_tokens?beta=true", baseURL) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) @@ -951,6 +1012,203 @@ func isClaudeOAuthToken(apiKey string) bool { return strings.Contains(apiKey, "sk-ant-oat") } +// remapOAuthToolNames renames third-party tool names to Claude Code equivalents +// and removes tools without an official counterpart. This prevents Anthropic from +// fingerprinting the request as a third-party client via tool naming patterns. +// +// 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 { + // 1. Rename and filter tools array + tools := gjson.GetBytes(body, "tools") + if !tools.Exists() || !tools.IsArray() { + return body + } + + // First pass: rename tools that have Claude Code equivalents. + tools.ForEach(func(idx, tool gjson.Result) bool { + // Skip built-in tools (web_search, code_execution, etc.) which have a "type" field + if tool.Get("type").Exists() && tool.Get("type").String() != "" { + return true + } + name := tool.Get("name").String() + if newName, ok := oauthToolRenameMap[name]; ok { + path := fmt.Sprintf("tools.%d.name", idx.Int()) + body, _ = sjson.SetBytes(body, path, newName) + } + return true + }) + + // Second pass: remove tools that are in oauthToolsToRemove by rebuilding the array. + // This avoids index-shifting issues with sjson.DeleteBytes. + var newTools []gjson.Result + toRemove := false + tools.ForEach(func(_, tool gjson.Result) bool { + // Skip built-in tools from removal check + if tool.Get("type").Exists() && tool.Get("type").String() != "" { + newTools = append(newTools, tool) + return true + } + name := tool.Get("name").String() + if oauthToolsToRemove[name] { + toRemove = true + return true + } + newTools = append(newTools, tool) + return true + }) + + if toRemove { + // Rebuild the tools array without removed tools + var toolsJSON strings.Builder + toolsJSON.WriteByte('[') + for i, t := range newTools { + if i > 0 { + toolsJSON.WriteByte(',') + } + toolsJSON.WriteString(t.Raw) + } + toolsJSON.WriteByte(']') + body, _ = sjson.SetRawBytes(body, "tools", []byte(toolsJSON.String())) + } + + // 2. Rename tool_choice if it references a known tool + toolChoiceType := gjson.GetBytes(body, "tool_choice.type").String() + if toolChoiceType == "tool" { + tcName := gjson.GetBytes(body, "tool_choice.name").String() + if newName, ok := oauthToolRenameMap[tcName]; ok { + body, _ = sjson.SetBytes(body, "tool_choice.name", newName) + } + } + + // 3. Rename tool references in messages + messages := gjson.GetBytes(body, "messages") + if messages.Exists() && messages.IsArray() { + messages.ForEach(func(msgIndex, msg gjson.Result) bool { + content := msg.Get("content") + if !content.Exists() || !content.IsArray() { + return true + } + content.ForEach(func(contentIndex, part gjson.Result) bool { + partType := part.Get("type").String() + switch partType { + case "tool_use": + name := part.Get("name").String() + if newName, ok := oauthToolRenameMap[name]; ok { + path := fmt.Sprintf("messages.%d.content.%d.name", msgIndex.Int(), contentIndex.Int()) + body, _ = sjson.SetBytes(body, path, newName) + } + case "tool_reference": + toolName := part.Get("tool_name").String() + if newName, ok := oauthToolRenameMap[toolName]; ok { + path := fmt.Sprintf("messages.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int()) + body, _ = sjson.SetBytes(body, path, newName) + } + case "tool_result": + // Handle nested tool_reference blocks inside tool_result.content[] + toolID := part.Get("tool_use_id").String() + _ = toolID // tool_use_id stays as-is + nestedContent := part.Get("content") + if nestedContent.Exists() && nestedContent.IsArray() { + 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 { + nestedPath := fmt.Sprintf("messages.%d.content.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int(), nestedIndex.Int()) + body, _ = sjson.SetBytes(body, nestedPath, newName) + } + } + return true + }) + } + } + return true + }) + return true + }) + } + + return body +} + +// reverseRemapOAuthToolNames reverses the tool name mapping for non-stream responses. +// It maps Claude Code TitleCase names back to the original lowercase names so the +// downstream client receives tool names it recognizes. +func reverseRemapOAuthToolNames(body []byte) []byte { + content := gjson.GetBytes(body, "content") + if !content.Exists() || !content.IsArray() { + return body + } + content.ForEach(func(index, part gjson.Result) bool { + partType := part.Get("type").String() + switch partType { + case "tool_use": + name := part.Get("name").String() + if origName, ok := oauthToolRenameReverseMap[name]; ok { + path := fmt.Sprintf("content.%d.name", index.Int()) + body, _ = sjson.SetBytes(body, path, origName) + } + case "tool_reference": + toolName := part.Get("tool_name").String() + if origName, ok := oauthToolRenameReverseMap[toolName]; ok { + path := fmt.Sprintf("content.%d.tool_name", index.Int()) + body, _ = sjson.SetBytes(body, path, origName) + } + } + return true + }) + return body +} + +// reverseRemapOAuthToolNamesFromStreamLine reverses the tool name mapping for SSE stream lines. +func reverseRemapOAuthToolNamesFromStreamLine(line []byte) []byte { + payload := helps.JSONPayload(line) + if len(payload) == 0 || !gjson.ValidBytes(payload) { + return line + } + + contentBlock := gjson.GetBytes(payload, "content_block") + if !contentBlock.Exists() { + return line + } + + blockType := contentBlock.Get("type").String() + var updated []byte + var err error + + switch blockType { + case "tool_use": + name := contentBlock.Get("name").String() + if origName, ok := oauthToolRenameReverseMap[name]; ok { + updated, err = sjson.SetBytes(payload, "content_block.name", origName) + if err != nil { + return line + } + } else { + return line + } + case "tool_reference": + toolName := contentBlock.Get("tool_name").String() + if origName, ok := oauthToolRenameReverseMap[toolName]; ok { + updated, err = sjson.SetBytes(payload, "content_block.tool_name", origName) + if err != nil { + return line + } + } else { + return line + } + default: + return line + } + + trimmed := bytes.TrimSpace(line) + if bytes.HasPrefix(trimmed, []byte("data:")) { + return append([]byte("data: "), updated...) + } + return updated +} + func applyClaudeToolPrefix(body []byte, prefix string) []byte { if prefix == "" { return body