feat(claude): add switch for device profile stabilization
This commit is contained in:
@@ -178,6 +178,7 @@ nonstream-keepalive-interval: 0
|
|||||||
# os: "MacOS"
|
# os: "MacOS"
|
||||||
# arch: "arm64"
|
# arch: "arm64"
|
||||||
# timeout: "600"
|
# 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.
|
# Default headers for Codex OAuth model requests.
|
||||||
# These are used only for file-backed/OAuth Codex requests when the client
|
# These are used only for file-backed/OAuth Codex requests when the client
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ claude-header-defaults:
|
|||||||
os: " MacOS "
|
os: " MacOS "
|
||||||
arch: " arm64 "
|
arch: " arm64 "
|
||||||
timeout: " 900 "
|
timeout: " 900 "
|
||||||
|
stabilize-device-profile: false
|
||||||
`)
|
`)
|
||||||
if err := os.WriteFile(configPath, configYAML, 0o600); err != nil {
|
if err := os.WriteFile(configPath, configYAML, 0o600); err != nil {
|
||||||
t.Fatalf("failed to write config: %v", err)
|
t.Fatalf("failed to write config: %v", err)
|
||||||
@@ -45,4 +46,10 @@ claude-header-defaults:
|
|||||||
if got := cfg.ClaudeHeaderDefaults.Timeout; got != "900" {
|
if got := cfg.ClaudeHeaderDefaults.Timeout; got != "900" {
|
||||||
t.Fatalf("Timeout = %q, want %q", 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ type ClaudeHeaderDefaults struct {
|
|||||||
OS string `yaml:"os" json:"os"`
|
OS string `yaml:"os" json:"os"`
|
||||||
Arch string `yaml:"arch" json:"arch"`
|
Arch string `yaml:"arch" json:"arch"`
|
||||||
Timeout string `yaml:"timeout" json:"timeout"`
|
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
|
// CodexHeaderDefaults configures fallback header values injected into Codex
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ type claudeDeviceProfileCacheEntry struct {
|
|||||||
expire time.Time
|
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 {
|
func defaultClaudeDeviceProfile(cfg *config.Config) claudeDeviceProfile {
|
||||||
hdrDefault := func(cfgVal, fallback string) string {
|
hdrDefault := func(cfgVal, fallback string) string {
|
||||||
if strings.TrimSpace(cfgVal) != "" {
|
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-Os", profile.OS)
|
||||||
r.Header.Set("X-Stainless-Arch", profile.Arch)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
|
||||||
ginHeaders = ginCtx.Request.Header
|
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"
|
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 != "" {
|
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
|
attrs = auth.Attributes
|
||||||
}
|
}
|
||||||
util.ApplyCustomHeadersFromAttrs(r, attrs)
|
util.ApplyCustomHeadersFromAttrs(r, attrs)
|
||||||
|
if stabilizeDeviceProfile {
|
||||||
applyClaudeDeviceProfileHeaders(r, deviceProfile)
|
applyClaudeDeviceProfileHeaders(r, deviceProfile)
|
||||||
|
} else {
|
||||||
|
applyClaudeLegacyDeviceHeaders(r, ginHeaders, cfg)
|
||||||
|
}
|
||||||
// Re-enforce Accept-Encoding: identity after ApplyCustomHeadersFromAttrs, which
|
// Re-enforce Accept-Encoding: identity after ApplyCustomHeadersFromAttrs, which
|
||||||
// may override it with a user-configured value. Compressed SSE breaks the line
|
// 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.
|
// scanner regardless of user preference, so this is non-negotiable for streams.
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ func assertClaudeFingerprint(t *testing.T, headers http.Header, userAgent, pkgVe
|
|||||||
|
|
||||||
func TestApplyClaudeHeaders_UsesConfiguredBaselineFingerprint(t *testing.T) {
|
func TestApplyClaudeHeaders_UsesConfiguredBaselineFingerprint(t *testing.T) {
|
||||||
resetClaudeDeviceProfileCache()
|
resetClaudeDeviceProfileCache()
|
||||||
|
stabilize := true
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{
|
ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{
|
||||||
@@ -71,6 +72,7 @@ func TestApplyClaudeHeaders_UsesConfiguredBaselineFingerprint(t *testing.T) {
|
|||||||
OS: "MacOS",
|
OS: "MacOS",
|
||||||
Arch: "arm64",
|
Arch: "arm64",
|
||||||
Timeout: "900",
|
Timeout: "900",
|
||||||
|
StabilizeDeviceProfile: &stabilize,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
auth := &cliproxyauth.Auth{
|
auth := &cliproxyauth.Auth{
|
||||||
@@ -102,6 +104,7 @@ func TestApplyClaudeHeaders_UsesConfiguredBaselineFingerprint(t *testing.T) {
|
|||||||
|
|
||||||
func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) {
|
func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) {
|
||||||
resetClaudeDeviceProfileCache()
|
resetClaudeDeviceProfileCache()
|
||||||
|
stabilize := true
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{
|
ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{
|
||||||
@@ -110,6 +113,7 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) {
|
|||||||
RuntimeVersion: "v22.0.0",
|
RuntimeVersion: "v22.0.0",
|
||||||
OS: "MacOS",
|
OS: "MacOS",
|
||||||
Arch: "arm64",
|
Arch: "arm64",
|
||||||
|
StabilizeDeviceProfile: &stabilize,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
auth := &cliproxyauth.Auth{
|
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")
|
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) {
|
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":{}}]}]}`)
|
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_")
|
out := applyClaudeToolPrefix(input, "proxy_")
|
||||||
|
|||||||
Reference in New Issue
Block a user