From fc1ddf365f489ca465e5fd85334d01303e635f11 Mon Sep 17 00:00:00 2001 From: Enzo Lucchesi Date: Sun, 19 Apr 2026 14:36:25 +0000 Subject: [PATCH] fix(claude): centralize oauth tool-name transform flow --- internal/runtime/executor/claude_executor.go | 74 +++++++++---------- .../runtime/executor/claude_executor_test.go | 64 ++++++++++++++++ 2 files changed, 100 insertions(+), 38 deletions(-) diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 78fa3cd6f..2dbff1d3e 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -191,14 +191,8 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r bodyForUpstream := body oauthToken := isClaudeOAuthToken(apiKey) var oauthToolNamesReverseMap map[string]string - 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, oauthToolNamesReverseMap = remapOAuthToolNames(bodyForUpstream) + bodyForUpstream, oauthToolNamesReverseMap = prepareClaudeOAuthToolNamesForUpstream(bodyForUpstream, claudeToolPrefix, auth.ToolPrefixDisabled()) } // 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. @@ -292,13 +286,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } else { reporter.Publish(ctx, helps.ParseClaudeUsage(data)) } - 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) && len(oauthToolNamesReverseMap) > 0 { - data = reverseRemapOAuthToolNames(data, oauthToolNamesReverseMap) - } + data = restoreClaudeOAuthToolNamesFromResponse(data, claudeToolPrefix, auth.ToolPrefixDisabled(), oauthToolNamesReverseMap) var param any out := sdktranslator.TranslateNonStream( ctx, @@ -373,14 +361,8 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A bodyForUpstream := body oauthToken := isClaudeOAuthToken(apiKey) var oauthToolNamesReverseMap map[string]string - 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, oauthToolNamesReverseMap = remapOAuthToolNames(bodyForUpstream) + bodyForUpstream, oauthToolNamesReverseMap = prepareClaudeOAuthToolNamesForUpstream(bodyForUpstream, claudeToolPrefix, auth.ToolPrefixDisabled()) } // Enable cch signing by default for OAuth tokens (not just experimental flag). if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) { @@ -471,12 +453,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if detail, ok := helps.ParseClaudeStreamUsage(line); ok { reporter.Publish(ctx, detail) } - if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() { - line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix) - } - if isClaudeOAuthToken(apiKey) && len(oauthToolNamesReverseMap) > 0 { - line = reverseRemapOAuthToolNamesFromStreamLine(line, oauthToolNamesReverseMap) - } + line = restoreClaudeOAuthToolNamesFromStreamLine(line, claudeToolPrefix, auth.ToolPrefixDisabled(), oauthToolNamesReverseMap) // Forward the line as-is to preserve SSE format cloned := make([]byte, len(line)+1) copy(cloned, line) @@ -501,12 +478,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if detail, ok := helps.ParseClaudeStreamUsage(line); ok { reporter.Publish(ctx, detail) } - if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() { - line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix) - } - if isClaudeOAuthToken(apiKey) && len(oauthToolNamesReverseMap) > 0 { - line = reverseRemapOAuthToolNamesFromStreamLine(line, oauthToolNamesReverseMap) - } + line = restoreClaudeOAuthToolNamesFromStreamLine(line, claudeToolPrefix, auth.ToolPrefixDisabled(), oauthToolNamesReverseMap) chunks := sdktranslator.TranslateStream( ctx, to, @@ -556,12 +528,8 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut // Extract betas from body and convert to header (for count_tokens too) var extraBetas []string extraBetas, body = extractAndRemoveBetas(body) - 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) + body, _ = prepareClaudeOAuthToolNamesForUpstream(body, claudeToolPrefix, auth.ToolPrefixDisabled()) } url := fmt.Sprintf("%s/v1/messages/count_tokens?beta=true", baseURL) @@ -1001,6 +969,36 @@ func isClaudeOAuthToken(apiKey string) bool { return strings.Contains(apiKey, "sk-ant-oat") } +// prepareClaudeOAuthToolNamesForUpstream applies the Claude OAuth tool-name +// transforms in the same order across request paths. Remap runs before prefixing +// so any future non-empty prefix still composes correctly with the per-request +// reverse map. +func prepareClaudeOAuthToolNamesForUpstream(body []byte, prefix string, prefixDisabled bool) ([]byte, map[string]string) { + body, reverseMap := remapOAuthToolNames(body) + if !prefixDisabled { + body = applyClaudeToolPrefix(body, prefix) + } + return body, reverseMap +} + +// restoreClaudeOAuthToolNamesFromResponse undoes the Claude OAuth tool-name +// transforms for non-stream responses in reverse order. +func restoreClaudeOAuthToolNamesFromResponse(body []byte, prefix string, prefixDisabled bool, reverseMap map[string]string) []byte { + if !prefixDisabled { + body = stripClaudeToolPrefixFromResponse(body, prefix) + } + return reverseRemapOAuthToolNames(body, reverseMap) +} + +// restoreClaudeOAuthToolNamesFromStreamLine undoes the Claude OAuth tool-name +// transforms for SSE lines in reverse order. +func restoreClaudeOAuthToolNamesFromStreamLine(line []byte, prefix string, prefixDisabled bool, reverseMap map[string]string) []byte { + if !prefixDisabled { + line = stripClaudeToolPrefixFromStreamLine(line, prefix) + } + return reverseRemapOAuthToolNamesFromStreamLine(line, reverseMap) +} + // 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. diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 0176340b5..9011be04b 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -2090,3 +2090,67 @@ func TestReverseRemapOAuthToolNamesFromStreamLine_HonorsPerRequestMap(t *testing t.Fatalf("Glob should be restored to glob, got: %s", string(out)) } } + +func TestPrepareClaudeOAuthToolNamesForUpstream_MixedCaseWithPrefix(t *testing.T) { + body := []byte(`{"tools":[` + + `{"name":"Bash","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}}}},` + + `{"name":"glob","input_schema":{"type":"object","properties":{"filePattern":{"type":"string"}}}}` + + `],"messages":[{"role":"assistant","content":[` + + `{"type":"tool_use","id":"toolu_01","name":"Bash","input":{}},` + + `{"type":"tool_use","id":"toolu_02","name":"glob","input":{}}` + + `]}]}`) + + out, reverseMap := prepareClaudeOAuthToolNamesForUpstream(body, "proxy_", false) + + if got := gjson.GetBytes(out, "tools.0.name").String(); got != "proxy_Bash" { + t.Fatalf("tools.0.name = %q, want %q", got, "proxy_Bash") + } + if got := gjson.GetBytes(out, "tools.1.name").String(); got != "proxy_Glob" { + t.Fatalf("tools.1.name = %q, want %q", got, "proxy_Glob") + } + if got := gjson.GetBytes(out, "messages.0.content.0.name").String(); got != "proxy_Bash" { + t.Fatalf("messages.0.content.0.name = %q, want %q", got, "proxy_Bash") + } + if got := gjson.GetBytes(out, "messages.0.content.1.name").String(); got != "proxy_Glob" { + t.Fatalf("messages.0.content.1.name = %q, want %q", got, "proxy_Glob") + } + if len(reverseMap) != 1 || reverseMap["Glob"] != "glob" { + t.Fatalf("reverseMap = %v, want {Glob:glob}", reverseMap) + } +} + +func TestRestoreClaudeOAuthToolNamesFromResponse_MixedCaseWithPrefix(t *testing.T) { + reverseMap := map[string]string{"Glob": "glob"} + resp := []byte(`{"content":[` + + `{"type":"tool_use","id":"toolu_01","name":"proxy_Bash","input":{}},` + + `{"type":"tool_use","id":"toolu_02","name":"proxy_Glob","input":{}}` + + `]}`) + + out := restoreClaudeOAuthToolNamesFromResponse(resp, "proxy_", false, reverseMap) + + if got := gjson.GetBytes(out, "content.0.name").String(); got != "Bash" { + t.Fatalf("content.0.name = %q, want %q", got, "Bash") + } + if got := gjson.GetBytes(out, "content.1.name").String(); got != "glob" { + t.Fatalf("content.1.name = %q, want %q", got, "glob") + } +} + +func TestRestoreClaudeOAuthToolNamesFromStreamLine_MixedCaseWithPrefix(t *testing.T) { + reverseMap := map[string]string{"Glob": "glob"} + + bashLine := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01","name":"proxy_Bash","input":{}}}`) + out := restoreClaudeOAuthToolNamesFromStreamLine(bashLine, "proxy_", false, reverseMap) + if !bytes.Contains(out, []byte(`"name":"Bash"`)) { + t.Fatalf("Bash should be preserved, got: %s", string(out)) + } + if bytes.Contains(out, []byte(`"name":"bash"`)) { + t.Fatalf("Bash must not be lowercased, got: %s", string(out)) + } + + globLine := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_02","name":"proxy_Glob","input":{}}}`) + out = restoreClaudeOAuthToolNamesFromStreamLine(globLine, "proxy_", false, reverseMap) + if !bytes.Contains(out, []byte(`"name":"glob"`)) { + t.Fatalf("Glob should be restored to glob, got: %s", string(out)) + } +}