diff --git a/config.example.yaml b/config.example.yaml index c7742ded..c393bb7a 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 817ff673..04822b61 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 fce126b3..68bcd102 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 6b124ba5..8e356f74 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 31c8915a..68d391fa 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) {