diff --git a/internal/runtime/executor/claude_device_profile.go b/internal/runtime/executor/claude_device_profile.go index aa81eb94..374720b8 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 91242802..c163d7ea 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