From d52839fced30b1524bed8a699b27f41a72edd275 Mon Sep 17 00:00:00 2001 From: tpob Date: Wed, 18 Mar 2026 18:46:54 +0800 Subject: [PATCH 1/9] fix: stabilize claude device fingerprint --- config.example.yaml | 2 + .../config/claude_header_defaults_test.go | 48 ++++ internal/config/config.go | 19 ++ .../runtime/executor/claude_device_profile.go | 250 ++++++++++++++++++ internal/runtime/executor/claude_executor.go | 49 +--- .../runtime/executor/claude_executor_test.go | 141 ++++++++++ 6 files changed, 462 insertions(+), 47 deletions(-) create mode 100644 internal/config/claude_header_defaults_test.go create mode 100644 internal/runtime/executor/claude_device_profile.go diff --git a/config.example.yaml b/config.example.yaml index 3718a07a1e..637e85a478 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 0000000000..8f3325953f --- /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 a11c741efc..3cafd14e76 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 0000000000..e662f530bc --- /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 82b12a2f80..65bd3e2452 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 fa458c0fd0..08c1d5e2fe 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_") From e0e337aeb9e8dc4688c612fecb2bac4473e47072 Mon Sep 17 00:00:00 2001 From: tpob Date: Wed, 18 Mar 2026 19:31:59 +0800 Subject: [PATCH 2/9] feat(claude): add switch for device profile stabilization --- config.example.yaml | 1 + .../config/claude_header_defaults_test.go | 7 ++ internal/config/config.go | 13 +-- .../runtime/executor/claude_device_profile.go | 41 +++++++++ internal/runtime/executor/claude_executor.go | 12 ++- .../runtime/executor/claude_executor_test.go | 87 ++++++++++++++++--- 6 files changed, 142 insertions(+), 19 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 637e85a478..c078998b19 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -178,6 +178,7 @@ nonstream-keepalive-interval: 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 index 8f3325953f..676f449a06 100644 --- a/internal/config/claude_header_defaults_test.go +++ b/internal/config/claude_header_defaults_test.go @@ -17,6 +17,7 @@ claude-header-defaults: 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) @@ -45,4 +46,10 @@ claude-header-defaults: 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 3cafd14e76..74bcf8c65d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -131,12 +131,13 @@ 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 { - 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"` + 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 diff --git a/internal/runtime/executor/claude_device_profile.go b/internal/runtime/executor/claude_device_profile.go index e662f530bc..da40c4c0ac 100644 --- a/internal/runtime/executor/claude_device_profile.go +++ b/internal/runtime/executor/claude_device_profile.go @@ -74,6 +74,13 @@ type claudeDeviceProfileCacheEntry struct { 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) != "" { @@ -248,3 +255,37 @@ func applyClaudeDeviceProfileHeaders(r *http.Request, profile claudeDeviceProfil 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", profile.OS) + miscEnsure("X-Stainless-Arch", profile.Arch) + + clientUA := "" + if ginHeaders != nil { + clientUA = ginHeaders.Get("User-Agent") + } + if isClaudeCodeClient(clientUA) { + r.Header.Set("User-Agent", clientUA) + return + } + if strings.TrimSpace(r.Header.Get("User-Agent")) == "" { + r.Header.Set("User-Agent", profile.UserAgent) + } +} diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 65bd3e2452..a8b43ed6c8 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -793,7 +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 } - deviceProfile := resolveClaudeDeviceProfile(auth, apiKey, ginHeaders, cfg) + 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 != "" { @@ -858,7 +862,11 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, attrs = auth.Attributes } util.ApplyCustomHeadersFromAttrs(r, attrs) - applyClaudeDeviceProfileHeaders(r, deviceProfile) + 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 08c1d5e2fe..68a2997ac3 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -62,15 +62,17 @@ func assertClaudeFingerprint(t *testing.T, headers http.Header, userAgent, pkgVe 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", + 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{ @@ -102,14 +104,16 @@ func TestApplyClaudeHeaders_UsesConfiguredBaselineFingerprint(t *testing.T) { 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", + 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{ @@ -160,6 +164,67 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) { assertClaudeFingerprint(t, lowerReq.Header, "claude-cli/2.1.63 (external, cli)", "0.75.0", "v24.4.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 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_") From 616d41c06ad221878601485d0f3de79bd42a87ec Mon Sep 17 00:00:00 2001 From: tpob Date: Thu, 19 Mar 2026 00:01:50 +0800 Subject: [PATCH 3/9] fix(claude): restore legacy runtime OS arch fallback --- config.example.yaml | 4 +- internal/config/config.go | 6 +- .../runtime/executor/claude_device_profile.go | 35 +++++++++++- internal/runtime/executor/claude_executor.go | 4 +- .../runtime/executor/claude_executor_test.go | 56 +++++++++++++++++++ 5 files changed, 98 insertions(+), 7 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index c078998b19..c7742deda1 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -170,7 +170,9 @@ 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, all values below seed the pinned baseline fingerprint. # claude-header-defaults: # user-agent: "claude-cli/2.1.44 (external, sdk-cli)" # package-version: "0.74.0" diff --git a/internal/config/config.go b/internal/config/config.go index 74bcf8c65d..817ff673df 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -128,8 +128,10 @@ 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, all of these values seed the baseline pinned fingerprint. type ClaudeHeaderDefaults struct { UserAgent string `yaml:"user-agent" json:"user-agent"` PackageVersion string `yaml:"package-version" json:"package-version"` diff --git a/internal/runtime/executor/claude_device_profile.go b/internal/runtime/executor/claude_device_profile.go index da40c4c0ac..a0e8a6ed5c 100644 --- a/internal/runtime/executor/claude_device_profile.go +++ b/internal/runtime/executor/claude_device_profile.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "net/http" "regexp" + "runtime" "strconv" "strings" "sync" @@ -107,6 +108,36 @@ func defaultClaudeDeviceProfile(cfg *config.Config) claudeDeviceProfile { 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 { @@ -274,8 +305,8 @@ func applyClaudeLegacyDeviceHeaders(r *http.Request, ginHeaders http.Header, cfg miscEnsure("X-Stainless-Runtime-Version", profile.RuntimeVersion) miscEnsure("X-Stainless-Package-Version", profile.PackageVersion) - miscEnsure("X-Stainless-Os", profile.OS) - miscEnsure("X-Stainless-Arch", profile.Arch) + miscEnsure("X-Stainless-Os", mapStainlessOS()) + miscEnsure("X-Stainless-Arch", mapStainlessArch()) clientUA := "" if ginHeaders != nil { diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index a8b43ed6c8..6b124ba520 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -855,8 +855,8 @@ 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 may pin + // the full device profile from the cached or configured baseline. var attrs map[string]string if auth != nil { attrs = auth.Attributes diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 68a2997ac3..6b1d640048 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -216,6 +216,62 @@ func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) { assertClaudeFingerprint(t, lowerReq.Header, "claude-cli/2.1.61 (external, cli)", "0.73.0", "v24.2.0", "Windows", "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") From dd64adbeeba9e7c19acf57bd0604d29400d2cc1d Mon Sep 17 00:00:00 2001 From: tpob Date: Thu, 19 Mar 2026 00:03:09 +0800 Subject: [PATCH 4/9] fix(claude): preserve legacy user agent overrides --- .../runtime/executor/claude_device_profile.go | 12 ++++--- .../runtime/executor/claude_executor_test.go | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/internal/runtime/executor/claude_device_profile.go b/internal/runtime/executor/claude_device_profile.go index a0e8a6ed5c..9de3689fd6 100644 --- a/internal/runtime/executor/claude_device_profile.go +++ b/internal/runtime/executor/claude_device_profile.go @@ -308,15 +308,19 @@ func applyClaudeLegacyDeviceHeaders(r *http.Request, ginHeaders http.Header, cfg 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 = ginHeaders.Get("User-Agent") + clientUA = strings.TrimSpace(ginHeaders.Get("User-Agent")) } if isClaudeCodeClient(clientUA) { r.Header.Set("User-Agent", clientUA) return } - if strings.TrimSpace(r.Header.Get("User-Agent")) == "" { - r.Header.Set("User-Agent", profile.UserAgent) - } + r.Header.Set("User-Agent", profile.UserAgent) } diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 6b1d640048..3ff8fd7b53 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -216,6 +216,38 @@ func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) { 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() From 6fa7abe43418d3f46f7586c6f210a688dc5deb40 Mon Sep 17 00:00:00 2001 From: tpob Date: Thu, 19 Mar 2026 01:02:04 +0800 Subject: [PATCH 5/9] fix(claude): keep configured baseline above older fingerprints --- .../runtime/executor/claude_device_profile.go | 18 +++++++- .../runtime/executor/claude_executor_test.go | 42 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/internal/runtime/executor/claude_device_profile.go b/internal/runtime/executor/claude_device_profile.go index 9de3689fd6..44d7069d16 100644 --- a/internal/runtime/executor/claude_device_profile.go +++ b/internal/runtime/executor/claude_device_profile.go @@ -158,6 +158,19 @@ func parseClaudeCLIVersion(userAgent string) (claudeCLIVersion, bool) { return claudeCLIVersion{major: major, minor: minor, patch: patch}, true } +func shouldUpgradeClaudeDeviceProfile(candidate, current claudeDeviceProfile) bool { + if candidate.UserAgent == "" { + return false + } + if current.UserAgent == "" { + return true + } + if current.Version == (claudeCLIVersion{}) { + return false + } + return candidate.Version.Compare(current.Version) > 0 +} + func extractClaudeDeviceProfile(headers http.Header, cfg *config.Config) (claudeDeviceProfile, bool) { if headers == nil { return claudeDeviceProfile{}, false @@ -235,13 +248,16 @@ func resolveClaudeDeviceProfile(auth *cliproxyauth.Auth, apiKey string, headers now := time.Now() baseline := defaultClaudeDeviceProfile(cfg) candidate, hasCandidate := extractClaudeDeviceProfile(headers, cfg) + 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 && (!cachedValid || candidate.Version.Compare(entry.profile.Version) > 0) { + if hasCandidate && (!cachedValid || shouldUpgradeClaudeDeviceProfile(candidate, entry.profile)) { newEntry := claudeDeviceProfileCacheEntry{ profile: candidate, expire: now.Add(claudeDeviceProfileTTL), diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 3ff8fd7b53..e73b1c06ea 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -164,6 +164,48 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) { 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", "Linux", "x64") +} + func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) { resetClaudeDeviceProfileCache() From 8179d5a8a471c14e8451198e6ee752dd4a0d363e Mon Sep 17 00:00:00 2001 From: tpob Date: Thu, 19 Mar 2026 01:03:41 +0800 Subject: [PATCH 6/9] fix(claude): avoid racy fingerprint downgrades --- .../runtime/executor/claude_device_profile.go | 22 ++++- .../runtime/executor/claude_executor_test.go | 93 +++++++++++++++++++ 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/internal/runtime/executor/claude_device_profile.go b/internal/runtime/executor/claude_device_profile.go index 44d7069d16..fce126b357 100644 --- a/internal/runtime/executor/claude_device_profile.go +++ b/internal/runtime/executor/claude_device_profile.go @@ -31,6 +31,8 @@ var ( claudeDeviceProfileCache = make(map[string]claudeDeviceProfileCacheEntry) claudeDeviceProfileCacheMu sync.RWMutex claudeDeviceProfileCacheCleanupOnce sync.Once + + claudeDeviceProfileBeforeCandidateStore func(claudeDeviceProfile) ) type claudeCLIVersion struct { @@ -257,13 +259,25 @@ func resolveClaudeDeviceProfile(auth *cliproxyauth.Auth, apiKey string, headers cachedValid := hasCached && entry.expire.After(now) && entry.profile.UserAgent != "" claudeDeviceProfileCacheMu.RUnlock() - if hasCandidate && (!cachedValid || shouldUpgradeClaudeDeviceProfile(candidate, entry.profile)) { - newEntry := claudeDeviceProfileCacheEntry{ + if hasCandidate { + if claudeDeviceProfileBeforeCandidateStore != nil { + claudeDeviceProfileBeforeCandidateStore(candidate) + } + + claudeDeviceProfileCacheMu.Lock() + entry, hasCached = claudeDeviceProfileCache[cacheKey] + cachedValid = hasCached && entry.expire.After(now) && entry.profile.UserAgent != "" + 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.Lock() - claudeDeviceProfileCache[cacheKey] = newEntry claudeDeviceProfileCacheMu.Unlock() return candidate } diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index e73b1c06ea..31c8915afd 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -8,7 +8,9 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "testing" + "time" "github.com/gin-gonic/gin" "github.com/klauspost/compress/zstd" @@ -206,6 +208,97 @@ func TestApplyClaudeHeaders_DoesNotDowngradeConfiguredBaselineOnFirstClaudeClien assertClaudeFingerprint(t, newerClaudeReq.Header, "claude-cli/2.1.71 (external, cli)", "0.81.0", "v24.6.0", "Linux", "x64") } +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") + } + 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)") + } + + 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") + } +} + func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) { resetClaudeDeviceProfileCache() From f7069e9548e91bc6d91d82ad8342699e5056df23 Mon Sep 17 00:00:00 2001 From: tpob Date: Thu, 19 Mar 2026 13:07:16 +0800 Subject: [PATCH 7/9] fix(claude): pin stabilized OS arch to baseline --- config.example.yaml | 4 +- internal/config/config.go | 3 +- .../runtime/executor/claude_device_profile.go | 13 +++++ internal/runtime/executor/claude_executor.go | 5 +- .../runtime/executor/claude_executor_test.go | 57 ++++++++++++++++++- 5 files changed, 75 insertions(+), 7 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index c7742deda1..c393bb7aa7 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -172,7 +172,9 @@ nonstream-keepalive-interval: 0 # Default headers for Claude API requests. Update when Claude Code releases new versions. # 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, all values below seed the pinned baseline fingerprint. +# 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" diff --git a/internal/config/config.go b/internal/config/config.go index 817ff673df..04822b618b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -131,7 +131,8 @@ type Config struct { // 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, all of these values seed the baseline pinned fingerprint. +// 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"` diff --git a/internal/runtime/executor/claude_device_profile.go b/internal/runtime/executor/claude_device_profile.go index fce126b357..68bcd10227 100644 --- a/internal/runtime/executor/claude_device_profile.go +++ b/internal/runtime/executor/claude_device_profile.go @@ -173,6 +173,12 @@ func shouldUpgradeClaudeDeviceProfile(candidate, current claudeDeviceProfile) bo return candidate.Version.Compare(current.Version) > 0 } +func pinClaudeDeviceProfilePlatform(profile, baseline claudeDeviceProfile) claudeDeviceProfile { + profile.OS = baseline.OS + profile.Arch = baseline.Arch + return profile +} + func extractClaudeDeviceProfile(headers http.Header, cfg *config.Config) (claudeDeviceProfile, bool) { if headers == nil { return claudeDeviceProfile{}, false @@ -250,6 +256,9 @@ func resolveClaudeDeviceProfile(auth *cliproxyauth.Auth, apiKey string, headers now := time.Now() baseline := defaultClaudeDeviceProfile(cfg) candidate, hasCandidate := extractClaudeDeviceProfile(headers, cfg) + if hasCandidate { + candidate = pinClaudeDeviceProfilePlatform(candidate, baseline) + } if hasCandidate && !shouldUpgradeClaudeDeviceProfile(candidate, baseline) { hasCandidate = false } @@ -267,6 +276,9 @@ func resolveClaudeDeviceProfile(auth *cliproxyauth.Auth, apiKey string, headers claudeDeviceProfileCacheMu.Lock() entry, hasCached = claudeDeviceProfileCache[cacheKey] cachedValid = hasCached && entry.expire.After(now) && entry.profile.UserAgent != "" + if cachedValid { + entry.profile = pinClaudeDeviceProfilePlatform(entry.profile, baseline) + } if cachedValid && !shouldUpgradeClaudeDeviceProfile(candidate, entry.profile) { entry.expire = now.Add(claudeDeviceProfileTTL) claudeDeviceProfileCache[cacheKey] = entry @@ -286,6 +298,7 @@ func resolveClaudeDeviceProfile(auth *cliproxyauth.Auth, apiKey string, headers claudeDeviceProfileCacheMu.Lock() entry = claudeDeviceProfileCache[cacheKey] if entry.expire.After(now) && entry.profile.UserAgent != "" { + entry.profile = pinClaudeDeviceProfilePlatform(entry.profile, baseline) entry.expire = now.Add(claudeDeviceProfileTTL) claudeDeviceProfileCache[cacheKey] = entry claudeDeviceProfileCacheMu.Unlock() diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 6b124ba520..8e356f74d3 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -855,8 +855,9 @@ 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") } - // Legacy mode keeps OS/Arch runtime-derived; stabilized mode may pin - // the full device profile from the cached or configured baseline. + // 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 diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 31c8915afd..68d391faad 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -133,7 +133,7 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) { "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") + 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"}, @@ -143,7 +143,7 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) { "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") + 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)"}, @@ -205,7 +205,7 @@ func TestApplyClaudeHeaders_DoesNotDowngradeConfiguredBaselineOnFirstClaudeClien "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", "Linux", "x64") + assertClaudeFingerprint(t, newerClaudeReq.Header, "claude-cli/2.1.71 (external, cli)", "0.81.0", "v24.6.0", "MacOS", "arm64") } func TestResolveClaudeDeviceProfile_RechecksCacheBeforeStoringCandidate(t *testing.T) { @@ -280,6 +280,9 @@ func TestResolveClaudeDeviceProfile_RechecksCacheBeforeStoringCandidate(t *testi 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") } @@ -287,6 +290,9 @@ func TestResolveClaudeDeviceProfile_RechecksCacheBeforeStoringCandidate(t *testi 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"}, @@ -297,6 +303,51 @@ func TestResolveClaudeDeviceProfile_RechecksCacheBeforeStoringCandidate(t *testi 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) { From 680105f84d67ee9a50ee823ced295e2fce3595b6 Mon Sep 17 00:00:00 2001 From: tpob Date: Thu, 19 Mar 2026 13:28:58 +0800 Subject: [PATCH 8/9] fix(claude): refresh cached fingerprint after baseline upgrades --- .../runtime/executor/claude_device_profile.go | 17 +++++- .../runtime/executor/claude_executor_test.go | 52 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/internal/runtime/executor/claude_device_profile.go b/internal/runtime/executor/claude_device_profile.go index 68bcd10227..aa81eb94c4 100644 --- a/internal/runtime/executor/claude_device_profile.go +++ b/internal/runtime/executor/claude_device_profile.go @@ -179,6 +179,19 @@ func pinClaudeDeviceProfilePlatform(profile, baseline claudeDeviceProfile) claud 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.Version == (claudeCLIVersion{}) || shouldUpgradeClaudeDeviceProfile(baseline, profile) { + profile.UserAgent = baseline.UserAgent + profile.PackageVersion = baseline.PackageVersion + profile.RuntimeVersion = baseline.RuntimeVersion + profile.Version = baseline.Version + } + return profile +} + func extractClaudeDeviceProfile(headers http.Header, cfg *config.Config) (claudeDeviceProfile, bool) { if headers == nil { return claudeDeviceProfile{}, false @@ -277,7 +290,7 @@ func resolveClaudeDeviceProfile(auth *cliproxyauth.Auth, apiKey string, headers entry, hasCached = claudeDeviceProfileCache[cacheKey] cachedValid = hasCached && entry.expire.After(now) && entry.profile.UserAgent != "" if cachedValid { - entry.profile = pinClaudeDeviceProfilePlatform(entry.profile, baseline) + entry.profile = normalizeClaudeDeviceProfile(entry.profile, baseline) } if cachedValid && !shouldUpgradeClaudeDeviceProfile(candidate, entry.profile) { entry.expire = now.Add(claudeDeviceProfileTTL) @@ -298,7 +311,7 @@ func resolveClaudeDeviceProfile(auth *cliproxyauth.Auth, apiKey string, headers claudeDeviceProfileCacheMu.Lock() entry = claudeDeviceProfileCache[cacheKey] if entry.expire.After(now) && entry.profile.UserAgent != "" { - entry.profile = pinClaudeDeviceProfilePlatform(entry.profile, baseline) + entry.profile = normalizeClaudeDeviceProfile(entry.profile, baseline) entry.expire = now.Add(claudeDeviceProfileTTL) claudeDeviceProfileCache[cacheKey] = entry claudeDeviceProfileCacheMu.Unlock() diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 68d391faad..91242802bd 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -208,6 +208,58 @@ func TestApplyClaudeHeaders_DoesNotDowngradeConfiguredBaselineOnFirstClaudeClien 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 TestResolveClaudeDeviceProfile_RechecksCacheBeforeStoringCandidate(t *testing.T) { resetClaudeDeviceProfileCache() stabilize := true From 52c1fa025eb5657de1a7689f26b4b689ca7b787c Mon Sep 17 00:00:00 2001 From: tpob Date: Thu, 19 Mar 2026 13:59:41 +0800 Subject: [PATCH 9/9] fix(claude): learn official fingerprints after custom baselines --- .../runtime/executor/claude_device_profile.go | 13 ++--- .../runtime/executor/claude_executor_test.go | 52 +++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/internal/runtime/executor/claude_device_profile.go b/internal/runtime/executor/claude_device_profile.go index aa81eb94c4..374720b860 100644 --- a/internal/runtime/executor/claude_device_profile.go +++ b/internal/runtime/executor/claude_device_profile.go @@ -70,6 +70,7 @@ type claudeDeviceProfile struct { OS string Arch string Version claudeCLIVersion + HasVersion bool } type claudeDeviceProfileCacheEntry struct { @@ -106,6 +107,7 @@ func defaultClaudeDeviceProfile(cfg *config.Config) claudeDeviceProfile { } if version, ok := parseClaudeCLIVersion(profile.UserAgent); ok { profile.Version = version + profile.HasVersion = true } return profile } @@ -161,15 +163,12 @@ func parseClaudeCLIVersion(userAgent string) (claudeCLIVersion, bool) { } func shouldUpgradeClaudeDeviceProfile(candidate, current claudeDeviceProfile) bool { - if candidate.UserAgent == "" { + if candidate.UserAgent == "" || !candidate.HasVersion { return false } - if current.UserAgent == "" { + if current.UserAgent == "" || !current.HasVersion { return true } - if current.Version == (claudeCLIVersion{}) { - return false - } return candidate.Version.Compare(current.Version) > 0 } @@ -183,11 +182,12 @@ func pinClaudeDeviceProfilePlatform(profile, baseline claudeDeviceProfile) claud // 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.Version == (claudeCLIVersion{}) || shouldUpgradeClaudeDeviceProfile(baseline, profile) { + 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 } @@ -211,6 +211,7 @@ func extractClaudeDeviceProfile(headers http.Header, cfg *config.Config) (claude OS: firstNonEmptyHeader(headers, "X-Stainless-Os", baseline.OS), Arch: firstNonEmptyHeader(headers, "X-Stainless-Arch", baseline.Arch), Version: version, + HasVersion: true, } return profile, true } diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 91242802bd..c163d7ea6a 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -260,6 +260,58 @@ func TestApplyClaudeHeaders_UpgradesCachedSoftwareFingerprintWhenBaselineAdvance 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