From e0e337aeb9e8dc4688c612fecb2bac4473e47072 Mon Sep 17 00:00:00 2001 From: tpob Date: Wed, 18 Mar 2026 19:31:59 +0800 Subject: [PATCH] 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 637e85a4..c078998b 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 8f332595..676f449a 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 3cafd14e..74bcf8c6 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 e662f530..da40c4c0 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 65bd3e24..a8b43ed6 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 08c1d5e2..68a2997a 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_")