fix(claude): restore legacy runtime OS arch fallback

This commit is contained in:
tpob
2026-03-19 00:01:50 +08:00
parent e0e337aeb9
commit 616d41c06a
5 changed files with 98 additions and 7 deletions

View File

@@ -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"

View File

@@ -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"`

View File

@@ -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 {

View File

@@ -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

View File

@@ -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")