diff --git a/config.example.yaml b/config.example.yaml index 3718a07a1e..c393bb7aa7 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -170,12 +170,19 @@ nonstream-keepalive-interval: 0 # cache-user-id: true # optional: default is false; set true to reuse cached user_id per API key instead of generating a random one each request # 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. +# In legacy mode, user-agent/package-version/runtime-version/timeout are used as fallbacks +# when the client omits them, while OS/arch remain runtime-derived. When +# stabilize-device-profile is enabled, OS/arch stay pinned to the baseline values below, +# while user-agent/package-version/runtime-version seed a software fingerprint that can +# still upgrade to newer official Claude client versions. # claude-header-defaults: # 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" +# stabilize-device-profile: false # optional, default false; set true to enable per-auth/API-key fingerprint pinning # Default headers for Codex OAuth model requests. # These are used only for file-backed/OAuth Codex requests when the client diff --git a/internal/config/claude_header_defaults_test.go b/internal/config/claude_header_defaults_test.go new file mode 100644 index 0000000000..676f449a06 --- /dev/null +++ b/internal/config/claude_header_defaults_test.go @@ -0,0 +1,55 @@ +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 " + stabilize-device-profile: false +`) + 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") + } + if cfg.ClaudeHeaderDefaults.StabilizeDeviceProfile == nil { + t.Fatal("StabilizeDeviceProfile = nil, want non-nil") + } + if got := *cfg.ClaudeHeaderDefaults.StabilizeDeviceProfile; got { + t.Fatalf("StabilizeDeviceProfile = %v, want false", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index a11c741efc..04822b618b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -128,13 +128,19 @@ type Config struct { legacyMigrationPending bool `yaml:"-" json:"-"` } -// 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. +// ClaudeHeaderDefaults configures default header values injected into Claude API requests. +// In legacy mode, UserAgent/PackageVersion/RuntimeVersion/Timeout act as fallbacks when +// the client omits them, while OS/Arch remain runtime-derived. When stabilized device +// profiles are enabled, OS/Arch become the pinned platform baseline, while +// UserAgent/PackageVersion/RuntimeVersion seed the upgradeable software fingerprint. 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"` - Timeout string `yaml:"timeout" json:"timeout"` + 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"` + StabilizeDeviceProfile *bool `yaml:"stabilize-device-profile,omitempty" json:"stabilize-device-profile,omitempty"` } // CodexHeaderDefaults configures fallback header values injected into Codex @@ -630,6 +636,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 +738,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 0000000000..374720b860 --- /dev/null +++ b/internal/runtime/executor/claude_device_profile.go @@ -0,0 +1,383 @@ +package executor + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "regexp" + "runtime" + "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 + + claudeDeviceProfileBeforeCandidateStore func(claudeDeviceProfile) +) + +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 + HasVersion bool +} + +type claudeDeviceProfileCacheEntry struct { + profile claudeDeviceProfile + expire time.Time +} + +func claudeDeviceProfileStabilizationEnabled(cfg *config.Config) bool { + if cfg == nil || cfg.ClaudeHeaderDefaults.StabilizeDeviceProfile == nil { + return false + } + return *cfg.ClaudeHeaderDefaults.StabilizeDeviceProfile +} + +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 + profile.HasVersion = true + } + return profile +} + +// 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 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 shouldUpgradeClaudeDeviceProfile(candidate, current claudeDeviceProfile) bool { + if candidate.UserAgent == "" || !candidate.HasVersion { + return false + } + if current.UserAgent == "" || !current.HasVersion { + return true + } + return candidate.Version.Compare(current.Version) > 0 +} + +func pinClaudeDeviceProfilePlatform(profile, baseline claudeDeviceProfile) claudeDeviceProfile { + profile.OS = baseline.OS + profile.Arch = baseline.Arch + return profile +} + +// normalizeClaudeDeviceProfile keeps stabilized profiles pinned to the current +// baseline platform and enforces the baseline software fingerprint as a floor. +func normalizeClaudeDeviceProfile(profile, baseline claudeDeviceProfile) claudeDeviceProfile { + profile = pinClaudeDeviceProfilePlatform(profile, baseline) + if profile.UserAgent == "" || !profile.HasVersion || shouldUpgradeClaudeDeviceProfile(baseline, profile) { + profile.UserAgent = baseline.UserAgent + profile.PackageVersion = baseline.PackageVersion + profile.RuntimeVersion = baseline.RuntimeVersion + profile.Version = baseline.Version + profile.HasVersion = baseline.HasVersion + } + return profile +} + +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, + HasVersion: true, + } + 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) + if hasCandidate { + candidate = pinClaudeDeviceProfilePlatform(candidate, baseline) + } + if hasCandidate && !shouldUpgradeClaudeDeviceProfile(candidate, baseline) { + hasCandidate = false + } + + claudeDeviceProfileCacheMu.RLock() + entry, hasCached := claudeDeviceProfileCache[cacheKey] + cachedValid := hasCached && entry.expire.After(now) && entry.profile.UserAgent != "" + claudeDeviceProfileCacheMu.RUnlock() + + if hasCandidate { + if claudeDeviceProfileBeforeCandidateStore != nil { + claudeDeviceProfileBeforeCandidateStore(candidate) + } + + claudeDeviceProfileCacheMu.Lock() + entry, hasCached = claudeDeviceProfileCache[cacheKey] + cachedValid = hasCached && entry.expire.After(now) && entry.profile.UserAgent != "" + if cachedValid { + entry.profile = normalizeClaudeDeviceProfile(entry.profile, baseline) + } + if cachedValid && !shouldUpgradeClaudeDeviceProfile(candidate, entry.profile) { + entry.expire = now.Add(claudeDeviceProfileTTL) + claudeDeviceProfileCache[cacheKey] = entry + claudeDeviceProfileCacheMu.Unlock() + return entry.profile + } + + claudeDeviceProfileCache[cacheKey] = claudeDeviceProfileCacheEntry{ + profile: candidate, + expire: now.Add(claudeDeviceProfileTTL), + } + claudeDeviceProfileCacheMu.Unlock() + return candidate + } + + if cachedValid { + claudeDeviceProfileCacheMu.Lock() + entry = claudeDeviceProfileCache[cacheKey] + if entry.expire.After(now) && entry.profile.UserAgent != "" { + entry.profile = normalizeClaudeDeviceProfile(entry.profile, baseline) + 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) +} + +func applyClaudeLegacyDeviceHeaders(r *http.Request, ginHeaders http.Header, cfg *config.Config) { + if r == nil { + return + } + profile := defaultClaudeDeviceProfile(cfg) + miscEnsure := func(name, fallback string) { + if strings.TrimSpace(r.Header.Get(name)) != "" { + return + } + if strings.TrimSpace(ginHeaders.Get(name)) != "" { + r.Header.Set(name, strings.TrimSpace(ginHeaders.Get(name))) + return + } + r.Header.Set(name, fallback) + } + + miscEnsure("X-Stainless-Runtime-Version", profile.RuntimeVersion) + miscEnsure("X-Stainless-Package-Version", profile.PackageVersion) + miscEnsure("X-Stainless-Os", mapStainlessOS()) + miscEnsure("X-Stainless-Arch", mapStainlessArch()) + + // Legacy mode preserves per-auth custom header overrides. By the time we get + // here, ApplyCustomHeadersFromAttrs has already populated r.Header. + if strings.TrimSpace(r.Header.Get("User-Agent")) != "" { + return + } + + clientUA := "" + if ginHeaders != nil { + clientUA = strings.TrimSpace(ginHeaders.Get("User-Agent")) + } + if isClaudeCodeClient(clientUA) { + r.Header.Set("User-Agent", clientUA) + return + } + r.Header.Set("User-Agent", profile.UserAgent) +} diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 82b12a2f80..8e356f74d3 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,11 @@ 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 } + stabilizeDeviceProfile := claudeDeviceProfileStabilizationEnabled(cfg) + var deviceProfile claudeDeviceProfile + if stabilizeDeviceProfile { + 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 +841,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") @@ -897,13 +855,19 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, r.Header.Set("Accept", "application/json") r.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") } - // Keep OS/Arch mapping dynamic (not configurable). - // They intentionally continue to derive from runtime.GOOS/runtime.GOARCH. + // Legacy mode keeps OS/Arch runtime-derived; stabilized mode pins OS/Arch + // to the configured baseline while still allowing newer official + // User-Agent/package/runtime tuples to upgrade the software fingerprint. var attrs map[string]string if auth != nil { attrs = auth.Attributes } util.ApplyCustomHeadersFromAttrs(r, attrs) + if stabilizeDeviceProfile { + applyClaudeDeviceProfileHeaders(r, deviceProfile) + } else { + applyClaudeLegacyDeviceHeaders(r, ginHeaders, cfg) + } // 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 fa458c0fd0..c163d7ea6a 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -8,8 +8,11 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "testing" + "time" + "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 +22,587 @@ 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() + stabilize := true + + 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", + StabilizeDeviceProfile: &stabilize, + }, + } + 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() + stabilize := true + + 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", + StabilizeDeviceProfile: &stabilize, + }, + } + 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", "MacOS", "arm64") + + 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", "MacOS", "arm64") + + 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 TestApplyClaudeHeaders_DoesNotDowngradeConfiguredBaselineOnFirstClaudeClient(t *testing.T) { + resetClaudeDeviceProfileCache() + stabilize := true + + 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", + StabilizeDeviceProfile: &stabilize, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-baseline-floor", + Attributes: map[string]string{ + "api_key": "key-baseline-floor", + }, + } + + olderClaudeReq := 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(olderClaudeReq, auth, "key-baseline-floor", false, nil, cfg) + assertClaudeFingerprint(t, olderClaudeReq.Header, "claude-cli/2.1.70 (external, cli)", "0.80.0", "v24.5.0", "MacOS", "arm64") + + newerClaudeReq := newClaudeHeaderTestRequest(t, http.Header{ + "User-Agent": []string{"claude-cli/2.1.71 (external, cli)"}, + "X-Stainless-Package-Version": []string{"0.81.0"}, + "X-Stainless-Runtime-Version": []string{"v24.6.0"}, + "X-Stainless-Os": []string{"Linux"}, + "X-Stainless-Arch": []string{"x64"}, + }) + applyClaudeHeaders(newerClaudeReq, auth, "key-baseline-floor", false, nil, cfg) + assertClaudeFingerprint(t, newerClaudeReq.Header, "claude-cli/2.1.71 (external, cli)", "0.81.0", "v24.6.0", "MacOS", "arm64") +} + +func TestApplyClaudeHeaders_UpgradesCachedSoftwareFingerprintWhenBaselineAdvances(t *testing.T) { + resetClaudeDeviceProfileCache() + stabilize := true + + oldCfg := &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", + StabilizeDeviceProfile: &stabilize, + }, + } + newCfg := &config.Config{ + ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{ + UserAgent: "claude-cli/2.1.77 (external, cli)", + PackageVersion: "0.87.0", + RuntimeVersion: "v24.8.0", + OS: "MacOS", + Arch: "arm64", + StabilizeDeviceProfile: &stabilize, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-baseline-reload", + Attributes: map[string]string{ + "api_key": "key-baseline-reload", + }, + } + + officialReq := newClaudeHeaderTestRequest(t, http.Header{ + "User-Agent": []string{"claude-cli/2.1.71 (external, cli)"}, + "X-Stainless-Package-Version": []string{"0.81.0"}, + "X-Stainless-Runtime-Version": []string{"v24.6.0"}, + "X-Stainless-Os": []string{"Linux"}, + "X-Stainless-Arch": []string{"x64"}, + }) + applyClaudeHeaders(officialReq, auth, "key-baseline-reload", false, nil, oldCfg) + assertClaudeFingerprint(t, officialReq.Header, "claude-cli/2.1.71 (external, cli)", "0.81.0", "v24.6.0", "MacOS", "arm64") + + thirdPartyReq := newClaudeHeaderTestRequest(t, 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"}, + }) + applyClaudeHeaders(thirdPartyReq, auth, "key-baseline-reload", false, nil, newCfg) + assertClaudeFingerprint(t, thirdPartyReq.Header, "claude-cli/2.1.77 (external, cli)", "0.87.0", "v24.8.0", "MacOS", "arm64") +} + +func TestApplyClaudeHeaders_LearnsOfficialFingerprintAfterCustomBaselineFallback(t *testing.T) { + resetClaudeDeviceProfileCache() + stabilize := true + + cfg := &config.Config{ + ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{ + UserAgent: "my-gateway/1.0", + PackageVersion: "custom-pkg", + RuntimeVersion: "custom-runtime", + OS: "MacOS", + Arch: "arm64", + StabilizeDeviceProfile: &stabilize, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-custom-baseline-learning", + Attributes: map[string]string{ + "api_key": "key-custom-baseline-learning", + }, + } + + thirdPartyReq := newClaudeHeaderTestRequest(t, 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"}, + }) + applyClaudeHeaders(thirdPartyReq, auth, "key-custom-baseline-learning", false, nil, cfg) + assertClaudeFingerprint(t, thirdPartyReq.Header, "my-gateway/1.0", "custom-pkg", "custom-runtime", "MacOS", "arm64") + + officialReq := newClaudeHeaderTestRequest(t, http.Header{ + "User-Agent": []string{"claude-cli/2.1.77 (external, cli)"}, + "X-Stainless-Package-Version": []string{"0.87.0"}, + "X-Stainless-Runtime-Version": []string{"v24.8.0"}, + "X-Stainless-Os": []string{"Linux"}, + "X-Stainless-Arch": []string{"x64"}, + }) + applyClaudeHeaders(officialReq, auth, "key-custom-baseline-learning", false, nil, cfg) + assertClaudeFingerprint(t, officialReq.Header, "claude-cli/2.1.77 (external, cli)", "0.87.0", "v24.8.0", "MacOS", "arm64") + + postLearningThirdPartyReq := newClaudeHeaderTestRequest(t, 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"}, + }) + applyClaudeHeaders(postLearningThirdPartyReq, auth, "key-custom-baseline-learning", false, nil, cfg) + assertClaudeFingerprint(t, postLearningThirdPartyReq.Header, "claude-cli/2.1.77 (external, cli)", "0.87.0", "v24.8.0", "MacOS", "arm64") +} + +func TestResolveClaudeDeviceProfile_RechecksCacheBeforeStoringCandidate(t *testing.T) { + resetClaudeDeviceProfileCache() + stabilize := true + + 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", + StabilizeDeviceProfile: &stabilize, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-racy-upgrade", + Attributes: map[string]string{ + "api_key": "key-racy-upgrade", + }, + } + + lowPaused := make(chan struct{}) + releaseLow := make(chan struct{}) + var pauseOnce sync.Once + var releaseOnce sync.Once + + claudeDeviceProfileBeforeCandidateStore = func(candidate claudeDeviceProfile) { + if candidate.UserAgent != "claude-cli/2.1.62 (external, cli)" { + return + } + pauseOnce.Do(func() { close(lowPaused) }) + <-releaseLow + } + t.Cleanup(func() { + claudeDeviceProfileBeforeCandidateStore = nil + releaseOnce.Do(func() { close(releaseLow) }) + }) + + lowResultCh := make(chan claudeDeviceProfile, 1) + go func() { + lowResultCh <- resolveClaudeDeviceProfile(auth, "key-racy-upgrade", 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"}, + }, cfg) + }() + + select { + case <-lowPaused: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for lower candidate to pause before storing") + } + + highResult := resolveClaudeDeviceProfile(auth, "key-racy-upgrade", 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"}, + }, cfg) + releaseOnce.Do(func() { close(releaseLow) }) + + select { + case lowResult := <-lowResultCh: + if lowResult.UserAgent != "claude-cli/2.1.63 (external, cli)" { + t.Fatalf("lowResult.UserAgent = %q, want %q", lowResult.UserAgent, "claude-cli/2.1.63 (external, cli)") + } + if lowResult.PackageVersion != "0.75.0" { + t.Fatalf("lowResult.PackageVersion = %q, want %q", lowResult.PackageVersion, "0.75.0") + } + if lowResult.OS != "MacOS" || lowResult.Arch != "arm64" { + t.Fatalf("lowResult platform = %s/%s, want %s/%s", lowResult.OS, lowResult.Arch, "MacOS", "arm64") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for lower candidate result") + } + + if highResult.UserAgent != "claude-cli/2.1.63 (external, cli)" { + t.Fatalf("highResult.UserAgent = %q, want %q", highResult.UserAgent, "claude-cli/2.1.63 (external, cli)") + } + if highResult.OS != "MacOS" || highResult.Arch != "arm64" { + t.Fatalf("highResult platform = %s/%s, want %s/%s", highResult.OS, highResult.Arch, "MacOS", "arm64") + } + + cached := resolveClaudeDeviceProfile(auth, "key-racy-upgrade", http.Header{ + "User-Agent": []string{"curl/8.7.1"}, + }, cfg) + if cached.UserAgent != "claude-cli/2.1.63 (external, cli)" { + t.Fatalf("cached.UserAgent = %q, want %q", cached.UserAgent, "claude-cli/2.1.63 (external, cli)") + } + if cached.PackageVersion != "0.75.0" { + t.Fatalf("cached.PackageVersion = %q, want %q", cached.PackageVersion, "0.75.0") + } + if cached.OS != "MacOS" || cached.Arch != "arm64" { + t.Fatalf("cached platform = %s/%s, want %s/%s", cached.OS, cached.Arch, "MacOS", "arm64") + } +} + +func TestApplyClaudeHeaders_ThirdPartyBaselineThenOfficialUpgradeKeepsPinnedPlatform(t *testing.T) { + resetClaudeDeviceProfileCache() + stabilize := true + + 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", + StabilizeDeviceProfile: &stabilize, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-third-party-then-official", + Attributes: map[string]string{ + "api_key": "key-third-party-then-official", + }, + } + + thirdPartyReq := newClaudeHeaderTestRequest(t, 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"}, + }) + applyClaudeHeaders(thirdPartyReq, auth, "key-third-party-then-official", false, nil, cfg) + assertClaudeFingerprint(t, thirdPartyReq.Header, "claude-cli/2.1.70 (external, cli)", "0.80.0", "v24.5.0", "MacOS", "arm64") + + officialReq := newClaudeHeaderTestRequest(t, http.Header{ + "User-Agent": []string{"claude-cli/2.1.77 (external, cli)"}, + "X-Stainless-Package-Version": []string{"0.87.0"}, + "X-Stainless-Runtime-Version": []string{"v24.8.0"}, + "X-Stainless-Os": []string{"Linux"}, + "X-Stainless-Arch": []string{"x64"}, + }) + applyClaudeHeaders(officialReq, auth, "key-third-party-then-official", false, nil, cfg) + assertClaudeFingerprint(t, officialReq.Header, "claude-cli/2.1.77 (external, cli)", "0.87.0", "v24.8.0", "MacOS", "arm64") +} + +func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) { + resetClaudeDeviceProfileCache() + + stabilize := false + 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", + StabilizeDeviceProfile: &stabilize, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-disable-stability", + Attributes: map[string]string{ + "api_key": "key-disable-stability", + }, + } + + 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-disable-stability", 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-disable-stability", false, nil, cfg) + assertClaudeFingerprint(t, thirdPartyReq.Header, "claude-cli/2.1.60 (external, cli)", "0.10.0", "v18.0.0", "Windows", "x64") + + 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-disable-stability", false, nil, cfg) + assertClaudeFingerprint(t, lowerReq.Header, "claude-cli/2.1.61 (external, cli)", "0.73.0", "v24.2.0", "Windows", "x64") +} + +func TestApplyClaudeHeaders_LegacyModePreservesConfiguredUserAgentOverrideForClaudeClients(t *testing.T) { + resetClaudeDeviceProfileCache() + + stabilize := false + cfg := &config.Config{ + ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{ + UserAgent: "claude-cli/2.1.60 (external, cli)", + PackageVersion: "0.70.0", + RuntimeVersion: "v22.0.0", + StabilizeDeviceProfile: &stabilize, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-legacy-ua-override", + Attributes: map[string]string{ + "api_key": "key-legacy-ua-override", + "header:User-Agent": "config-ua/1.0", + }, + } + + req := 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(req, auth, "key-legacy-ua-override", false, nil, cfg) + + assertClaudeFingerprint(t, req.Header, "config-ua/1.0", "0.74.0", "v24.3.0", "Linux", "x64") +} + +func TestApplyClaudeHeaders_LegacyModeFallsBackToRuntimeOSArchWhenMissing(t *testing.T) { + resetClaudeDeviceProfileCache() + + stabilize := false + 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", + StabilizeDeviceProfile: &stabilize, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-legacy-runtime-os-arch", + Attributes: map[string]string{ + "api_key": "key-legacy-runtime-os-arch", + }, + } + + req := newClaudeHeaderTestRequest(t, http.Header{ + "User-Agent": []string{"curl/8.7.1"}, + }) + applyClaudeHeaders(req, auth, "key-legacy-runtime-os-arch", false, nil, cfg) + + assertClaudeFingerprint(t, req.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", mapStainlessOS(), mapStainlessArch()) +} + +func TestApplyClaudeHeaders_UnsetStabilizationAlsoUsesLegacyRuntimeOSArchFallback(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-unset-runtime-os-arch", + Attributes: map[string]string{ + "api_key": "key-unset-runtime-os-arch", + }, + } + + req := newClaudeHeaderTestRequest(t, http.Header{ + "User-Agent": []string{"curl/8.7.1"}, + }) + applyClaudeHeaders(req, auth, "key-unset-runtime-os-arch", false, nil, cfg) + + assertClaudeFingerprint(t, req.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", mapStainlessOS(), mapStainlessArch()) +} + +func TestClaudeDeviceProfileStabilizationEnabled_DefaultFalse(t *testing.T) { + if claudeDeviceProfileStabilizationEnabled(nil) { + t.Fatal("expected nil config to default to disabled stabilization") + } + if claudeDeviceProfileStabilizationEnabled(&config.Config{}) { + t.Fatal("expected unset stabilize-device-profile to default to disabled stabilization") + } +} + 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_")