fix(claude): centralize oauth tool-name transform flow
This commit is contained in:
@@ -191,14 +191,8 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
|||||||
bodyForUpstream := body
|
bodyForUpstream := body
|
||||||
oauthToken := isClaudeOAuthToken(apiKey)
|
oauthToken := isClaudeOAuthToken(apiKey)
|
||||||
var oauthToolNamesReverseMap map[string]string
|
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 {
|
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).
|
// 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.
|
||||||
@@ -292,13 +286,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
|||||||
} else {
|
} else {
|
||||||
reporter.Publish(ctx, helps.ParseClaudeUsage(data))
|
reporter.Publish(ctx, helps.ParseClaudeUsage(data))
|
||||||
}
|
}
|
||||||
if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
|
data = restoreClaudeOAuthToolNamesFromResponse(data, claudeToolPrefix, auth.ToolPrefixDisabled(), oauthToolNamesReverseMap)
|
||||||
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)
|
|
||||||
}
|
|
||||||
var param any
|
var param any
|
||||||
out := sdktranslator.TranslateNonStream(
|
out := sdktranslator.TranslateNonStream(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -373,14 +361,8 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
bodyForUpstream := body
|
bodyForUpstream := body
|
||||||
oauthToken := isClaudeOAuthToken(apiKey)
|
oauthToken := isClaudeOAuthToken(apiKey)
|
||||||
var oauthToolNamesReverseMap map[string]string
|
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 {
|
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).
|
// Enable cch signing by default for OAuth tokens (not just experimental flag).
|
||||||
if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) {
|
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 {
|
if detail, ok := helps.ParseClaudeStreamUsage(line); ok {
|
||||||
reporter.Publish(ctx, detail)
|
reporter.Publish(ctx, detail)
|
||||||
}
|
}
|
||||||
if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
|
line = restoreClaudeOAuthToolNamesFromStreamLine(line, claudeToolPrefix, auth.ToolPrefixDisabled(), oauthToolNamesReverseMap)
|
||||||
line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix)
|
|
||||||
}
|
|
||||||
if isClaudeOAuthToken(apiKey) && len(oauthToolNamesReverseMap) > 0 {
|
|
||||||
line = reverseRemapOAuthToolNamesFromStreamLine(line, oauthToolNamesReverseMap)
|
|
||||||
}
|
|
||||||
// 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)
|
||||||
@@ -501,12 +478,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
if detail, ok := helps.ParseClaudeStreamUsage(line); ok {
|
if detail, ok := helps.ParseClaudeStreamUsage(line); ok {
|
||||||
reporter.Publish(ctx, detail)
|
reporter.Publish(ctx, detail)
|
||||||
}
|
}
|
||||||
if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
|
line = restoreClaudeOAuthToolNamesFromStreamLine(line, claudeToolPrefix, auth.ToolPrefixDisabled(), oauthToolNamesReverseMap)
|
||||||
line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix)
|
|
||||||
}
|
|
||||||
if isClaudeOAuthToken(apiKey) && len(oauthToolNamesReverseMap) > 0 {
|
|
||||||
line = reverseRemapOAuthToolNamesFromStreamLine(line, oauthToolNamesReverseMap)
|
|
||||||
}
|
|
||||||
chunks := sdktranslator.TranslateStream(
|
chunks := sdktranslator.TranslateStream(
|
||||||
ctx,
|
ctx,
|
||||||
to,
|
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)
|
// Extract betas from body and convert to header (for count_tokens too)
|
||||||
var extraBetas []string
|
var extraBetas []string
|
||||||
extraBetas, body = extractAndRemoveBetas(body)
|
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) {
|
if isClaudeOAuthToken(apiKey) {
|
||||||
body, _ = remapOAuthToolNames(body)
|
body, _ = prepareClaudeOAuthToolNamesForUpstream(body, claudeToolPrefix, auth.ToolPrefixDisabled())
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/v1/messages/count_tokens?beta=true", baseURL)
|
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")
|
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
|
// remapOAuthToolNames renames third-party tool names to Claude Code equivalents
|
||||||
// and removes tools without an official counterpart. This prevents Anthropic from
|
// and removes tools without an official counterpart. This prevents Anthropic from
|
||||||
// fingerprinting the request as a third-party client via tool naming patterns.
|
// fingerprinting the request as a third-party client via tool naming patterns.
|
||||||
|
|||||||
@@ -2090,3 +2090,67 @@ func TestReverseRemapOAuthToolNamesFromStreamLine_HonorsPerRequestMap(t *testing
|
|||||||
t.Fatalf("Glob should be restored to glob, got: %s", string(out))
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user