diff --git a/config.example.yaml b/config.example.yaml index 3718a07a..637e85a4 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -175,6 +175,8 @@ nonstream-keepalive-interval: 0 # user-agent: "claude-cli/2.1.44 (external, sdk-cli)" # package-version: "0.74.0" # runtime-version: "v24.3.0" +# os: "MacOS" +# arch: "arm64" # timeout: "600" # Default headers for Codex OAuth model requests. diff --git a/internal/config/claude_header_defaults_test.go b/internal/config/claude_header_defaults_test.go new file mode 100644 index 00000000..8f332595 --- /dev/null +++ b/internal/config/claude_header_defaults_test.go @@ -0,0 +1,48 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfigOptional_ClaudeHeaderDefaults(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + configYAML := []byte(` +claude-header-defaults: + user-agent: " claude-cli/2.1.70 (external, cli) " + package-version: " 0.80.0 " + runtime-version: " v24.5.0 " + os: " MacOS " + arch: " arm64 " + timeout: " 900 " +`) + if err := os.WriteFile(configPath, configYAML, 0o600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := LoadConfigOptional(configPath, false) + if err != nil { + t.Fatalf("LoadConfigOptional() error = %v", err) + } + + if got := cfg.ClaudeHeaderDefaults.UserAgent; got != "claude-cli/2.1.70 (external, cli)" { + t.Fatalf("UserAgent = %q, want %q", got, "claude-cli/2.1.70 (external, cli)") + } + if got := cfg.ClaudeHeaderDefaults.PackageVersion; got != "0.80.0" { + t.Fatalf("PackageVersion = %q, want %q", got, "0.80.0") + } + if got := cfg.ClaudeHeaderDefaults.RuntimeVersion; got != "v24.5.0" { + t.Fatalf("RuntimeVersion = %q, want %q", got, "v24.5.0") + } + if got := cfg.ClaudeHeaderDefaults.OS; got != "MacOS" { + t.Fatalf("OS = %q, want %q", got, "MacOS") + } + if got := cfg.ClaudeHeaderDefaults.Arch; got != "arm64" { + t.Fatalf("Arch = %q, want %q", got, "arm64") + } + if got := cfg.ClaudeHeaderDefaults.Timeout; got != "900" { + t.Fatalf("Timeout = %q, want %q", got, "900") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index a11c741e..3cafd14e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -134,6 +134,8 @@ type ClaudeHeaderDefaults struct { UserAgent string `yaml:"user-agent" json:"user-agent"` PackageVersion string `yaml:"package-version" json:"package-version"` RuntimeVersion string `yaml:"runtime-version" json:"runtime-version"` + OS string `yaml:"os" json:"os"` + Arch string `yaml:"arch" json:"arch"` Timeout string `yaml:"timeout" json:"timeout"` } @@ -630,6 +632,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { // Sanitize Codex header defaults. cfg.SanitizeCodexHeaderDefaults() + // Sanitize Claude header defaults. + cfg.SanitizeClaudeHeaderDefaults() + // Sanitize Claude key headers cfg.SanitizeClaudeKeys() @@ -729,6 +734,20 @@ func (cfg *Config) SanitizeCodexHeaderDefaults() { cfg.CodexHeaderDefaults.BetaFeatures = strings.TrimSpace(cfg.CodexHeaderDefaults.BetaFeatures) } +// SanitizeClaudeHeaderDefaults trims surrounding whitespace from the +// configured Claude fingerprint baseline values. +func (cfg *Config) SanitizeClaudeHeaderDefaults() { + if cfg == nil { + return + } + cfg.ClaudeHeaderDefaults.UserAgent = strings.TrimSpace(cfg.ClaudeHeaderDefaults.UserAgent) + cfg.ClaudeHeaderDefaults.PackageVersion = strings.TrimSpace(cfg.ClaudeHeaderDefaults.PackageVersion) + cfg.ClaudeHeaderDefaults.RuntimeVersion = strings.TrimSpace(cfg.ClaudeHeaderDefaults.RuntimeVersion) + cfg.ClaudeHeaderDefaults.OS = strings.TrimSpace(cfg.ClaudeHeaderDefaults.OS) + cfg.ClaudeHeaderDefaults.Arch = strings.TrimSpace(cfg.ClaudeHeaderDefaults.Arch) + cfg.ClaudeHeaderDefaults.Timeout = strings.TrimSpace(cfg.ClaudeHeaderDefaults.Timeout) +} + // SanitizeOAuthModelAlias normalizes and deduplicates global OAuth model name aliases. // It trims whitespace, normalizes channel keys to lower-case, drops empty entries, // allows multiple aliases per upstream name, and ensures aliases are unique within each channel. diff --git a/internal/runtime/executor/claude_device_profile.go b/internal/runtime/executor/claude_device_profile.go new file mode 100644 index 00000000..e662f530 --- /dev/null +++ b/internal/runtime/executor/claude_device_profile.go @@ -0,0 +1,250 @@ +package executor + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +const ( + defaultClaudeFingerprintUserAgent = "claude-cli/2.1.63 (external, cli)" + defaultClaudeFingerprintPackageVersion = "0.74.0" + defaultClaudeFingerprintRuntimeVersion = "v24.3.0" + defaultClaudeFingerprintOS = "MacOS" + defaultClaudeFingerprintArch = "arm64" + claudeDeviceProfileTTL = 7 * 24 * time.Hour + claudeDeviceProfileCleanupPeriod = time.Hour +) + +var ( + claudeCLIVersionPattern = regexp.MustCompile(`^claude-cli/(\d+)\.(\d+)\.(\d+)`) + + claudeDeviceProfileCache = make(map[string]claudeDeviceProfileCacheEntry) + claudeDeviceProfileCacheMu sync.RWMutex + claudeDeviceProfileCacheCleanupOnce sync.Once +) + +type claudeCLIVersion struct { + major int + minor int + patch int +} + +func (v claudeCLIVersion) Compare(other claudeCLIVersion) int { + switch { + case v.major != other.major: + if v.major > other.major { + return 1 + } + return -1 + case v.minor != other.minor: + if v.minor > other.minor { + return 1 + } + return -1 + case v.patch != other.patch: + if v.patch > other.patch { + return 1 + } + return -1 + default: + return 0 + } +} + +type claudeDeviceProfile struct { + UserAgent string + PackageVersion string + RuntimeVersion string + OS string + Arch string + Version claudeCLIVersion +} + +type claudeDeviceProfileCacheEntry struct { + profile claudeDeviceProfile + expire time.Time +} + +func defaultClaudeDeviceProfile(cfg *config.Config) claudeDeviceProfile { + hdrDefault := func(cfgVal, fallback string) string { + if strings.TrimSpace(cfgVal) != "" { + return strings.TrimSpace(cfgVal) + } + return fallback + } + + var hd config.ClaudeHeaderDefaults + if cfg != nil { + hd = cfg.ClaudeHeaderDefaults + } + + profile := claudeDeviceProfile{ + UserAgent: hdrDefault(hd.UserAgent, defaultClaudeFingerprintUserAgent), + PackageVersion: hdrDefault(hd.PackageVersion, defaultClaudeFingerprintPackageVersion), + RuntimeVersion: hdrDefault(hd.RuntimeVersion, defaultClaudeFingerprintRuntimeVersion), + OS: hdrDefault(hd.OS, defaultClaudeFingerprintOS), + Arch: hdrDefault(hd.Arch, defaultClaudeFingerprintArch), + } + if version, ok := parseClaudeCLIVersion(profile.UserAgent); ok { + profile.Version = version + } + return profile +} + +func parseClaudeCLIVersion(userAgent string) (claudeCLIVersion, bool) { + matches := claudeCLIVersionPattern.FindStringSubmatch(strings.TrimSpace(userAgent)) + if len(matches) != 4 { + return claudeCLIVersion{}, false + } + major, err := strconv.Atoi(matches[1]) + if err != nil { + return claudeCLIVersion{}, false + } + minor, err := strconv.Atoi(matches[2]) + if err != nil { + return claudeCLIVersion{}, false + } + patch, err := strconv.Atoi(matches[3]) + if err != nil { + return claudeCLIVersion{}, false + } + return claudeCLIVersion{major: major, minor: minor, patch: patch}, true +} + +func extractClaudeDeviceProfile(headers http.Header, cfg *config.Config) (claudeDeviceProfile, bool) { + if headers == nil { + return claudeDeviceProfile{}, false + } + + userAgent := strings.TrimSpace(headers.Get("User-Agent")) + version, ok := parseClaudeCLIVersion(userAgent) + if !ok { + return claudeDeviceProfile{}, false + } + + baseline := defaultClaudeDeviceProfile(cfg) + profile := claudeDeviceProfile{ + UserAgent: userAgent, + PackageVersion: firstNonEmptyHeader(headers, "X-Stainless-Package-Version", baseline.PackageVersion), + RuntimeVersion: firstNonEmptyHeader(headers, "X-Stainless-Runtime-Version", baseline.RuntimeVersion), + OS: firstNonEmptyHeader(headers, "X-Stainless-Os", baseline.OS), + Arch: firstNonEmptyHeader(headers, "X-Stainless-Arch", baseline.Arch), + Version: version, + } + return profile, true +} + +func firstNonEmptyHeader(headers http.Header, name, fallback string) string { + if headers == nil { + return fallback + } + if value := strings.TrimSpace(headers.Get(name)); value != "" { + return value + } + return fallback +} + +func claudeDeviceProfileScopeKey(auth *cliproxyauth.Auth, apiKey string) string { + switch { + case auth != nil && strings.TrimSpace(auth.ID) != "": + return "auth:" + strings.TrimSpace(auth.ID) + case strings.TrimSpace(apiKey) != "": + return "api_key:" + strings.TrimSpace(apiKey) + default: + return "global" + } +} + +func claudeDeviceProfileCacheKey(auth *cliproxyauth.Auth, apiKey string) string { + sum := sha256.Sum256([]byte(claudeDeviceProfileScopeKey(auth, apiKey))) + return hex.EncodeToString(sum[:]) +} + +func startClaudeDeviceProfileCacheCleanup() { + go func() { + ticker := time.NewTicker(claudeDeviceProfileCleanupPeriod) + defer ticker.Stop() + for range ticker.C { + purgeExpiredClaudeDeviceProfiles() + } + }() +} + +func purgeExpiredClaudeDeviceProfiles() { + now := time.Now() + claudeDeviceProfileCacheMu.Lock() + for key, entry := range claudeDeviceProfileCache { + if !entry.expire.After(now) { + delete(claudeDeviceProfileCache, key) + } + } + claudeDeviceProfileCacheMu.Unlock() +} + +func resolveClaudeDeviceProfile(auth *cliproxyauth.Auth, apiKey string, headers http.Header, cfg *config.Config) claudeDeviceProfile { + claudeDeviceProfileCacheCleanupOnce.Do(startClaudeDeviceProfileCacheCleanup) + + cacheKey := claudeDeviceProfileCacheKey(auth, apiKey) + now := time.Now() + baseline := defaultClaudeDeviceProfile(cfg) + candidate, hasCandidate := extractClaudeDeviceProfile(headers, cfg) + + claudeDeviceProfileCacheMu.RLock() + entry, hasCached := claudeDeviceProfileCache[cacheKey] + cachedValid := hasCached && entry.expire.After(now) && entry.profile.UserAgent != "" + claudeDeviceProfileCacheMu.RUnlock() + + if hasCandidate && (!cachedValid || candidate.Version.Compare(entry.profile.Version) > 0) { + newEntry := claudeDeviceProfileCacheEntry{ + profile: candidate, + expire: now.Add(claudeDeviceProfileTTL), + } + claudeDeviceProfileCacheMu.Lock() + claudeDeviceProfileCache[cacheKey] = newEntry + claudeDeviceProfileCacheMu.Unlock() + return candidate + } + + if cachedValid { + claudeDeviceProfileCacheMu.Lock() + entry = claudeDeviceProfileCache[cacheKey] + if entry.expire.After(now) && entry.profile.UserAgent != "" { + entry.expire = now.Add(claudeDeviceProfileTTL) + claudeDeviceProfileCache[cacheKey] = entry + claudeDeviceProfileCacheMu.Unlock() + return entry.profile + } + claudeDeviceProfileCacheMu.Unlock() + } + + return baseline +} + +func applyClaudeDeviceProfileHeaders(r *http.Request, profile claudeDeviceProfile) { + if r == nil { + return + } + for _, headerName := range []string{ + "User-Agent", + "X-Stainless-Package-Version", + "X-Stainless-Runtime-Version", + "X-Stainless-Os", + "X-Stainless-Arch", + } { + r.Header.Del(headerName) + } + r.Header.Set("User-Agent", profile.UserAgent) + r.Header.Set("X-Stainless-Package-Version", profile.PackageVersion) + r.Header.Set("X-Stainless-Runtime-Version", profile.RuntimeVersion) + r.Header.Set("X-Stainless-Os", profile.OS) + r.Header.Set("X-Stainless-Arch", profile.Arch) +} diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 82b12a2f..65bd3e24 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -14,7 +14,6 @@ import ( "io" "net/http" "net/textproto" - "runtime" "strings" "time" @@ -767,36 +766,6 @@ func decodeResponseBody(body io.ReadCloser, contentEncoding string) (io.ReadClos return body, nil } -// mapStainlessOS maps runtime.GOOS to Stainless SDK OS names. -func mapStainlessOS() string { - switch runtime.GOOS { - case "darwin": - return "MacOS" - case "windows": - return "Windows" - case "linux": - return "Linux" - case "freebsd": - return "FreeBSD" - default: - return "Other::" + runtime.GOOS - } -} - -// mapStainlessArch maps runtime.GOARCH to Stainless SDK architecture names. -func mapStainlessArch() string { - switch runtime.GOARCH { - case "amd64": - return "x64" - case "arm64": - return "arm64" - case "386": - return "x86" - default: - return "other::" + runtime.GOARCH - } -} - func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, stream bool, extraBetas []string, cfg *config.Config) { hdrDefault := func(cfgVal, fallback string) string { if cfgVal != "" { @@ -824,6 +793,7 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { ginHeaders = ginCtx.Request.Header } + deviceProfile := resolveClaudeDeviceProfile(auth, apiKey, ginHeaders, cfg) baseBetas := "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05" if val := strings.TrimSpace(ginHeaders.Get("Anthropic-Beta")); val != "" { @@ -867,25 +837,9 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, 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). 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")) misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Runtime", "node") misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Lang", "js") - misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Arch", mapStainlessArch()) - misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Os", mapStainlessOS()) misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Timeout", hdrDefault(hd.Timeout, "600")) - // For User-Agent, only forward the client's header if it's already a Claude Code client. - // Non-Claude-Code clients (e.g. curl, OpenAI SDKs) get the default Claude Code User-Agent - // to avoid leaking the real client identity during cloaking. - clientUA := "" - if ginHeaders != nil { - clientUA = ginHeaders.Get("User-Agent") - } - 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("Connection", "keep-alive") if stream { r.Header.Set("Accept", "text/event-stream") @@ -904,6 +858,7 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, attrs = auth.Attributes } util.ApplyCustomHeadersFromAttrs(r, attrs) + applyClaudeDeviceProfileHeaders(r, deviceProfile) // Re-enforce Accept-Encoding: identity after ApplyCustomHeadersFromAttrs, which // may override it with a user-configured value. Compressed SSE breaks the line // scanner regardless of user preference, so this is non-negotiable for streams. diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index fa458c0f..08c1d5e2 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/gin-gonic/gin" "github.com/klauspost/compress/zstd" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" @@ -19,6 +20,146 @@ import ( "github.com/tidwall/sjson" ) +func resetClaudeDeviceProfileCache() { + claudeDeviceProfileCacheMu.Lock() + claudeDeviceProfileCache = make(map[string]claudeDeviceProfileCacheEntry) + claudeDeviceProfileCacheMu.Unlock() +} + +func newClaudeHeaderTestRequest(t *testing.T, incoming http.Header) *http.Request { + t.Helper() + + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginReq := httptest.NewRequest(http.MethodPost, "http://localhost/v1/messages", nil) + ginReq.Header = incoming.Clone() + ginCtx.Request = ginReq + + req := httptest.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", nil) + return req.WithContext(context.WithValue(req.Context(), "gin", ginCtx)) +} + +func assertClaudeFingerprint(t *testing.T, headers http.Header, userAgent, pkgVersion, runtimeVersion, osName, arch string) { + t.Helper() + + if got := headers.Get("User-Agent"); got != userAgent { + t.Fatalf("User-Agent = %q, want %q", got, userAgent) + } + if got := headers.Get("X-Stainless-Package-Version"); got != pkgVersion { + t.Fatalf("X-Stainless-Package-Version = %q, want %q", got, pkgVersion) + } + if got := headers.Get("X-Stainless-Runtime-Version"); got != runtimeVersion { + t.Fatalf("X-Stainless-Runtime-Version = %q, want %q", got, runtimeVersion) + } + if got := headers.Get("X-Stainless-Os"); got != osName { + t.Fatalf("X-Stainless-Os = %q, want %q", got, osName) + } + if got := headers.Get("X-Stainless-Arch"); got != arch { + t.Fatalf("X-Stainless-Arch = %q, want %q", got, arch) + } +} + +func TestApplyClaudeHeaders_UsesConfiguredBaselineFingerprint(t *testing.T) { + resetClaudeDeviceProfileCache() + + cfg := &config.Config{ + ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{ + UserAgent: "claude-cli/2.1.70 (external, cli)", + PackageVersion: "0.80.0", + RuntimeVersion: "v24.5.0", + OS: "MacOS", + Arch: "arm64", + Timeout: "900", + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-baseline", + Attributes: map[string]string{ + "api_key": "key-baseline", + "header:User-Agent": "evil-client/9.9", + "header:X-Stainless-Os": "Linux", + "header:X-Stainless-Arch": "x64", + "header:X-Stainless-Package-Version": "9.9.9", + }, + } + incoming := http.Header{ + "User-Agent": []string{"curl/8.7.1"}, + "X-Stainless-Package-Version": []string{"0.10.0"}, + "X-Stainless-Runtime-Version": []string{"v18.0.0"}, + "X-Stainless-Os": []string{"Linux"}, + "X-Stainless-Arch": []string{"x64"}, + } + + req := newClaudeHeaderTestRequest(t, incoming) + applyClaudeHeaders(req, auth, "key-baseline", false, nil, cfg) + + assertClaudeFingerprint(t, req.Header, "claude-cli/2.1.70 (external, cli)", "0.80.0", "v24.5.0", "MacOS", "arm64") + if got := req.Header.Get("X-Stainless-Timeout"); got != "900" { + t.Fatalf("X-Stainless-Timeout = %q, want %q", got, "900") + } +} + +func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) { + resetClaudeDeviceProfileCache() + + cfg := &config.Config{ + ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{ + UserAgent: "claude-cli/2.1.60 (external, cli)", + PackageVersion: "0.70.0", + RuntimeVersion: "v22.0.0", + OS: "MacOS", + Arch: "arm64", + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-upgrade", + Attributes: map[string]string{ + "api_key": "key-upgrade", + }, + } + + firstReq := newClaudeHeaderTestRequest(t, http.Header{ + "User-Agent": []string{"claude-cli/2.1.62 (external, cli)"}, + "X-Stainless-Package-Version": []string{"0.74.0"}, + "X-Stainless-Runtime-Version": []string{"v24.3.0"}, + "X-Stainless-Os": []string{"Linux"}, + "X-Stainless-Arch": []string{"x64"}, + }) + applyClaudeHeaders(firstReq, auth, "key-upgrade", false, nil, cfg) + assertClaudeFingerprint(t, firstReq.Header, "claude-cli/2.1.62 (external, cli)", "0.74.0", "v24.3.0", "Linux", "x64") + + thirdPartyReq := newClaudeHeaderTestRequest(t, http.Header{ + "User-Agent": []string{"lobe-chat/1.0"}, + "X-Stainless-Package-Version": []string{"0.10.0"}, + "X-Stainless-Runtime-Version": []string{"v18.0.0"}, + "X-Stainless-Os": []string{"Windows"}, + "X-Stainless-Arch": []string{"x64"}, + }) + applyClaudeHeaders(thirdPartyReq, auth, "key-upgrade", false, nil, cfg) + assertClaudeFingerprint(t, thirdPartyReq.Header, "claude-cli/2.1.62 (external, cli)", "0.74.0", "v24.3.0", "Linux", "x64") + + higherReq := newClaudeHeaderTestRequest(t, http.Header{ + "User-Agent": []string{"claude-cli/2.1.63 (external, cli)"}, + "X-Stainless-Package-Version": []string{"0.75.0"}, + "X-Stainless-Runtime-Version": []string{"v24.4.0"}, + "X-Stainless-Os": []string{"MacOS"}, + "X-Stainless-Arch": []string{"arm64"}, + }) + applyClaudeHeaders(higherReq, auth, "key-upgrade", false, nil, cfg) + assertClaudeFingerprint(t, higherReq.Header, "claude-cli/2.1.63 (external, cli)", "0.75.0", "v24.4.0", "MacOS", "arm64") + + lowerReq := newClaudeHeaderTestRequest(t, http.Header{ + "User-Agent": []string{"claude-cli/2.1.61 (external, cli)"}, + "X-Stainless-Package-Version": []string{"0.73.0"}, + "X-Stainless-Runtime-Version": []string{"v24.2.0"}, + "X-Stainless-Os": []string{"Windows"}, + "X-Stainless-Arch": []string{"x64"}, + }) + applyClaudeHeaders(lowerReq, auth, "key-upgrade", false, nil, cfg) + assertClaudeFingerprint(t, lowerReq.Header, "claude-cli/2.1.63 (external, cli)", "0.75.0", "v24.4.0", "MacOS", "arm64") +} + func TestApplyClaudeToolPrefix(t *testing.T) { input := []byte(`{"tools":[{"name":"alpha"},{"name":"proxy_bravo"}],"tool_choice":{"type":"tool","name":"charlie"},"messages":[{"role":"assistant","content":[{"type":"tool_use","name":"delta","id":"t1","input":{}}]}]}`) out := applyClaudeToolPrefix(input, "proxy_")