diff --git a/config.example.yaml b/config.example.yaml index 3718a07a1e..f5871356cb 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -172,7 +172,8 @@ nonstream-keepalive-interval: 0 # Default headers for Claude API requests. Update when Claude Code releases new versions. # These are used as fallbacks when the client does not send its own headers. # claude-header-defaults: -# user-agent: "claude-cli/2.1.44 (external, sdk-cli)" +# version: "2.1.63" +# user-agent: "claude-cli/2.1.63 (external, cli)" # package-version: "0.74.0" # runtime-version: "v24.3.0" # timeout: "600" diff --git a/internal/config/config.go b/internal/config/config.go index a11c741efc..a8030ed134 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -131,6 +131,7 @@ type Config struct { // ClaudeHeaderDefaults configures default header values injected into Claude API requests // when the client does not send them. Update these when Claude Code releases a new version. type ClaudeHeaderDefaults struct { + Version string `yaml:"version" json:"version"` UserAgent string `yaml:"user-agent" json:"user-agent"` PackageVersion string `yaml:"package-version" json:"package-version"` RuntimeVersion string `yaml:"runtime-version" json:"runtime-version"` diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 82b12a2f80..6a43611855 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -44,6 +44,7 @@ type ClaudeExecutor struct { // claudeToolPrefix is empty to match real Claude Code behavior (no tool name prefix). // Previously "proxy_" was used but this is a detectable fingerprint difference. const claudeToolPrefix = "" +const defaultClaudeCodeVersion = "2.1.63" func NewClaudeExecutor(cfg *config.Config) *ClaudeExecutor { return &ClaudeExecutor{cfg: cfg} } @@ -471,7 +472,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut body, _ = sjson.SetBytes(body, "model", baseModel) if !strings.HasPrefix(baseModel, "claude-3-5-haiku") { - body = checkSystemInstructions(body) + body = checkSystemInstructionsWithMode(body, false, e.cfg) } // Keep count_tokens requests compatible with Anthropic cache-control constraints too. @@ -865,7 +866,7 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Version", "2023-06-01") misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Dangerous-Direct-Browser-Access", "true") misc.EnsureHeader(r.Header, ginHeaders, "X-App", "cli") - // Values below match Claude Code 2.1.63 / @anthropic-ai/sdk 0.74.0 (updated 2026-02-28). + // Fallback values below match Claude Code 2.1.63 / @anthropic-ai/sdk 0.74.0 (updated 2026-02-28). misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Retry-Count", "0") misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Runtime-Version", hdrDefault(hd.RuntimeVersion, "v24.3.0")) misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Package-Version", hdrDefault(hd.PackageVersion, "0.74.0")) @@ -884,7 +885,7 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, if isClaudeCodeClient(clientUA) { r.Header.Set("User-Agent", clientUA) } else { - r.Header.Set("User-Agent", hdrDefault(hd.UserAgent, "claude-cli/2.1.63 (external, cli)")) + r.Header.Set("User-Agent", hdrDefault(hd.UserAgent, defaultClaudeUserAgent(hd))) } r.Header.Set("Connection", "keep-alive") if stream { @@ -928,10 +929,6 @@ func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { return } -func checkSystemInstructions(payload []byte) []byte { - return checkSystemInstructionsWithMode(payload, false) -} - func isClaudeOAuthToken(apiKey string) bool { return strings.Contains(apiKey, "sk-ant-oat") } @@ -1203,10 +1200,33 @@ func injectFakeUserID(payload []byte, apiKey string, useCache bool) []byte { return payload } +func claudeCodeVersion(hd config.ClaudeHeaderDefaults) string { + if version := strings.TrimSpace(hd.Version); version != "" { + return version + } + return defaultClaudeCodeVersion +} + +func defaultClaudeUserAgent(hd config.ClaudeHeaderDefaults) string { + return fmt.Sprintf("claude-cli/%s (external, cli)", claudeCodeVersion(hd)) +} + +func buildClaudeTextSystemBlock(text string) string { + block, err := json.Marshal(map[string]string{ + "type": "text", + "text": text, + }) + if err != nil { + quoted, _ := json.Marshal(text) + return fmt.Sprintf(`{"type":"text","text":%s}`, quoted) + } + return string(block) +} + // generateBillingHeader creates the x-anthropic-billing-header text block that // real Claude Code prepends to every system prompt array. // Format: x-anthropic-billing-header: cc_version=.; cc_entrypoint=cli; cch=; -func generateBillingHeader(payload []byte) string { +func generateBillingHeader(payload []byte, hd config.ClaudeHeaderDefaults) string { // Generate a deterministic cch hash from the payload content (system + messages + tools). // Real Claude Code uses a 5-char hex hash that varies per request. h := sha256.Sum256(payload) @@ -1217,7 +1237,7 @@ func generateBillingHeader(payload []byte) string { _, _ = rand.Read(buildBytes) buildHash := hex.EncodeToString(buildBytes)[:3] - return fmt.Sprintf("x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=%s;", buildHash, cch) + return fmt.Sprintf("x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=cli; cch=%s;", claudeCodeVersion(hd), buildHash, cch) } // checkSystemInstructionsWithMode injects Claude Code-style system blocks: @@ -1225,17 +1245,21 @@ func generateBillingHeader(payload []byte) string { // system[0]: billing header (no cache_control) // system[1]: agent identifier (no cache_control) // system[2..]: user system messages (cache_control added when missing) -func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte { +func checkSystemInstructionsWithMode(payload []byte, strictMode bool, cfg *config.Config) []byte { system := gjson.GetBytes(payload, "system") - billingText := generateBillingHeader(payload) - billingBlock := fmt.Sprintf(`{"type":"text","text":"%s"}`, billingText) + var hd config.ClaudeHeaderDefaults + if cfg != nil { + hd = cfg.ClaudeHeaderDefaults + } + billingText := generateBillingHeader(payload, hd) + billingBlock := buildClaudeTextSystemBlock(billingText) // No cache_control on the agent block. It is a cloaking artifact with zero cache // value (the last system block is what actually triggers caching of all system content). // Including any cache_control here creates an intra-system TTL ordering violation // when the client's system blocks use ttl='1h' (prompt-caching-scope-2026-01-05 beta // forbids 1h blocks after 5m blocks, and a no-TTL block defaults to 5m). - agentBlock := `{"type":"text","text":"You are a Claude agent, built on Anthropic's Claude Agent SDK."}` + agentBlock := buildClaudeTextSystemBlock("You are a Claude agent, built on Anthropic's Claude Agent SDK.") if strictMode { // Strict mode: billing header + agent identifier only @@ -1325,7 +1349,7 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A // Skip system instructions for claude-3-5-haiku models if !strings.HasPrefix(model, "claude-3-5-haiku") { - payload = checkSystemInstructionsWithMode(payload, strictMode) + payload = checkSystemInstructionsWithMode(payload, strictMode, cfg) } // Inject fake user ID diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index fa458c0fd0..789d092d18 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -485,6 +485,85 @@ func TestClaudeExecutor_CountTokens_AppliesCacheControlGuards(t *testing.T) { } } +func TestClaudeExecutor_CountTokens_UsesConfiguredClaudeVersion(t *testing.T) { + var seenBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + seenBody = bytes.Clone(body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"input_tokens":42}`)) + })) + defer server.Close() + + executor := NewClaudeExecutor(&config.Config{ + ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{ + Version: "2.2.0", + }, + }) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "key-123", + "base_url": server.URL, + }} + payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`) + + _, err := executor.CountTokens(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-3-5-sonnet-20241022", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}) + if err != nil { + t.Fatalf("CountTokens error: %v", err) + } + + if len(seenBody) == 0 { + t.Fatal("expected count_tokens request body to be captured") + } + if got := gjson.GetBytes(seenBody, "system.0.text").String(); !strings.Contains(got, "cc_version=2.2.0.") { + t.Fatalf("count_tokens billing header should use configured Claude version, got %q", got) + } +} + +func TestClaudeExecutor_CountTokens_EscapesConfiguredClaudeVersionInBillingHeader(t *testing.T) { + var seenBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + seenBody = bytes.Clone(body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"input_tokens":42}`)) + })) + defer server.Close() + + configuredVersion := `2.2.0\"beta\\build` + executor := NewClaudeExecutor(&config.Config{ + ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{ + Version: configuredVersion, + }, + }) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "key-123", + "base_url": server.URL, + }} + payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`) + + _, err := executor.CountTokens(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-3-5-sonnet-20241022", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}) + if err != nil { + t.Fatalf("CountTokens error: %v", err) + } + + if len(seenBody) == 0 { + t.Fatal("expected count_tokens request body to be captured") + } + if !gjson.ValidBytes(seenBody) { + t.Fatalf("count_tokens request body should remain valid JSON: %s", string(seenBody)) + } + got := gjson.GetBytes(seenBody, "system.0.text").String() + if !strings.Contains(got, "cc_version="+configuredVersion+".") { + t.Fatalf("count_tokens billing header should preserve configured Claude version, got %q", got) + } +} + func hasTTLOrderingViolation(payload []byte) bool { seen5m := false violates := false @@ -985,7 +1064,7 @@ func TestClaudeExecutor_ExecuteStream_GzipErrorBodyNoContentEncodingHeader(t *te func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) { payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`) - out := checkSystemInstructionsWithMode(payload, false) + out := checkSystemInstructionsWithMode(payload, false, nil) system := gjson.GetBytes(out, "system") if !system.IsArray() { @@ -1000,6 +1079,9 @@ func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) { if !strings.HasPrefix(blocks[0].Get("text").String(), "x-anthropic-billing-header:") { t.Fatalf("blocks[0] should be billing header, got %q", blocks[0].Get("text").String()) } + if !strings.Contains(blocks[0].Get("text").String(), "cc_version=2.1.63.") { + t.Fatalf("blocks[0] should use default Claude version, got %q", blocks[0].Get("text").String()) + } if blocks[1].Get("text").String() != "You are a Claude agent, built on Anthropic's Claude Agent SDK." { t.Fatalf("blocks[1] should be agent block, got %q", blocks[1].Get("text").String()) } @@ -1015,7 +1097,7 @@ func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) { func TestCheckSystemInstructionsWithMode_StringSystemStrict(t *testing.T) { payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`) - out := checkSystemInstructionsWithMode(payload, true) + out := checkSystemInstructionsWithMode(payload, true, nil) blocks := gjson.GetBytes(out, "system").Array() if len(blocks) != 2 { @@ -1027,7 +1109,7 @@ func TestCheckSystemInstructionsWithMode_StringSystemStrict(t *testing.T) { func TestCheckSystemInstructionsWithMode_EmptyStringSystemIgnored(t *testing.T) { payload := []byte(`{"system":"","messages":[{"role":"user","content":"hi"}]}`) - out := checkSystemInstructionsWithMode(payload, false) + out := checkSystemInstructionsWithMode(payload, false, nil) blocks := gjson.GetBytes(out, "system").Array() if len(blocks) != 2 { @@ -1039,7 +1121,7 @@ func TestCheckSystemInstructionsWithMode_EmptyStringSystemIgnored(t *testing.T) func TestCheckSystemInstructionsWithMode_ArraySystemStillWorks(t *testing.T) { payload := []byte(`{"system":[{"type":"text","text":"Be concise."}],"messages":[{"role":"user","content":"hi"}]}`) - out := checkSystemInstructionsWithMode(payload, false) + out := checkSystemInstructionsWithMode(payload, false, nil) blocks := gjson.GetBytes(out, "system").Array() if len(blocks) != 3 { @@ -1054,7 +1136,7 @@ func TestCheckSystemInstructionsWithMode_ArraySystemStillWorks(t *testing.T) { func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) { payload := []byte(`{"system":"Use tags & \"quotes\" in output.","messages":[{"role":"user","content":"hi"}]}`) - out := checkSystemInstructionsWithMode(payload, false) + out := checkSystemInstructionsWithMode(payload, false, nil) blocks := gjson.GetBytes(out, "system").Array() if len(blocks) != 3 { @@ -1064,3 +1146,80 @@ func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) { t.Fatalf("blocks[2] text mangled, got %q", blocks[2].Get("text").String()) } } + +func TestCheckSystemInstructionsWithMode_UsesConfiguredClaudeVersion(t *testing.T) { + payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`) + cfg := &config.Config{ + ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{ + Version: "2.2.0", + }, + } + + out := checkSystemInstructionsWithMode(payload, false, cfg) + + billingHeader := gjson.GetBytes(out, "system.0.text").String() + if !strings.Contains(billingHeader, "cc_version=2.2.0.") { + t.Fatalf("billing header should use configured Claude version, got %q", billingHeader) + } +} + +func TestCheckSystemInstructionsWithMode_EscapesConfiguredClaudeVersion(t *testing.T) { + configuredVersion := `2.2.0\"beta\\build` + payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`) + cfg := &config.Config{ + ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{ + Version: configuredVersion, + }, + } + + out := checkSystemInstructionsWithMode(payload, false, cfg) + + if !gjson.ValidBytes(out) { + t.Fatalf("payload should remain valid JSON: %s", string(out)) + } + system := gjson.GetBytes(out, "system").Array() + if len(system) != 3 { + t.Fatalf("expected 3 system blocks, got %d", len(system)) + } + billingHeader := system[0].Get("text").String() + if !strings.Contains(billingHeader, "cc_version="+configuredVersion+".") { + t.Fatalf("billing header should preserve configured Claude version, got %q", billingHeader) + } + if got := system[1].Get("text").String(); got != "You are a Claude agent, built on Anthropic's Claude Agent SDK." { + t.Fatalf("system[1] should remain the agent block, got %q", got) + } + if got := system[2].Get("text").String(); got != "You are a helpful assistant." { + t.Fatalf("system[2] should remain the user system prompt, got %q", got) + } +} + +func TestApplyClaudeHeaders_UsesConfiguredClaudeVersionForFallbackUserAgent(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "https://example.com/v1/messages", nil) + cfg := &config.Config{ + ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{ + Version: "2.2.0", + }, + } + + applyClaudeHeaders(req, nil, "key-123", false, nil, cfg) + + if got := req.Header.Get("User-Agent"); got != "claude-cli/2.2.0 (external, cli)" { + t.Fatalf("User-Agent = %q, want %q", got, "claude-cli/2.2.0 (external, cli)") + } +} + +func TestApplyClaudeHeaders_UsesConfiguredUserAgent(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "https://example.com/v1/messages", nil) + cfg := &config.Config{ + ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{ + Version: "2.3.4", + UserAgent: "claude-cli/custom-build (external, cli)", + }, + } + + applyClaudeHeaders(req, nil, "key-123", false, nil, cfg) + + if got := req.Header.Get("User-Agent"); got != "claude-cli/custom-build (external, cli)" { + t.Fatalf("User-Agent = %q, want %q", got, "claude-cli/custom-build (external, cli)") + } +}