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
This commit is contained in:
@@ -45,6 +45,41 @@ type ClaudeExecutor struct {
|
|||||||
// Previously "proxy_" was used but this is a detectable fingerprint difference.
|
// Previously "proxy_" was used but this is a detectable fingerprint difference.
|
||||||
const claudeToolPrefix = ""
|
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
|
// Anthropic-compatible upstreams may reject or even crash when Claude models
|
||||||
// omit max_tokens. Prefer registered model metadata before using a fallback.
|
// omit max_tokens. Prefer registered model metadata before using a fallback.
|
||||||
const defaultModelMaxTokens = 1024
|
const defaultModelMaxTokens = 1024
|
||||||
@@ -161,6 +196,12 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
|||||||
if oauthToken && !auth.ToolPrefixDisabled() {
|
if oauthToken && !auth.ToolPrefixDisabled() {
|
||||||
bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)
|
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).
|
// 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.
|
// Claude Code always computes cch; missing or invalid cch is a detectable fingerprint.
|
||||||
if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) {
|
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() {
|
if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
|
||||||
data = stripClaudeToolPrefixFromResponse(data, claudeToolPrefix)
|
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
|
var param any
|
||||||
out := sdktranslator.TranslateNonStream(
|
out := sdktranslator.TranslateNonStream(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -332,6 +377,12 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
if oauthToken && !auth.ToolPrefixDisabled() {
|
if oauthToken && !auth.ToolPrefixDisabled() {
|
||||||
bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)
|
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).
|
// Enable cch signing by default for OAuth tokens (not just experimental flag).
|
||||||
if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) {
|
if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) {
|
||||||
bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream)
|
bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream)
|
||||||
@@ -424,6 +475,9 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
|
if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
|
||||||
line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix)
|
line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix)
|
||||||
}
|
}
|
||||||
|
if isClaudeOAuthToken(apiKey) {
|
||||||
|
line = reverseRemapOAuthToolNamesFromStreamLine(line)
|
||||||
|
}
|
||||||
// Forward the line as-is to preserve SSE format
|
// Forward the line as-is to preserve SSE format
|
||||||
cloned := make([]byte, len(line)+1)
|
cloned := make([]byte, len(line)+1)
|
||||||
copy(cloned, line)
|
copy(cloned, line)
|
||||||
@@ -451,6 +505,9 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
|
if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
|
||||||
line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix)
|
line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix)
|
||||||
}
|
}
|
||||||
|
if isClaudeOAuthToken(apiKey) {
|
||||||
|
line = reverseRemapOAuthToolNamesFromStreamLine(line)
|
||||||
|
}
|
||||||
chunks := sdktranslator.TranslateStream(
|
chunks := sdktranslator.TranslateStream(
|
||||||
ctx,
|
ctx,
|
||||||
to,
|
to,
|
||||||
@@ -503,6 +560,10 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
|
if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
|
||||||
body = applyClaudeToolPrefix(body, claudeToolPrefix)
|
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)
|
url := fmt.Sprintf("%s/v1/messages/count_tokens?beta=true", baseURL)
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
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")
|
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 {
|
func applyClaudeToolPrefix(body []byte, prefix string) []byte {
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
return body
|
return body
|
||||||
|
|||||||
Reference in New Issue
Block a user