diff --git a/config.example.yaml b/config.example.yaml index c078998b..c7742ded 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 74bcf8c6..817ff673 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 da40c4c0..a0e8a6ed 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 a8b43ed6..6b124ba5 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 68a2997a..6b1d6400 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")