Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 125 additions & 20 deletions internal/runtime/executor/claude_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"net/textproto"
"strings"
"time"
"unicode"

"github.com/andybalholm/brotli"
"github.com/google/uuid"
Expand Down Expand Up @@ -79,6 +80,56 @@ var oauthToolRenameReverseMap = func() map[string]string {
// even after remapping. Currently empty — all tools are mapped instead of removed.
var oauthToolsToRemove = map[string]bool{}

// snakeCaseToTitleCase converts snake_case or kebab-case tool names to TitleCase.
// MCP tools (containing "__") are left unchanged as they follow a separate naming convention.
// Examples: "sessions_list" -> "SessionsList", "agents_list" -> "AgentsList"
func snakeCaseToTitleCase(name string) string {
if name == "" {
return name
}
// Skip MCP tools (mcp__server__tool format)
if strings.Contains(name, "__") {
return name
}
// Skip if no separators (already TitleCase or single word)
if !strings.ContainsAny(name, "_-") {
return name
}
var sb strings.Builder
capitalize := true
for _, r := range name {
if r == '_' || r == '-' {
capitalize = true
continue
}
if capitalize {
sb.WriteRune(unicode.ToUpper(r))
capitalize = false
} else {
sb.WriteRune(r)
}
}
return sb.String()
}

// dynamicOAuthToolRename attempts to rename a tool name using snakeCaseToTitleCase
// when it is not found in the static oauthToolRenameMap. Returns the new name and
// true if a rename was performed, or the original name and false otherwise.
// Collision check ensures no clash with existing static map values.
func dynamicOAuthToolRename(name string) (string, bool) {
newName := snakeCaseToTitleCase(name)
if newName == name {
return name, false
}
// Collision check against existing static mappings
for _, v := range oauthToolRenameMap {
if v == newName {
return name, false
}
}
return newName, 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
Expand Down Expand Up @@ -193,14 +244,15 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
bodyForUpstream := body
oauthToken := isClaudeOAuthToken(apiKey)
oauthToolNamesRemapped := false
var oauthDynReverse 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, oauthToolNamesRemapped = remapOAuthToolNames(bodyForUpstream)
bodyForUpstream, oauthToolNamesRemapped, oauthDynReverse = 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.
Expand Down Expand Up @@ -298,8 +350,17 @@ 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.
// When stream=true (format translation), data is SSE-formatted; apply line-by-line reverse.
if isClaudeOAuthToken(apiKey) && oauthToolNamesRemapped {
data = reverseRemapOAuthToolNames(data)
if stream {
var reversedLines [][]byte
for _, line := range bytes.Split(data, []byte("\n")) {
reversedLines = append(reversedLines, reverseRemapOAuthToolNamesFromStreamLine(line, oauthDynReverse))
}
data = bytes.Join(reversedLines, []byte("\n"))
} else {
data = reverseRemapOAuthToolNames(data, oauthDynReverse)
}
}
var param any
out := sdktranslator.TranslateNonStream(
Expand Down Expand Up @@ -375,14 +436,15 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
bodyForUpstream := body
oauthToken := isClaudeOAuthToken(apiKey)
oauthToolNamesRemapped := false
var oauthDynReverse 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, oauthToolNamesRemapped = remapOAuthToolNames(bodyForUpstream)
bodyForUpstream, oauthToolNamesRemapped, oauthDynReverse = remapOAuthToolNames(bodyForUpstream)
}
// Enable cch signing by default for OAuth tokens (not just experimental flag).
if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) {
Expand Down Expand Up @@ -477,7 +539,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix)
}
if isClaudeOAuthToken(apiKey) && oauthToolNamesRemapped {
line = reverseRemapOAuthToolNamesFromStreamLine(line)
line = reverseRemapOAuthToolNamesFromStreamLine(line, oauthDynReverse)
}
// Forward the line as-is to preserve SSE format
cloned := make([]byte, len(line)+1)
Expand Down Expand Up @@ -507,7 +569,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix)
}
if isClaudeOAuthToken(apiKey) && oauthToolNamesRemapped {
line = reverseRemapOAuthToolNamesFromStreamLine(line)
line = reverseRemapOAuthToolNamesFromStreamLine(line, oauthDynReverse)
}
chunks := sdktranslator.TranslateStream(
ctx,
Expand Down Expand Up @@ -563,7 +625,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)
Expand Down Expand Up @@ -1020,8 +1082,25 @@ 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, bool) {
func remapOAuthToolNames(body []byte) ([]byte, bool, map[string]string) {
renamed := false
dynReverse := map[string]string{}

// resolveNewName tries the static map first, then falls back to dynamic
// snakeCaseToTitleCase conversion. Dynamic renames are recorded in dynReverse
// for later response decoding.
resolveNewName := func(name string) (string, bool) {
if newName, ok := oauthToolRenameMap[name]; ok && newName != name {
return newName, true
}
if newName, ok := dynamicOAuthToolRename(name); ok {
dynReverse[newName] = name
log.Debugf("claude: dynamic tool rename: %q -> %q", name, newName)
return newName, true
}
return name, 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
Expand Down Expand Up @@ -1050,7 +1129,7 @@ func remapOAuthToolNames(body []byte) ([]byte, bool) {
}

toolJSON := tool.Raw
if newName, ok := oauthToolRenameMap[name]; ok && newName != name {
if newName, ok := resolveNewName(name); ok {
updatedTool, err := sjson.Set(toolJSON, "name", newName)
if err == nil {
toolJSON = updatedTool
Expand All @@ -1077,7 +1156,7 @@ func remapOAuthToolNames(body []byte) ([]byte, bool) {
// 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 && newName != tcName {
} else if newName, ok := resolveNewName(tcName); ok {
body, _ = sjson.SetBytes(body, "tool_choice.name", newName)
renamed = true
}
Expand All @@ -1096,14 +1175,14 @@ func remapOAuthToolNames(body []byte) ([]byte, bool) {
switch partType {
case "tool_use":
name := part.Get("name").String()
if newName, ok := oauthToolRenameMap[name]; ok && newName != name {
if newName, ok := resolveNewName(name); ok {
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 && newName != toolName {
if newName, ok := resolveNewName(toolName); ok {
path := fmt.Sprintf("messages.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int())
body, _ = sjson.SetBytes(body, path, newName)
renamed = true
Expand All @@ -1117,7 +1196,7 @@ func remapOAuthToolNames(body []byte) ([]byte, bool) {
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 && newName != nestedToolName {
if newName, ok := resolveNewName(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)
renamed = true
Expand All @@ -1133,13 +1212,26 @@ func remapOAuthToolNames(body []byte) ([]byte, bool) {
})
}

return body, renamed
return body, renamed, dynReverse
}

// 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 {
// downstream client receives tool names it recognizes. dynReverse contains per-request
// mappings for dynamically renamed tools (from snakeCaseToTitleCase fallback).
func reverseRemapOAuthToolNames(body []byte, dynReverse map[string]string) []byte {
resolveOrigName := func(name string) (string, bool) {
if origName, ok := oauthToolRenameReverseMap[name]; ok {
return origName, true
}
if dynReverse != nil {
if origName, ok := dynReverse[name]; ok {
return origName, true
}
}
return name, false
}

content := gjson.GetBytes(body, "content")
if !content.Exists() || !content.IsArray() {
return body
Expand All @@ -1149,13 +1241,13 @@ func reverseRemapOAuthToolNames(body []byte) []byte {
switch partType {
case "tool_use":
name := part.Get("name").String()
if origName, ok := oauthToolRenameReverseMap[name]; ok {
if origName, ok := resolveOrigName(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 {
if origName, ok := resolveOrigName(toolName); ok {
path := fmt.Sprintf("content.%d.tool_name", index.Int())
body, _ = sjson.SetBytes(body, path, origName)
}
Expand All @@ -1166,7 +1258,20 @@ func reverseRemapOAuthToolNames(body []byte) []byte {
}

// reverseRemapOAuthToolNamesFromStreamLine reverses the tool name mapping for SSE stream lines.
func reverseRemapOAuthToolNamesFromStreamLine(line []byte) []byte {
// dynReverse contains per-request mappings for dynamically renamed tools.
func reverseRemapOAuthToolNamesFromStreamLine(line []byte, dynReverse map[string]string) []byte {
resolveOrigName := func(name string) (string, bool) {
if origName, ok := oauthToolRenameReverseMap[name]; ok {
return origName, true
}
if dynReverse != nil {
if origName, ok := dynReverse[name]; ok {
return origName, true
}
}
return name, false
}

payload := helps.JSONPayload(line)
if len(payload) == 0 || !gjson.ValidBytes(payload) {
return line
Expand All @@ -1184,7 +1289,7 @@ func reverseRemapOAuthToolNamesFromStreamLine(line []byte) []byte {
switch blockType {
case "tool_use":
name := contentBlock.Get("name").String()
if origName, ok := oauthToolRenameReverseMap[name]; ok {
if origName, ok := resolveOrigName(name); ok {
updated, err = sjson.SetBytes(payload, "content_block.name", origName)
if err != nil {
return line
Expand All @@ -1194,7 +1299,7 @@ func reverseRemapOAuthToolNamesFromStreamLine(line []byte) []byte {
}
case "tool_reference":
toolName := contentBlock.Get("tool_name").String()
if origName, ok := oauthToolRenameReverseMap[toolName]; ok {
if origName, ok := resolveOrigName(toolName); ok {
updated, err = sjson.SetBytes(payload, "content_block.tool_name", origName)
if err != nil {
return line
Expand Down
8 changes: 4 additions & 4 deletions internal/runtime/executor/claude_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1953,7 +1953,7 @@ func TestNormalizeClaudeTemperatureForThinking_AfterForcedToolChoiceKeepsOrigina
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)
out, renamed, dynReverse := remapOAuthToolNames(body)
if renamed {
t.Fatalf("renamed = true, want false")
}
Expand All @@ -1964,7 +1964,7 @@ func TestRemapOAuthToolNames_TitleCase_NoReverseNeeded(t *testing.T) {
resp := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"cmd":"ls"}}]}`)
reversed := resp
if renamed {
reversed = reverseRemapOAuthToolNames(resp)
reversed = reverseRemapOAuthToolNames(resp, dynReverse)
}
if got := gjson.GetBytes(reversed, "content.0.name").String(); got != "Bash" {
t.Fatalf("content.0.name = %q, want %q", got, "Bash")
Expand All @@ -1974,7 +1974,7 @@ func TestRemapOAuthToolNames_TitleCase_NoReverseNeeded(t *testing.T) {
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)
out, renamed, dynReverse := remapOAuthToolNames(body)
if !renamed {
t.Fatalf("renamed = false, want true")
}
Expand All @@ -1985,7 +1985,7 @@ func TestRemapOAuthToolNames_Lowercase_ReverseApplied(t *testing.T) {
resp := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"cmd":"ls"}}]}`)
reversed := resp
if renamed {
reversed = reverseRemapOAuthToolNames(resp)
reversed = reverseRemapOAuthToolNames(resp, dynReverse)
}
if got := gjson.GetBytes(reversed, "content.0.name").String(); got != "bash" {
t.Fatalf("content.0.name = %q, want %q", got, "bash")
Expand Down
6 changes: 3 additions & 3 deletions internal/runtime/executor/helps/claude_device_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import (
)

const (
defaultClaudeFingerprintUserAgent = "claude-cli/2.1.63 (external, cli)"
defaultClaudeFingerprintPackageVersion = "0.74.0"
defaultClaudeFingerprintUserAgent = "claude-cli/2.1.108 (external, sdk-cli)"
defaultClaudeFingerprintPackageVersion = "0.81.0"
defaultClaudeFingerprintRuntimeVersion = "v24.3.0"
defaultClaudeFingerprintOS = "MacOS"
defaultClaudeFingerprintArch = "arm64"
Expand Down Expand Up @@ -365,7 +365,7 @@ func DefaultClaudeVersion(cfg *config.Config) string {
if version, ok := parseClaudeCLIVersion(profile.UserAgent); ok {
return strconv.Itoa(version.major) + "." + strconv.Itoa(version.minor) + "." + strconv.Itoa(version.patch)
}
return "2.1.63"
return "2.1.108"
}

func ApplyClaudeLegacyDeviceHeaders(r *http.Request, ginHeaders http.Header, cfg *config.Config) {
Expand Down
Loading