Files
CLIProxyAPIPlus/internal/runtime/executor/claude_device_profile.go

327 lines
8.9 KiB
Go

package executor
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
)
const (
defaultClaudeFingerprintUserAgent = "claude-cli/2.1.63 (external, cli)"
defaultClaudeFingerprintPackageVersion = "0.74.0"
defaultClaudeFingerprintRuntimeVersion = "v24.3.0"
defaultClaudeFingerprintOS = "MacOS"
defaultClaudeFingerprintArch = "arm64"
claudeDeviceProfileTTL = 7 * 24 * time.Hour
claudeDeviceProfileCleanupPeriod = time.Hour
)
var (
claudeCLIVersionPattern = regexp.MustCompile(`^claude-cli/(\d+)\.(\d+)\.(\d+)`)
claudeDeviceProfileCache = make(map[string]claudeDeviceProfileCacheEntry)
claudeDeviceProfileCacheMu sync.RWMutex
claudeDeviceProfileCacheCleanupOnce sync.Once
)
type claudeCLIVersion struct {
major int
minor int
patch int
}
func (v claudeCLIVersion) Compare(other claudeCLIVersion) int {
switch {
case v.major != other.major:
if v.major > other.major {
return 1
}
return -1
case v.minor != other.minor:
if v.minor > other.minor {
return 1
}
return -1
case v.patch != other.patch:
if v.patch > other.patch {
return 1
}
return -1
default:
return 0
}
}
type claudeDeviceProfile struct {
UserAgent string
PackageVersion string
RuntimeVersion string
OS string
Arch string
Version claudeCLIVersion
}
type claudeDeviceProfileCacheEntry struct {
profile claudeDeviceProfile
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) != "" {
return strings.TrimSpace(cfgVal)
}
return fallback
}
var hd config.ClaudeHeaderDefaults
if cfg != nil {
hd = cfg.ClaudeHeaderDefaults
}
profile := claudeDeviceProfile{
UserAgent: hdrDefault(hd.UserAgent, defaultClaudeFingerprintUserAgent),
PackageVersion: hdrDefault(hd.PackageVersion, defaultClaudeFingerprintPackageVersion),
RuntimeVersion: hdrDefault(hd.RuntimeVersion, defaultClaudeFingerprintRuntimeVersion),
OS: hdrDefault(hd.OS, defaultClaudeFingerprintOS),
Arch: hdrDefault(hd.Arch, defaultClaudeFingerprintArch),
}
if version, ok := parseClaudeCLIVersion(profile.UserAgent); ok {
profile.Version = version
}
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 {
return claudeCLIVersion{}, false
}
major, err := strconv.Atoi(matches[1])
if err != nil {
return claudeCLIVersion{}, false
}
minor, err := strconv.Atoi(matches[2])
if err != nil {
return claudeCLIVersion{}, false
}
patch, err := strconv.Atoi(matches[3])
if err != nil {
return claudeCLIVersion{}, false
}
return claudeCLIVersion{major: major, minor: minor, patch: patch}, true
}
func extractClaudeDeviceProfile(headers http.Header, cfg *config.Config) (claudeDeviceProfile, bool) {
if headers == nil {
return claudeDeviceProfile{}, false
}
userAgent := strings.TrimSpace(headers.Get("User-Agent"))
version, ok := parseClaudeCLIVersion(userAgent)
if !ok {
return claudeDeviceProfile{}, false
}
baseline := defaultClaudeDeviceProfile(cfg)
profile := claudeDeviceProfile{
UserAgent: userAgent,
PackageVersion: firstNonEmptyHeader(headers, "X-Stainless-Package-Version", baseline.PackageVersion),
RuntimeVersion: firstNonEmptyHeader(headers, "X-Stainless-Runtime-Version", baseline.RuntimeVersion),
OS: firstNonEmptyHeader(headers, "X-Stainless-Os", baseline.OS),
Arch: firstNonEmptyHeader(headers, "X-Stainless-Arch", baseline.Arch),
Version: version,
}
return profile, true
}
func firstNonEmptyHeader(headers http.Header, name, fallback string) string {
if headers == nil {
return fallback
}
if value := strings.TrimSpace(headers.Get(name)); value != "" {
return value
}
return fallback
}
func claudeDeviceProfileScopeKey(auth *cliproxyauth.Auth, apiKey string) string {
switch {
case auth != nil && strings.TrimSpace(auth.ID) != "":
return "auth:" + strings.TrimSpace(auth.ID)
case strings.TrimSpace(apiKey) != "":
return "api_key:" + strings.TrimSpace(apiKey)
default:
return "global"
}
}
func claudeDeviceProfileCacheKey(auth *cliproxyauth.Auth, apiKey string) string {
sum := sha256.Sum256([]byte(claudeDeviceProfileScopeKey(auth, apiKey)))
return hex.EncodeToString(sum[:])
}
func startClaudeDeviceProfileCacheCleanup() {
go func() {
ticker := time.NewTicker(claudeDeviceProfileCleanupPeriod)
defer ticker.Stop()
for range ticker.C {
purgeExpiredClaudeDeviceProfiles()
}
}()
}
func purgeExpiredClaudeDeviceProfiles() {
now := time.Now()
claudeDeviceProfileCacheMu.Lock()
for key, entry := range claudeDeviceProfileCache {
if !entry.expire.After(now) {
delete(claudeDeviceProfileCache, key)
}
}
claudeDeviceProfileCacheMu.Unlock()
}
func resolveClaudeDeviceProfile(auth *cliproxyauth.Auth, apiKey string, headers http.Header, cfg *config.Config) claudeDeviceProfile {
claudeDeviceProfileCacheCleanupOnce.Do(startClaudeDeviceProfileCacheCleanup)
cacheKey := claudeDeviceProfileCacheKey(auth, apiKey)
now := time.Now()
baseline := defaultClaudeDeviceProfile(cfg)
candidate, hasCandidate := extractClaudeDeviceProfile(headers, cfg)
claudeDeviceProfileCacheMu.RLock()
entry, hasCached := claudeDeviceProfileCache[cacheKey]
cachedValid := hasCached && entry.expire.After(now) && entry.profile.UserAgent != ""
claudeDeviceProfileCacheMu.RUnlock()
if hasCandidate && (!cachedValid || candidate.Version.Compare(entry.profile.Version) > 0) {
newEntry := claudeDeviceProfileCacheEntry{
profile: candidate,
expire: now.Add(claudeDeviceProfileTTL),
}
claudeDeviceProfileCacheMu.Lock()
claudeDeviceProfileCache[cacheKey] = newEntry
claudeDeviceProfileCacheMu.Unlock()
return candidate
}
if cachedValid {
claudeDeviceProfileCacheMu.Lock()
entry = claudeDeviceProfileCache[cacheKey]
if entry.expire.After(now) && entry.profile.UserAgent != "" {
entry.expire = now.Add(claudeDeviceProfileTTL)
claudeDeviceProfileCache[cacheKey] = entry
claudeDeviceProfileCacheMu.Unlock()
return entry.profile
}
claudeDeviceProfileCacheMu.Unlock()
}
return baseline
}
func applyClaudeDeviceProfileHeaders(r *http.Request, profile claudeDeviceProfile) {
if r == nil {
return
}
for _, headerName := range []string{
"User-Agent",
"X-Stainless-Package-Version",
"X-Stainless-Runtime-Version",
"X-Stainless-Os",
"X-Stainless-Arch",
} {
r.Header.Del(headerName)
}
r.Header.Set("User-Agent", profile.UserAgent)
r.Header.Set("X-Stainless-Package-Version", profile.PackageVersion)
r.Header.Set("X-Stainless-Runtime-Version", profile.RuntimeVersion)
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", mapStainlessOS())
miscEnsure("X-Stainless-Arch", mapStainlessArch())
// Legacy mode preserves per-auth custom header overrides. By the time we get
// here, ApplyCustomHeadersFromAttrs has already populated r.Header.
if strings.TrimSpace(r.Header.Get("User-Agent")) != "" {
return
}
clientUA := ""
if ginHeaders != nil {
clientUA = strings.TrimSpace(ginHeaders.Get("User-Agent"))
}
if isClaudeCodeClient(clientUA) {
r.Header.Set("User-Agent", clientUA)
return
}
r.Header.Set("User-Agent", profile.UserAgent)
}