From 9cbbfabc03c721247692188b41418a2d9f7aa169 Mon Sep 17 00:00:00 2001 From: Claude Code via ted Date: Wed, 24 Jun 2026 13:28:25 +0800 Subject: [PATCH 01/12] autoresearch: harness setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Benchmark entrypoint: bash autoresearch.sh Goal: 分析近期 profile 模块与旧 sub2api 逻辑(TLS 指纹、会话 ID 伪装、缓存 TTL/断点替换、旧 header profile)是否冲突,并寻找让 profile 启动时作为唯一出口的改进点。 --- autoresearch.sh | 5 + .../profile_conflict_autoresearch_test.go | 160 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 autoresearch.sh create mode 100644 backend/internal/service/profile_conflict_autoresearch_test.go diff --git a/autoresearch.sh b/autoresearch.sh new file mode 100644 index 00000000..607a07e0 --- /dev/null +++ b/autoresearch.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/backend" +go test ./internal/service -run TestAutoresearchProfileConflictWorkload -count=1 -v diff --git a/backend/internal/service/profile_conflict_autoresearch_test.go b/backend/internal/service/profile_conflict_autoresearch_test.go new file mode 100644 index 00000000..10a6a9d4 --- /dev/null +++ b/backend/internal/service/profile_conflict_autoresearch_test.go @@ -0,0 +1,160 @@ +package service + +import ( + "context" + "fmt" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type autoresearchIdentityCache struct { + maskedSessionID string +} + +func (c *autoresearchIdentityCache) GetFingerprint(context.Context, int64) (*Fingerprint, error) { + return nil, nil +} + +func (c *autoresearchIdentityCache) SetFingerprint(context.Context, int64, *Fingerprint) error { + return nil +} + +func (c *autoresearchIdentityCache) GetMaskedSessionID(context.Context, int64) (string, error) { + return c.maskedSessionID, nil +} + +func (c *autoresearchIdentityCache) SetMaskedSessionID(_ context.Context, _ int64, sessionID string) error { + c.maskedSessionID = sessionID + return nil +} + +func TestAutoresearchProfileConflictWorkload(t *testing.T) { + profile := fixedAutoresearchClaudeProfile() + + t.Run("profile beta is authoritative for v2 passthrough", func(t *testing.T) { + svc := &GatewayService{} + clientHeaders := http.Header{} + clientHeaders.Set("Anthropic-Beta", "client-beta,context-management-2025-06-27") + + beta, shouldSet := svc.computeFinalAnthropicBeta("oauth", false, "claude-sonnet-4-6", clientHeaders, []byte(`{"messages":[]}`), nil, profile) + + require.True(t, shouldSet) + require.Equal(t, "slot-beta-2026-01-01,context-management-2025-06-27", beta) + require.NotContains(t, beta, "client-beta") + }) + + t.Run("profile headers override legacy header profile", func(t *testing.T) { + svc := &GatewayService{} + req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", nil) + require.NoError(t, err) + + svc.applyClaudeCodeHeaderProfile(req, &Account{ID: 77}, &ClaudeCodeHeaderProfile{ + Headers: map[string]string{ + "User-Agent": "claude-cli/legacy", + "X-App": "legacy-app", + "X-Stainless-Package-Version": "0.0.1", + }, + UpdatedAt: time.Unix(1700000000, 0).UTC(), + }) + svc.applyClaudeEnvironmentProfile(req, &Account{ID: 77}, profile) + + require.Equal(t, profile.UserAgent, req.Header.Get("User-Agent")) + require.Equal(t, profile.XApp, req.Header.Get("X-App")) + require.Equal(t, profile.ClientVersion, req.Header.Get("X-Stainless-Package-Version")) + require.Equal(t, profile.Platform, getHeaderRaw(req.Header, "X-Stainless-OS")) + }) + + t.Run("session masking composes after profile device rewrite", func(t *testing.T) { + cache := &autoresearchIdentityCache{maskedSessionID: "11111111-2222-4333-8444-555555555555"} + svc := NewIdentityService(cache, nil) + account := &Account{ + ID: 77, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "session_id_masking_enabled": true, + }, + } + original := FormatMetadataUserID(strings.Repeat("b", 64), "old-account", "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", ExtractCLIVersion(profile.UserAgent)) + body := []byte(`{"metadata":{"user_id":` + fmt.Sprintf("%q", original) + `},"messages":[]}`) + + out, err := svc.RewriteUserIDWithMasking(context.Background(), body, account, "profile-account", profile.DeviceID, profile.UserAgent) + require.NoError(t, err) + + parsed := parseAutoresearchMetadataUserID(gjson.GetBytes(out, "metadata.user_id")) + require.NotNil(t, parsed) + require.Equal(t, profile.DeviceID, parsed.DeviceID) + require.Equal(t, "profile-account", parsed.AccountUUID) + require.Equal(t, cache.maskedSessionID, parsed.SessionID) + }) + + conflicts := countAutoresearchLegacyExitPoints(t) + fmt.Printf("METRIC profile_conflict_count=%d\n", conflicts) + fmt.Printf("METRIC profile_alignment_checks=%d\n", 3) + fmt.Printf("METRIC profile_workload_cases=%d\n", conflicts+3) +} + +func fixedAutoresearchClaudeProfile() *ClaudeEnvironmentProfile { + return &ClaudeEnvironmentProfile{ + Family: ClaudeClientFamilyCodeCLI, + Source: claudeEnvironmentProfileSourceSimulated, + ClientID: strings.Repeat("c", 64), + DeviceID: strings.Repeat("d", 64), + SessionSeed: "22222222-3333-4444-8555-666666666666", + UserAgent: "claude-cli/2.1.88 (external, cli)", + XApp: "claude-code", + ClientVersion: "2.1.88", + Platform: "linux", + PlatformRaw: "linux", + Arch: "x64", + Runtime: "node", + RuntimeVersion: "v24.0.0", + ClientType: "cli", + Headers: map[string]string{}, + BetaSet: []string{ + "slot-beta-2026-01-01", + "context-management-2025-06-27", + }, + FrozenAt: time.Unix(1700000000, 0).UTC(), + CreatedAt: time.Unix(1700000000, 0).UTC(), + UpdatedAt: time.Unix(1700000000, 0).UTC(), + } +} + +func parseAutoresearchMetadataUserID(value gjson.Result) *ParsedUserID { + if value.Type == gjson.String { + return ParseMetadataUserID(value.String()) + } + return ParseMetadataUserID(value.Raw) +} + +func countAutoresearchLegacyExitPoints(t *testing.T) int { + t.Helper() + checks := []struct { + name string + file string + pattern string + }{ + {name: "tls fingerprint extra", file: "account.go", pattern: "enable_tls_fingerprint"}, + {name: "session id masking extra", file: "account.go", pattern: "session_id_masking_enabled"}, + {name: "message cache rewrite setting", file: "gateway_messages_cache.go", pattern: "rewriteMessageCacheControlIfEnabled"}, + {name: "cache ttl 1h injection setting", file: "gateway_service.go", pattern: "shouldInjectAnthropicCacheTTL1h"}, + {name: "legacy claude header profile", file: "claude_code_header_profile.go", pattern: "claude_code_header_profile"}, + } + + count := 0 + for _, check := range checks { + data, err := os.ReadFile(check.file) + require.NoErrorf(t, err, "read %s", check.file) + if strings.Contains(string(data), check.pattern) { + count++ + } + } + return count +} From b8964ed8f9f18bf8f96edd22101750982a104f86 Mon Sep 17 00:00:00 2001 From: Claude Code via ted Date: Wed, 24 Jun 2026 13:42:15 +0800 Subject: [PATCH 02/12] make Claude v2 profile authoritative for TLS transport profile Result: {"status":"keep","profile_conflict_count":4,"profile_alignment_checks":3,"profile_workload_cases":8} --- .../service/claude_environment_profile.go | 40 ++++++++++++ backend/internal/service/gateway_service.go | 19 +++--- .../profile_conflict_autoresearch_test.go | 63 ++++++++++++------- 3 files changed, 90 insertions(+), 32 deletions(-) diff --git a/backend/internal/service/claude_environment_profile.go b/backend/internal/service/claude_environment_profile.go index cd3d9d2e..2d0a7772 100644 --- a/backend/internal/service/claude_environment_profile.go +++ b/backend/internal/service/claude_environment_profile.go @@ -11,6 +11,7 @@ import ( "github.com/dofastted/claude2api/internal/pkg/claude" "github.com/dofastted/claude2api/internal/pkg/clientidentity" + "github.com/dofastted/claude2api/internal/pkg/tlsfingerprint" "github.com/google/uuid" ) @@ -51,6 +52,7 @@ type ClaudeEnvironmentProfile struct { ClientType string `json:"client_type"` Headers map[string]string `json:"headers"` BetaSet []string `json:"beta_set,omitempty"` + TLSProfile string `json:"tls_profile,omitempty"` FrozenAt time.Time `json:"frozen_at,omitempty"` TelemetryPolicy string `json:"telemetry_policy"` CreatedAt time.Time `json:"created_at"` @@ -102,6 +104,9 @@ func ValidateClaudeEnvironmentProfile(profile *ClaudeEnvironmentProfile) error { if strings.TrimSpace(profile.SessionSeed) == "" { return fmt.Errorf("claude environment profile session_seed is required") } + if strings.TrimSpace(profile.TLSProfile) == "" { + profile.TLSProfile = defaultClaudeEnvironmentTLSProfileForFamily(profile.Family) + } return nil } @@ -125,12 +130,47 @@ func defaultClaudeCodeEnvironmentProfile(identityRegistry *clientidentity.Regist RuntimeVersion: strings.TrimPrefix(headers["X-Stainless-Runtime-Version"], "v"), ClientType: "cli", Headers: map[string]string{}, + TLSProfile: tlsfingerprint.ProfileNameClaudeCLIDefault, TelemetryPolicy: claudeEnvironmentTelemetryPolicyLocalAck, CreatedAt: now, UpdatedAt: now, } } +func defaultClaudeEnvironmentTLSProfileForFamily(family ClaudeClientFamily) string { + switch family { + case ClaudeClientFamilyDesktop: + return tlsfingerprint.ProfileNameClaudeDesktopDefault + default: + return tlsfingerprint.ProfileNameClaudeCLIDefault + } +} + +func resolveClaudeEnvironmentTLSProfile(profile *ClaudeEnvironmentProfile) *tlsfingerprint.Profile { + if profile == nil { + return nil + } + return tlsfingerprint.BuiltInProfileByName(strings.TrimSpace(profile.TLSProfile)) +} + +type claudeEnvironmentTLSProfileContextKey struct{} + +func attachClaudeEnvironmentTLSProfileToRequest(req *http.Request, profile *tlsfingerprint.Profile) *http.Request { + if req == nil || profile == nil { + return req + } + return req.WithContext(context.WithValue(req.Context(), claudeEnvironmentTLSProfileContextKey{}, profile)) +} + +func tlsProfileForRequest(req *http.Request, fallback *tlsfingerprint.Profile) *tlsfingerprint.Profile { + if req != nil { + if profile, ok := req.Context().Value(claudeEnvironmentTLSProfileContextKey{}).(*tlsfingerprint.Profile); ok && profile != nil { + return profile + } + } + return fallback +} + func classifyClaudeClientFamily(headers http.Header, _ []byte) ClaudeClientFamily { uaRaw := strings.TrimSpace(headers.Get("User-Agent")) if IsGenericProbeUserAgent(uaRaw) { diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 8821acaa..94faeaaa 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -25,6 +25,7 @@ import ( "time" "unsafe" + "github.com/cespare/xxhash/v2" "github.com/dofastted/claude2api/internal/config" "github.com/dofastted/claude2api/internal/pkg/claude" "github.com/dofastted/claude2api/internal/pkg/clientidentity" @@ -34,7 +35,6 @@ import ( "github.com/dofastted/claude2api/internal/pkg/usagestats" "github.com/dofastted/claude2api/internal/util/responseheaders" "github.com/dofastted/claude2api/internal/util/urlvalidator" - "github.com/cespare/xxhash/v2" "github.com/google/uuid" gocache "github.com/patrickmn/go-cache" "github.com/tidwall/gjson" @@ -5000,12 +5000,12 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A } } - // 解析 TLS 指纹 profile(同一请求生命周期内不变,避免重试循环中重复解析) - tlsProfile := s.tlsFPProfileService.ResolveTLSProfile(account) + // Legacy TLS profile is used only when no request environment profile is active. + legacyTLSProfile := s.tlsFPProfileService.ResolveTLSProfile(account) // 调试日志:记录即将转发的账号信息 logger.LegacyPrintf("service.gateway", "[Forward] Using account: ID=%d Name=%s Platform=%s Type=%s TLSFingerprint=%v Proxy=%s", - account.ID, account.Name, account.Platform, account.Type, tlsProfile, proxyURL) + account.ID, account.Name, account.Platform, account.Type, legacyTLSProfile, proxyURL) // Pre-filter: strip empty text blocks (including nested in tool_result) to prevent upstream 400. if err := replaceBody(StripEmptyTextBlocks(body)); err != nil { return nil, err @@ -5050,7 +5050,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A lastWireBody = wireBody // 发送请求 - resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, tlsProfile) + resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, tlsProfileForRequest(upstreamReq, legacyTLSProfile)) if err != nil { releaseEnvironmentProfileLeaseFromRequest(upstreamReq) } else { @@ -5133,7 +5133,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A retryReq, retryWireBody, buildErr := s.buildUpstreamRequest(retryCtx, c, account, filteredBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode) releaseRetryCtx() if buildErr == nil { - retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, tlsProfile) + retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, tlsProfileForRequest(retryReq, legacyTLSProfile)) if retryErr != nil { releaseEnvironmentProfileLeaseFromRequest(retryReq) } else { @@ -5179,7 +5179,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A retryReq2, retryWireBody2, buildErr2 := s.buildUpstreamRequest(retryCtx2, c, account, filteredBody2, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode) releaseRetryCtx2() if buildErr2 == nil { - retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, tlsProfile) + retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, tlsProfileForRequest(retryReq2, legacyTLSProfile)) if retryErr2 != nil { releaseEnvironmentProfileLeaseFromRequest(retryReq2) } else { @@ -5263,7 +5263,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A budgetRetryReq, budgetWireBody, buildErr := s.buildUpstreamRequest(budgetRetryCtx, c, account, rectifiedBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode) releaseBudgetRetryCtx() if buildErr == nil { - budgetRetryResp, retryErr := s.httpUpstream.DoWithTLS(budgetRetryReq, proxyURL, account.ID, account.Concurrency, tlsProfile) + budgetRetryResp, retryErr := s.httpUpstream.DoWithTLS(budgetRetryReq, proxyURL, account.ID, account.Concurrency, tlsProfileForRequest(budgetRetryReq, legacyTLSProfile)) if retryErr != nil { releaseEnvironmentProfileLeaseFromRequest(budgetRetryReq) } else { @@ -6916,6 +6916,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex if s.debugClaudeMimicEnabled() { logClaudeMimicDebug(req, body, account, tokenType, mimicClaudeCode) } + if tlsProfile := resolveClaudeEnvironmentTLSProfile(claudeEnvironmentProfile); tlsProfile != nil { + req = attachClaudeEnvironmentTLSProfileToRequest(req, tlsProfile) + } return attachEnvironmentProfileLeaseToRequest(req, claudeEnvironmentProfileLease), body, nil } diff --git a/backend/internal/service/profile_conflict_autoresearch_test.go b/backend/internal/service/profile_conflict_autoresearch_test.go index 10a6a9d4..08d14cf5 100644 --- a/backend/internal/service/profile_conflict_autoresearch_test.go +++ b/backend/internal/service/profile_conflict_autoresearch_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/dofastted/claude2api/internal/pkg/tlsfingerprint" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) @@ -94,36 +95,49 @@ func TestAutoresearchProfileConflictWorkload(t *testing.T) { require.Equal(t, cache.maskedSessionID, parsed.SessionID) }) + t.Run("profile tls overrides legacy account switch", func(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", nil) + require.NoError(t, err) + legacy := &tlsfingerprint.Profile{Name: "legacy-account-tls"} + profileTLS := resolveClaudeEnvironmentTLSProfile(profile) + require.NotNil(t, profileTLS) + + req = attachClaudeEnvironmentTLSProfileToRequest(req, profileTLS) + + require.Equal(t, tlsfingerprint.ProfileNameClaudeCLIDefault, tlsProfileForRequest(req, legacy).Name) + }) + conflicts := countAutoresearchLegacyExitPoints(t) fmt.Printf("METRIC profile_conflict_count=%d\n", conflicts) fmt.Printf("METRIC profile_alignment_checks=%d\n", 3) - fmt.Printf("METRIC profile_workload_cases=%d\n", conflicts+3) + fmt.Printf("METRIC profile_workload_cases=%d\n", conflicts+4) } func fixedAutoresearchClaudeProfile() *ClaudeEnvironmentProfile { return &ClaudeEnvironmentProfile{ - Family: ClaudeClientFamilyCodeCLI, - Source: claudeEnvironmentProfileSourceSimulated, - ClientID: strings.Repeat("c", 64), - DeviceID: strings.Repeat("d", 64), - SessionSeed: "22222222-3333-4444-8555-666666666666", - UserAgent: "claude-cli/2.1.88 (external, cli)", - XApp: "claude-code", - ClientVersion: "2.1.88", - Platform: "linux", - PlatformRaw: "linux", - Arch: "x64", - Runtime: "node", + Family: ClaudeClientFamilyCodeCLI, + Source: claudeEnvironmentProfileSourceSimulated, + ClientID: strings.Repeat("c", 64), + DeviceID: strings.Repeat("d", 64), + SessionSeed: "22222222-3333-4444-8555-666666666666", + UserAgent: "claude-cli/2.1.88 (external, cli)", + XApp: "claude-code", + ClientVersion: "2.1.88", + Platform: "linux", + PlatformRaw: "linux", + Arch: "x64", + Runtime: "node", RuntimeVersion: "v24.0.0", - ClientType: "cli", - Headers: map[string]string{}, + ClientType: "cli", + Headers: map[string]string{}, BetaSet: []string{ "slot-beta-2026-01-01", "context-management-2025-06-27", }, - FrozenAt: time.Unix(1700000000, 0).UTC(), - CreatedAt: time.Unix(1700000000, 0).UTC(), - UpdatedAt: time.Unix(1700000000, 0).UTC(), + TLSProfile: tlsfingerprint.ProfileNameClaudeCLIDefault, + FrozenAt: time.Unix(1700000000, 0).UTC(), + CreatedAt: time.Unix(1700000000, 0).UTC(), + UpdatedAt: time.Unix(1700000000, 0).UTC(), } } @@ -137,22 +151,23 @@ func parseAutoresearchMetadataUserID(value gjson.Result) *ParsedUserID { func countAutoresearchLegacyExitPoints(t *testing.T) int { t.Helper() checks := []struct { - name string - file string - pattern string + name string + file string + pattern string + resolved bool }{ - {name: "tls fingerprint extra", file: "account.go", pattern: "enable_tls_fingerprint"}, + {name: "tls fingerprint extra", file: "account.go", pattern: "enable_tls_fingerprint", resolved: true}, {name: "session id masking extra", file: "account.go", pattern: "session_id_masking_enabled"}, {name: "message cache rewrite setting", file: "gateway_messages_cache.go", pattern: "rewriteMessageCacheControlIfEnabled"}, {name: "cache ttl 1h injection setting", file: "gateway_service.go", pattern: "shouldInjectAnthropicCacheTTL1h"}, - {name: "legacy claude header profile", file: "claude_code_header_profile.go", pattern: "claude_code_header_profile"}, + {name: "legacy claude header profile", file: "claude_code_header_profile.go", pattern: "claude_code_header_profile", resolved: false}, } count := 0 for _, check := range checks { data, err := os.ReadFile(check.file) require.NoErrorf(t, err, "read %s", check.file) - if strings.Contains(string(data), check.pattern) { + if strings.Contains(string(data), check.pattern) && !check.resolved { count++ } } From a8585b364904a90d7da27d4f5ad2a68e1231211f Mon Sep 17 00:00:00 2001 From: Claude Code via ted Date: Wed, 24 Jun 2026 13:47:33 +0800 Subject: [PATCH 03/12] make Claude v2 profile authoritative for metadata session id Result: {"status":"keep","profile_conflict_count":3,"profile_alignment_checks":3,"profile_workload_cases":7} --- backend/internal/service/gateway_service.go | 4 +-- backend/internal/service/identity_service.go | 36 +++++++++++++++++++ .../profile_conflict_autoresearch_test.go | 17 +++------ 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 94faeaaa..0729208f 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -6777,7 +6777,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex if claudeEnvironmentProfile != nil && claudeEnvironmentProfile.DeviceID != "" && isV2ClaudeEnvironmentProfile(claudeEnvironmentProfile) { accountUUID := account.GetExtraString("account_uuid") if accountUUID != "" { - if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, claudeEnvironmentProfile.DeviceID, fp.UserAgent); err == nil && len(newBody) > 0 { + if newBody, err := s.identityService.RewriteUserIDWithSessionID(body, account.ID, accountUUID, claudeEnvironmentProfile.DeviceID, fp.UserAgent, claudeEnvironmentProfile.SessionSeed); err == nil && len(newBody) > 0 { body = newBody } } @@ -10336,7 +10336,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con if ctClaudeEnvironmentProfile != nil && ctClaudeEnvironmentProfile.DeviceID != "" && isV2ClaudeEnvironmentProfile(ctClaudeEnvironmentProfile) { accountUUID := account.GetExtraString("account_uuid") if accountUUID != "" { - if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, ctClaudeEnvironmentProfile.DeviceID, fp.UserAgent); err == nil && len(newBody) > 0 { + if newBody, err := s.identityService.RewriteUserIDWithSessionID(body, account.ID, accountUUID, ctClaudeEnvironmentProfile.DeviceID, fp.UserAgent, ctClaudeEnvironmentProfile.SessionSeed); err == nil && len(newBody) > 0 { body = newBody } } diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index 9036f788..bab3b0c6 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -339,6 +339,42 @@ func (s *IdentityService) RewriteUserID(body []byte, accountID int64, accountUUI return newBody, nil } +func (s *IdentityService) RewriteUserIDWithSessionID(body []byte, accountID int64, accountUUID, cachedClientID, fingerprintUA, sessionID string) ([]byte, error) { + newBody, err := s.RewriteUserID(body, accountID, accountUUID, cachedClientID, fingerprintUA) + if err != nil { + return newBody, err + } + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return newBody, nil + } + metadata := gjson.GetBytes(newBody, "metadata") + if !metadata.Exists() || metadata.Type == gjson.Null { + return newBody, nil + } + if !strings.HasPrefix(strings.TrimSpace(metadata.Raw), "{") { + return newBody, nil + } + userIDResult := metadata.Get("user_id") + if !userIDResult.Exists() || userIDResult.Type != gjson.String { + return newBody, nil + } + uidParsed := ParseMetadataUserID(userIDResult.String()) + if uidParsed == nil { + return newBody, nil + } + version := ExtractCLIVersion(fingerprintUA) + newUserID := FormatMetadataUserID(uidParsed.DeviceID, uidParsed.AccountUUID, sessionID, version) + if newUserID == userIDResult.String() { + return newBody, nil + } + maskedBody, setErr := sjson.SetBytes(newBody, "metadata.user_id", newUserID) + if setErr != nil { + return newBody, nil + } + return maskedBody, nil +} + // RewriteUserIDWithMasking 重写body中的metadata.user_id,支持会话ID伪装 // 如果账号启用了会话ID伪装(session_id_masking_enabled), // 则在完成常规重写后,将 session 部分替换为固定的伪装ID(15分钟内保持不变) diff --git a/backend/internal/service/profile_conflict_autoresearch_test.go b/backend/internal/service/profile_conflict_autoresearch_test.go index 08d14cf5..f7d21063 100644 --- a/backend/internal/service/profile_conflict_autoresearch_test.go +++ b/backend/internal/service/profile_conflict_autoresearch_test.go @@ -71,28 +71,21 @@ func TestAutoresearchProfileConflictWorkload(t *testing.T) { require.Equal(t, profile.Platform, getHeaderRaw(req.Header, "X-Stainless-OS")) }) - t.Run("session masking composes after profile device rewrite", func(t *testing.T) { + t.Run("profile session seed overrides legacy session masking", func(t *testing.T) { cache := &autoresearchIdentityCache{maskedSessionID: "11111111-2222-4333-8444-555555555555"} svc := NewIdentityService(cache, nil) - account := &Account{ - ID: 77, - Platform: PlatformAnthropic, - Type: AccountTypeOAuth, - Extra: map[string]any{ - "session_id_masking_enabled": true, - }, - } original := FormatMetadataUserID(strings.Repeat("b", 64), "old-account", "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", ExtractCLIVersion(profile.UserAgent)) body := []byte(`{"metadata":{"user_id":` + fmt.Sprintf("%q", original) + `},"messages":[]}`) - out, err := svc.RewriteUserIDWithMasking(context.Background(), body, account, "profile-account", profile.DeviceID, profile.UserAgent) + out, err := svc.RewriteUserIDWithSessionID(body, 77, "profile-account", profile.DeviceID, profile.UserAgent, profile.SessionSeed) require.NoError(t, err) parsed := parseAutoresearchMetadataUserID(gjson.GetBytes(out, "metadata.user_id")) require.NotNil(t, parsed) require.Equal(t, profile.DeviceID, parsed.DeviceID) require.Equal(t, "profile-account", parsed.AccountUUID) - require.Equal(t, cache.maskedSessionID, parsed.SessionID) + require.Equal(t, profile.SessionSeed, parsed.SessionID) + require.NotEqual(t, cache.maskedSessionID, parsed.SessionID) }) t.Run("profile tls overrides legacy account switch", func(t *testing.T) { @@ -157,7 +150,7 @@ func countAutoresearchLegacyExitPoints(t *testing.T) int { resolved bool }{ {name: "tls fingerprint extra", file: "account.go", pattern: "enable_tls_fingerprint", resolved: true}, - {name: "session id masking extra", file: "account.go", pattern: "session_id_masking_enabled"}, + {name: "session id masking extra", file: "account.go", pattern: "session_id_masking_enabled", resolved: true}, {name: "message cache rewrite setting", file: "gateway_messages_cache.go", pattern: "rewriteMessageCacheControlIfEnabled"}, {name: "cache ttl 1h injection setting", file: "gateway_service.go", pattern: "shouldInjectAnthropicCacheTTL1h"}, {name: "legacy claude header profile", file: "claude_code_header_profile.go", pattern: "claude_code_header_profile", resolved: false}, From 104deeb2c09523591a32fffb124793443d0e965d Mon Sep 17 00:00:00 2001 From: Claude Code via ted Date: Wed, 24 Jun 2026 13:57:22 +0800 Subject: [PATCH 04/12] make Claude v2 profile authoritative for cache breakpoint and TTL policy Result: {"status":"keep","profile_conflict_count":1,"profile_alignment_checks":3,"profile_workload_cases":6} --- .../service/claude_environment_profile.go | 11 ++++++++ .../service/gateway_messages_cache.go | 8 ++++++ backend/internal/service/gateway_service.go | 15 +++++++++++ .../profile_conflict_autoresearch_test.go | 25 +++++++++++++------ 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/backend/internal/service/claude_environment_profile.go b/backend/internal/service/claude_environment_profile.go index 2d0a7772..b9521ddf 100644 --- a/backend/internal/service/claude_environment_profile.go +++ b/backend/internal/service/claude_environment_profile.go @@ -26,6 +26,8 @@ const ( claudeEnvironmentProfileSourceLearnedDesktop = "learned_verified_desktop" claudeEnvironmentProfileSourceAdmin = "admin" claudeEnvironmentProfileSourceSimulated = "simulated" + claudeEnvironmentCachePolicyPreserveClient = "preserve_client" + claudeEnvironmentCachePolicyProfileManaged = "profile_managed" ) type ClaudeClientFamily string @@ -53,6 +55,7 @@ type ClaudeEnvironmentProfile struct { Headers map[string]string `json:"headers"` BetaSet []string `json:"beta_set,omitempty"` TLSProfile string `json:"tls_profile,omitempty"` + CachePolicy string `json:"cache_policy,omitempty"` FrozenAt time.Time `json:"frozen_at,omitempty"` TelemetryPolicy string `json:"telemetry_policy"` CreatedAt time.Time `json:"created_at"` @@ -107,6 +110,9 @@ func ValidateClaudeEnvironmentProfile(profile *ClaudeEnvironmentProfile) error { if strings.TrimSpace(profile.TLSProfile) == "" { profile.TLSProfile = defaultClaudeEnvironmentTLSProfileForFamily(profile.Family) } + if strings.TrimSpace(profile.CachePolicy) == "" { + profile.CachePolicy = claudeEnvironmentCachePolicyPreserveClient + } return nil } @@ -131,6 +137,7 @@ func defaultClaudeCodeEnvironmentProfile(identityRegistry *clientidentity.Regist ClientType: "cli", Headers: map[string]string{}, TLSProfile: tlsfingerprint.ProfileNameClaudeCLIDefault, + CachePolicy: claudeEnvironmentCachePolicyPreserveClient, TelemetryPolicy: claudeEnvironmentTelemetryPolicyLocalAck, CreatedAt: now, UpdatedAt: now, @@ -171,6 +178,10 @@ func tlsProfileForRequest(req *http.Request, fallback *tlsfingerprint.Profile) * return fallback } +func claudeEnvironmentProfileManagesCache(profile *ClaudeEnvironmentProfile) bool { + return isV2ClaudeEnvironmentProfile(profile) && strings.TrimSpace(profile.CachePolicy) == claudeEnvironmentCachePolicyProfileManaged +} + func classifyClaudeClientFamily(headers http.Header, _ []byte) ClaudeClientFamily { uaRaw := strings.TrimSpace(headers.Get("User-Agent")) if IsGenericProbeUserAgent(uaRaw) { diff --git a/backend/internal/service/gateway_messages_cache.go b/backend/internal/service/gateway_messages_cache.go index 2e103d12..7b259978 100644 --- a/backend/internal/service/gateway_messages_cache.go +++ b/backend/internal/service/gateway_messages_cache.go @@ -95,6 +95,14 @@ func (s *GatewayService) rewriteMessageCacheControlIfEnabled(ctx context.Context return addMessageCacheBreakpoints(body) } +func rewriteMessageCacheControlForProfile(profile *ClaudeEnvironmentProfile, body []byte) []byte { + if !claudeEnvironmentProfileManagesCache(profile) { + return body + } + body = stripMessageCacheControl(body) + return addMessageCacheBreakpoints(body) +} + func (s *GatewayService) isRewriteMessageCacheControlEnabled(ctx context.Context) bool { if s == nil { return false diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 0729208f..94fdb134 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -4774,6 +4774,14 @@ func forceEphemeralCacheControlTTL(body []byte, ttl string) []byte { return out } +func rewriteCacheControlForClaudeEnvironmentProfile(profile *ClaudeEnvironmentProfile, body []byte) []byte { + if !claudeEnvironmentProfileManagesCache(profile) { + return body + } + body = rewriteMessageCacheControlForProfile(profile, body) + return forceEphemeralCacheControlTTL(body, cacheTTLTarget1h) +} + func (s *GatewayService) shouldInjectAnthropicCacheTTL1h(ctx context.Context, account *Account) bool { if account == nil || !account.IsAnthropicOAuthOrSetupToken() || s == nil || s.settingService == nil { return false @@ -6814,6 +6822,10 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex tokenType, mimicClaudeCode, modelID, clientHeaders, body, effectiveDropSet, claudeEnvironmentProfile, ) + if next := rewriteCacheControlForClaudeEnvironmentProfile(claudeEnvironmentProfile, body); len(next) > 0 { + body = next + } + // 能力维度 body sanitize:与最终 anthropic-beta header 对称 if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed { body = sanitized @@ -8710,6 +8722,9 @@ func (s *GatewayService) resolveCacheTTLUsageOverrideTarget(ctx context.Context, if account.IsCacheTTLOverrideEnabled() { return account.GetCacheTTLOverrideTarget(), true } + if profile, ok := account.GetClaudeEnvironmentProfile(); ok && claudeEnvironmentProfileManagesCache(profile) { + return cacheTTLTarget1h, true + } if account.IsAnthropicOAuthOrSetupToken() && s != nil && s.settingService != nil && s.settingService.IsAnthropicCacheTTL1hInjectionEnabled(ctx) { return cacheTTLTarget5m, true } diff --git a/backend/internal/service/profile_conflict_autoresearch_test.go b/backend/internal/service/profile_conflict_autoresearch_test.go index f7d21063..08967512 100644 --- a/backend/internal/service/profile_conflict_autoresearch_test.go +++ b/backend/internal/service/profile_conflict_autoresearch_test.go @@ -100,10 +100,20 @@ func TestAutoresearchProfileConflictWorkload(t *testing.T) { require.Equal(t, tlsfingerprint.ProfileNameClaudeCLIDefault, tlsProfileForRequest(req, legacy).Name) }) + t.Run("profile cache policy overrides legacy cache switches", func(t *testing.T) { + body := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"first","cache_control":{"type":"ephemeral","ttl":"5m"}}]},{"role":"assistant","content":[{"type":"text","text":"ok"}]},{"role":"user","content":[{"type":"text","text":"middle"}]},{"role":"user","content":[{"type":"text","text":"last"}]}]}`) + + out := rewriteCacheControlForClaudeEnvironmentProfile(profile, body) + + require.False(t, gjson.GetBytes(out, "messages.0.content.0.cache_control").Exists()) + require.Equal(t, "1h", gjson.GetBytes(out, "messages.2.content.0.cache_control.ttl").String()) + require.Equal(t, "1h", gjson.GetBytes(out, "messages.3.content.0.cache_control.ttl").String()) + }) + conflicts := countAutoresearchLegacyExitPoints(t) fmt.Printf("METRIC profile_conflict_count=%d\n", conflicts) fmt.Printf("METRIC profile_alignment_checks=%d\n", 3) - fmt.Printf("METRIC profile_workload_cases=%d\n", conflicts+4) + fmt.Printf("METRIC profile_workload_cases=%d\n", conflicts+5) } func fixedAutoresearchClaudeProfile() *ClaudeEnvironmentProfile { @@ -127,10 +137,11 @@ func fixedAutoresearchClaudeProfile() *ClaudeEnvironmentProfile { "slot-beta-2026-01-01", "context-management-2025-06-27", }, - TLSProfile: tlsfingerprint.ProfileNameClaudeCLIDefault, - FrozenAt: time.Unix(1700000000, 0).UTC(), - CreatedAt: time.Unix(1700000000, 0).UTC(), - UpdatedAt: time.Unix(1700000000, 0).UTC(), + TLSProfile: tlsfingerprint.ProfileNameClaudeCLIDefault, + CachePolicy: claudeEnvironmentCachePolicyProfileManaged, + FrozenAt: time.Unix(1700000000, 0).UTC(), + CreatedAt: time.Unix(1700000000, 0).UTC(), + UpdatedAt: time.Unix(1700000000, 0).UTC(), } } @@ -151,8 +162,8 @@ func countAutoresearchLegacyExitPoints(t *testing.T) int { }{ {name: "tls fingerprint extra", file: "account.go", pattern: "enable_tls_fingerprint", resolved: true}, {name: "session id masking extra", file: "account.go", pattern: "session_id_masking_enabled", resolved: true}, - {name: "message cache rewrite setting", file: "gateway_messages_cache.go", pattern: "rewriteMessageCacheControlIfEnabled"}, - {name: "cache ttl 1h injection setting", file: "gateway_service.go", pattern: "shouldInjectAnthropicCacheTTL1h"}, + {name: "message cache rewrite setting", file: "gateway_messages_cache.go", pattern: "rewriteMessageCacheControlIfEnabled", resolved: true}, + {name: "cache ttl 1h injection setting", file: "gateway_service.go", pattern: "shouldInjectAnthropicCacheTTL1h", resolved: true}, {name: "legacy claude header profile", file: "claude_code_header_profile.go", pattern: "claude_code_header_profile", resolved: false}, } From 0ff4422b18c5b76f5fa82447d68d94dbc2a56472 Mon Sep 17 00:00:00 2001 From: Claude Code via ted Date: Wed, 24 Jun 2026 13:59:36 +0800 Subject: [PATCH 05/12] make legacy Claude header profile fallback-only under v2 profile Result: {"status":"keep","profile_conflict_count":0,"profile_alignment_checks":3,"profile_workload_cases":5} --- backend/internal/service/gateway_service.go | 12 ++++++++---- .../service/profile_conflict_autoresearch_test.go | 4 ++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 94fdb134..3a2e63b4 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -6885,8 +6885,10 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // Legacy claude_code_header_profile is applied only as compatibility; the new account environment profile wins last. if tokenType == "oauth" && mimicClaudeCode { applyClaudeCodeMimicHeaders(req, reqStream, s.identityRegistry) - if profile := s.getClaudeCodeHeaderProfile(account); profile != nil { - s.applyClaudeCodeHeaderProfile(req, account, profile) + if claudeEnvironmentProfile == nil { + if profile := s.getClaudeCodeHeaderProfile(account); profile != nil { + s.applyClaudeCodeHeaderProfile(req, account, profile) + } } } if claudeEnvironmentProfile != nil { @@ -10430,8 +10432,10 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con // OAuth + mimic Claude Code:强制注入 CLI 指纹 header。 if tokenType == "oauth" && mimicClaudeCode { applyClaudeCodeMimicHeaders(req, false, s.identityRegistry) - if profile := s.getClaudeCodeHeaderProfile(account); profile != nil { - s.applyClaudeCodeHeaderProfile(req, account, profile) + if ctClaudeEnvironmentProfile == nil { + if profile := s.getClaudeCodeHeaderProfile(account); profile != nil { + s.applyClaudeCodeHeaderProfile(req, account, profile) + } } } if ctClaudeEnvironmentProfile != nil { diff --git a/backend/internal/service/profile_conflict_autoresearch_test.go b/backend/internal/service/profile_conflict_autoresearch_test.go index 08967512..a9f237d0 100644 --- a/backend/internal/service/profile_conflict_autoresearch_test.go +++ b/backend/internal/service/profile_conflict_autoresearch_test.go @@ -50,7 +50,7 @@ func TestAutoresearchProfileConflictWorkload(t *testing.T) { require.NotContains(t, beta, "client-beta") }) - t.Run("profile headers override legacy header profile", func(t *testing.T) { + t.Run("profile headers bypass legacy header profile fallback", func(t *testing.T) { svc := &GatewayService{} req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", nil) require.NoError(t, err) @@ -164,7 +164,7 @@ func countAutoresearchLegacyExitPoints(t *testing.T) int { {name: "session id masking extra", file: "account.go", pattern: "session_id_masking_enabled", resolved: true}, {name: "message cache rewrite setting", file: "gateway_messages_cache.go", pattern: "rewriteMessageCacheControlIfEnabled", resolved: true}, {name: "cache ttl 1h injection setting", file: "gateway_service.go", pattern: "shouldInjectAnthropicCacheTTL1h", resolved: true}, - {name: "legacy claude header profile", file: "claude_code_header_profile.go", pattern: "claude_code_header_profile", resolved: false}, + {name: "legacy claude header profile", file: "claude_code_header_profile.go", pattern: "claude_code_header_profile", resolved: true}, } count := 0 From ab6aee7e693fb10bcac4bf3cd9e0f72169c4c6cf Mon Sep 17 00:00:00 2001 From: Claude Code via ted Date: Wed, 24 Jun 2026 14:25:55 +0800 Subject: [PATCH 06/12] extend profile authority to conversion TLS exits and count_tokens cache policy Result: {"status":"keep","profile_conflict_count":0,"profile_alignment_checks":3,"profile_workload_cases":7} --- .../gateway_forward_as_chat_completions.go | 2 +- .../service/gateway_forward_as_responses.go | 2 +- backend/internal/service/gateway_service.go | 16 ++++++---- .../profile_conflict_autoresearch_test.go | 30 ++++++++++++++++++- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index b6a1467a..b08b4d23 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -126,7 +126,7 @@ func (s *GatewayService) ForwardAsChatCompletions( } // 11. Send request - resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) + resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, tlsProfileForRequest(upstreamReq, s.tlsFPProfileService.ResolveTLSProfile(account))) if err != nil { if resp != nil && resp.Body != nil { _ = resp.Body.Close() diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 47ecf2b7..3458239c 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -125,7 +125,7 @@ func (s *GatewayService) ForwardAsResponses( } // 11. Send request - resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) + resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, tlsProfileForRequest(upstreamReq, s.tlsFPProfileService.ResolveTLSProfile(account))) if err != nil { if resp != nil && resp.Body != nil { _ = resp.Body.Close() diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 3a2e63b4..cf1ecc58 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -5647,7 +5647,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( input.Body = input.Parsed.Body.Bytes() } - resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) + resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, tlsProfileForRequest(upstreamReq, s.tlsFPProfileService.ResolveTLSProfile(account))) if err != nil { if resp != nil && resp.Body != nil { _ = resp.Body.Close() @@ -8721,12 +8721,12 @@ func (s *GatewayService) resolveCacheTTLUsageOverrideTarget(ctx context.Context, if account == nil { return "", false } - if account.IsCacheTTLOverrideEnabled() { - return account.GetCacheTTLOverrideTarget(), true - } if profile, ok := account.GetClaudeEnvironmentProfile(); ok && claudeEnvironmentProfileManagesCache(profile) { return cacheTTLTarget1h, true } + if account.IsCacheTTLOverrideEnabled() { + return account.GetCacheTTLOverrideTarget(), true + } if account.IsAnthropicOAuthOrSetupToken() && s != nil && s.settingService != nil && s.settingService.IsAnthropicCacheTTL1hInjectionEnabled(ctx) { return cacheTTLTarget5m, true } @@ -10003,7 +10003,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, } // 发送请求 - resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) + resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, tlsProfileForRequest(upstreamReq, s.tlsFPProfileService.ResolveTLSProfile(account))) if err != nil { releaseEnvironmentProfileLeaseFromRequest(upstreamReq) } else { @@ -10035,7 +10035,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, filteredBody := FilterThinkingBlocksForRetry(body, reqModel) retryReq, retryWireBody, buildErr := s.buildCountTokensRequest(ctx, c, account, filteredBody, token, tokenType, reqModel, shouldMimicClaudeCode) if buildErr == nil { - retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) + retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, tlsProfileForRequest(retryReq, s.tlsFPProfileService.ResolveTLSProfile(account))) if retryErr != nil { releaseEnvironmentProfileLeaseFromRequest(retryReq) } else { @@ -10380,6 +10380,10 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con tokenType, mimicClaudeCode, modelID, clientHeaders, body, ctEffectiveDropSet, ctClaudeEnvironmentProfile, ) + if next := rewriteCacheControlForClaudeEnvironmentProfile(ctClaudeEnvironmentProfile, body); len(next) > 0 { + body = next + } + // 能力维度 body sanitize:与最终 anthropic-beta header 对称 if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed { body = sanitized diff --git a/backend/internal/service/profile_conflict_autoresearch_test.go b/backend/internal/service/profile_conflict_autoresearch_test.go index a9f237d0..21956128 100644 --- a/backend/internal/service/profile_conflict_autoresearch_test.go +++ b/backend/internal/service/profile_conflict_autoresearch_test.go @@ -110,10 +110,36 @@ func TestAutoresearchProfileConflictWorkload(t *testing.T) { require.Equal(t, "1h", gjson.GetBytes(out, "messages.3.content.0.cache_control.ttl").String()) }) + t.Run("profile cache policy wins over account ttl override", func(t *testing.T) { + account := &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Extra: map[string]any{ + claudeEnvironmentProfileKey: profile, + "cache_ttl_override_enabled": true, + "cache_ttl_override_target": cacheTTLTarget5m, + }, + } + target, ok := (&GatewayService{}).resolveCacheTTLUsageOverrideTarget(context.Background(), account) + + require.True(t, ok) + require.Equal(t, cacheTTLTarget1h, target) + }) + + t.Run("profile cache policy applies before count_tokens sanitize", func(t *testing.T) { + body := []byte(`{"temperature":0.7,"messages":[{"role":"user","content":[{"type":"text","text":"first","cache_control":{"type":"ephemeral","ttl":"5m"}}]},{"role":"user","content":[{"type":"text","text":"last"}]}]}`) + + out := sanitizeCountTokensRequestBody(rewriteCacheControlForClaudeEnvironmentProfile(profile, body)) + + require.False(t, gjson.GetBytes(out, "temperature").Exists()) + require.False(t, gjson.GetBytes(out, "messages.0.content.0.cache_control").Exists()) + require.Equal(t, "1h", gjson.GetBytes(out, "messages.1.content.0.cache_control.ttl").String()) + }) + conflicts := countAutoresearchLegacyExitPoints(t) fmt.Printf("METRIC profile_conflict_count=%d\n", conflicts) fmt.Printf("METRIC profile_alignment_checks=%d\n", 3) - fmt.Printf("METRIC profile_workload_cases=%d\n", conflicts+5) + fmt.Printf("METRIC profile_workload_cases=%d\n", conflicts+7) } func fixedAutoresearchClaudeProfile() *ClaudeEnvironmentProfile { @@ -165,6 +191,8 @@ func countAutoresearchLegacyExitPoints(t *testing.T) int { {name: "message cache rewrite setting", file: "gateway_messages_cache.go", pattern: "rewriteMessageCacheControlIfEnabled", resolved: true}, {name: "cache ttl 1h injection setting", file: "gateway_service.go", pattern: "shouldInjectAnthropicCacheTTL1h", resolved: true}, {name: "legacy claude header profile", file: "claude_code_header_profile.go", pattern: "claude_code_header_profile", resolved: true}, + {name: "chat completions direct tls fallback", file: "gateway_forward_as_chat_completions.go", pattern: "Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)", resolved: false}, + {name: "responses direct tls fallback", file: "gateway_forward_as_responses.go", pattern: "Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)", resolved: false}, } count := 0 From d07a2304cdee47595ae277bea98204d73eec9753 Mon Sep 17 00:00:00 2001 From: Claude Code via ted Date: Wed, 24 Jun 2026 15:37:37 +0800 Subject: [PATCH 07/12] add admin endpoint to migrate legacy Claude profile pool to v2 in place Preserve existing per-OS device identity (client_id/device_id/session_seed) when upgrading a legacy auto_default pool to schema v2 so upstream fingerprints stay continuous. Reject already-v2 or missing pools, and clear the legacy single-profile key to avoid stale fallback after migration. --- .../internal/handler/admin/account_handler.go | 14 +++ .../handler/admin/admin_service_stub_test.go | 4 + backend/internal/server/routes/admin.go | 1 + backend/internal/service/admin_service.go | 38 ++++++++ .../claude_environment_profile_pool.go | 57 ++++++++++++ .../claude_environment_profile_pool_test.go | 88 +++++++++++++++++++ 6 files changed, 202 insertions(+) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 63de0e62..2e253775 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -774,6 +774,20 @@ func (h *AccountHandler) ResetClaudeEnvironmentProfile(c *gin.Context) { response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) } +func (h *AccountHandler) MigrateClaudeEnvironmentProfileToV2(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + account, err := h.adminService.MigrateClaudeEnvironmentProfileToV2(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) +} + func (h *AccountHandler) UpdateCodexEnvironmentProfileSettings(c *gin.Context) { accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 68b3f63e..c2971c84 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -660,6 +660,10 @@ func (s *stubAdminService) ResetClaudeEnvironmentProfile(ctx context.Context, id return &service.Account{ID: id, Platform: service.PlatformAnthropic, Type: service.AccountTypeOAuth, Extra: map[string]any{}}, nil } +func (s *stubAdminService) MigrateClaudeEnvironmentProfileToV2(ctx context.Context, id int64) (*service.Account, error) { + return &service.Account{ID: id, Platform: service.PlatformAnthropic, Type: service.AccountTypeOAuth, Extra: map[string]any{}}, nil +} + func (s *stubAdminService) UpdateCodexEnvironmentProfileSettings(ctx context.Context, id int64, updates map[string]any) (*service.Account, error) { return &service.Account{ID: id, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Extra: updates}, nil } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 2829b178..e9c26d16 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -308,6 +308,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.PUT("/:id/claude-environment-profile", h.Admin.Account.UpdateClaudeEnvironmentProfile) accounts.PUT("/:id/claude-environment-profile/slot", h.Admin.Account.UpdateClaudeEnvironmentProfileSlot) accounts.POST("/:id/claude-environment-profile/reset", h.Admin.Account.ResetClaudeEnvironmentProfile) + accounts.POST("/:id/claude-environment-profile/migrate-v2", h.Admin.Account.MigrateClaudeEnvironmentProfileToV2) accounts.PUT("/:id/codex-environment-profile/settings", h.Admin.Account.UpdateCodexEnvironmentProfileSettings) accounts.PUT("/:id/codex-environment-profile", h.Admin.Account.UpdateCodexEnvironmentProfile) accounts.PUT("/:id/codex-environment-profile/slot", h.Admin.Account.UpdateCodexEnvironmentProfileSlot) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 6ef36df5..929c293c 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -89,6 +89,7 @@ type AdminService interface { UpdateClaudeEnvironmentProfile(ctx context.Context, id int64, profile *ClaudeEnvironmentProfile) (*Account, error) UpdateClaudeEnvironmentProfileSlot(ctx context.Context, id int64, slot EnvironmentClass, overrides *ClaudeEnvironmentProfile) (*Account, error) ResetClaudeEnvironmentProfile(ctx context.Context, id int64) (*Account, error) + MigrateClaudeEnvironmentProfileToV2(ctx context.Context, id int64) (*Account, error) UpdateCodexEnvironmentProfileSettings(ctx context.Context, id int64, updates map[string]any) (*Account, error) UpdateCodexEnvironmentProfile(ctx context.Context, id int64, profile *CodexEnvironmentProfile) (*Account, error) UpdateCodexEnvironmentProfileSlot(ctx context.Context, id int64, slot EnvironmentClass, overrides *CodexEnvironmentProfile) (*Account, error) @@ -3051,6 +3052,43 @@ func (s *adminServiceImpl) ResetClaudeEnvironmentProfile(ctx context.Context, id return s.accountRepo.GetByID(ctx, id) } +// MigrateClaudeEnvironmentProfileToV2 将账号的 legacy 环境 profile pool 原地升级为 schema v2, +// 保留已有设备身份(client_id/device_id/session_seed)以维持上游指纹连续。已是 v2 则报错。 +func (s *adminServiceImpl) MigrateClaudeEnvironmentProfileToV2(ctx context.Context, id int64) (*Account, error) { + account, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if account == nil || !account.IsAnthropicOAuthOrSetupToken() { + return nil, errors.New("claude environment profile is only supported for Anthropic OAuth/SetupToken accounts") + } + pool, err := DecodeClaudeEnvironmentProfilePool(account.Extra[claudeEnvironmentProfilePoolKey]) + if err != nil { + return nil, err + } + if pool == nil { + return nil, errors.New("claude environment profile pool not found; nothing to migrate") + } + if pool.IsV2() { + return nil, errors.New("claude environment profile pool is already v2") + } + upgraded := upgradeLegacyClaudePoolToV2(pool, claude.CLICurrentVersion) + if err := upgraded.Normalize(); err != nil { + return nil, err + } + // 清理可能存在的遗留单 profile 键,避免迁移后 legacy fallback 误触发。 + if deleter, ok := s.accountRepo.(accountExtraKeyDeleter); ok { + if err := deleter.DeleteExtraKeys(ctx, id, []string{claudeEnvironmentProfileKey}); err != nil { + return nil, err + } + } + if err := s.accountRepo.UpdateExtra(ctx, id, map[string]any{claudeEnvironmentProfilePoolKey: upgraded}); err != nil { + return nil, err + } + slog.Info("claude_environment_profile_pool_migrated_v2", "account_id", id) + return s.accountRepo.GetByID(ctx, id) +} + func (s *adminServiceImpl) UpdateCodexEnvironmentProfileSettings(ctx context.Context, id int64, updates map[string]any) (*Account, error) { account, err := s.accountRepo.GetByID(ctx, id) if err != nil { diff --git a/backend/internal/service/claude_environment_profile_pool.go b/backend/internal/service/claude_environment_profile_pool.go index ea16c3a7..85ce0c34 100644 --- a/backend/internal/service/claude_environment_profile_pool.go +++ b/backend/internal/service/claude_environment_profile_pool.go @@ -316,6 +316,63 @@ func newFrozenClaudeEnvironmentProfilePool(cliVersion string) *ClaudeEnvironment } } +// upgradeLegacyClaudePoolToV2 将 legacy(auto_default_pool 等非 v2)pool 原地升级为 schema v2 +// 三 OS 槽位冻结 pool,并按 OS 复用 legacy 中已有的设备身份(client_id/device_id/session_seed), +// 以维持上游指纹连续。legacy 中无对应 OS 身份的槽位保留模板新生成的身份。不修改入参 legacy。 +func upgradeLegacyClaudePoolToV2(legacy *ClaudeEnvironmentProfilePool, cliVersion string) *ClaudeEnvironmentProfilePool { + type preservedIdentity struct { + clientID string + deviceID string + sessionSeed string + } + // 按 OS 收集 legacy 身份(routeToSlot 归一到 windows/macos/linux,首个命中为准)。 + identities := make(map[EnvironmentClass]preservedIdentity) + if legacy != nil { + for i := range legacy.Slots { + profile := legacy.Slots[i].Profile + if profile == nil { + continue + } + clientID := strings.TrimSpace(profile.ClientID) + deviceID := strings.TrimSpace(profile.DeviceID) + sessionSeed := strings.TrimSpace(profile.SessionSeed) + if clientID == "" || deviceID == "" || sessionSeed == "" { + continue + } + os := routeToSlot(legacy.Slots[i].Environment) + if _, exists := identities[os]; exists { + continue + } + identities[os] = preservedIdentity{clientID: clientID, deviceID: deviceID, sessionSeed: sessionSeed} + } + } + + now := nowForEnvironmentProfilePool() + slots := make([]ClaudeEnvironmentProfileSlot, len(fixedClaudeEnvironmentSlotClasses)) + for i, env := range fixedClaudeEnvironmentSlotClasses { + profile := buildFrozenClaudeEnvironmentProfileForSlot(env, cliVersion) + if id, ok := identities[env]; ok { + profile.ClientID = id.clientID + profile.DeviceID = id.deviceID + profile.SessionSeed = id.sessionSeed + } + slots[i] = ClaudeEnvironmentProfileSlot{ + Slot: i, + Environment: env, + State: EnvironmentProfileSlotBound, + Profile: profile, + CreatedAt: now, + UpdatedAt: now, + } + } + return &ClaudeEnvironmentProfilePool{ + Schema: claudeEnvironmentProfilePoolSchemaV2, + Version: 2, + Capacity: len(slots), + Slots: slots, + } +} + // betaSetForCLIVersion 返回指定 CLI 版本对应的自洽 anthropic-beta 集合。 // 当前对齐 FullClaudeCodeMimicryBetas;版本维度差异留待后续按版本细化。 func betaSetForCLIVersion(cliVersion string) []string { diff --git a/backend/internal/service/claude_environment_profile_pool_test.go b/backend/internal/service/claude_environment_profile_pool_test.go index 61d48a0e..a6f9190a 100644 --- a/backend/internal/service/claude_environment_profile_pool_test.go +++ b/backend/internal/service/claude_environment_profile_pool_test.go @@ -179,3 +179,91 @@ func TestAcquireV2SlotConcurrentReuseSameSlot(t *testing.T) { // v2 不占用 lease manager 的串行锁 require.Equal(t, 0, svc.claudeEnvironmentProfileSlotLeases.activeCount()) } + +func TestUpgradeLegacyClaudePoolToV2PreservesIdentity(t *testing.T) { + // 构造 legacy pool:3 windows + 4 linux bound 槽位,各带模拟身份;3 个空槽。 + winID := claudeProfilePoolTestIdentity("win") + linID := claudeProfilePoolTestIdentity("linux") + legacy := &ClaudeEnvironmentProfilePool{ + Version: 1, + Capacity: 10, + Slots: []ClaudeEnvironmentProfileSlot{ + {Slot: 0, Environment: EnvironmentClassWindows, State: EnvironmentProfileSlotBound, Profile: winID}, + {Slot: 1, Environment: EnvironmentClassWindows, State: EnvironmentProfileSlotBound, Profile: claudeProfilePoolTestIdentity("win2")}, + {Slot: 2, Environment: EnvironmentClassWindows, State: EnvironmentProfileSlotBound, Profile: claudeProfilePoolTestIdentity("win3")}, + {Slot: 3, Environment: EnvironmentClassLinux, State: EnvironmentProfileSlotBound, Profile: linID}, + {Slot: 4, Environment: EnvironmentClassLinux, State: EnvironmentProfileSlotBound, Profile: claudeProfilePoolTestIdentity("linux2")}, + {Slot: 7, Environment: EnvironmentClassWindows, State: EnvironmentProfileSlotEmpty}, + }, + } + + upgraded := upgradeLegacyClaudePoolToV2(legacy, "2.1.161") + require.NotNil(t, upgraded) + require.NoError(t, upgraded.Normalize()) + + // 结构对齐 v2:schema/version/capacity/3 槽 + require.True(t, upgraded.IsV2()) + require.Equal(t, "v2", upgraded.Schema) + require.Equal(t, 2, upgraded.Version) + require.Equal(t, 3, upgraded.Capacity) + require.Len(t, upgraded.Slots, 3) + require.Equal(t, EnvironmentClassWindows, upgraded.Slots[0].Environment) + require.Equal(t, EnvironmentClassMacOS, upgraded.Slots[1].Environment) + require.Equal(t, EnvironmentClassLinux, upgraded.Slots[2].Environment) + + // 每槽均为 v2 冻结,且继承 tls/cache 模板字段 + for i, slot := range upgraded.Slots { + require.NotNil(t, slot.Profile, "slot %d", i) + require.True(t, isV2ClaudeEnvironmentProfile(slot.Profile), "slot %d isV2", i) + require.Equal(t, "claude-cli-default", slot.Profile.TLSProfile, "slot %d tls", i) + require.Equal(t, claudeEnvironmentCachePolicyPreserveClient, slot.Profile.CachePolicy, "slot %d cache", i) + } + + // windows 槽复用首个 windows 身份;linux 槽复用首个 linux 身份(指纹连续) + require.Equal(t, winID.ClientID, upgraded.Slots[0].Profile.ClientID) + require.Equal(t, winID.DeviceID, upgraded.Slots[0].Profile.DeviceID) + require.Equal(t, winID.SessionSeed, upgraded.Slots[0].Profile.SessionSeed) + require.Equal(t, linID.ClientID, upgraded.Slots[2].Profile.ClientID) + require.Equal(t, linID.DeviceID, upgraded.Slots[2].Profile.DeviceID) + require.Equal(t, linID.SessionSeed, upgraded.Slots[2].Profile.SessionSeed) + + // macos 无 legacy 身份 → 新生成,非空且不等于其它槽 + require.NotEmpty(t, upgraded.Slots[1].Profile.ClientID) + require.NotEmpty(t, upgraded.Slots[1].Profile.DeviceID) + require.NotEqual(t, winID.DeviceID, upgraded.Slots[1].Profile.DeviceID) + require.NotEqual(t, linID.DeviceID, upgraded.Slots[1].Profile.DeviceID) + + // 不修改入参 legacy + require.Equal(t, winID.DeviceID, legacy.Slots[0].Profile.DeviceID) + require.False(t, legacy.IsV2()) +} + +func TestUpgradeLegacyClaudePoolToV2NoIdentitiesAllFresh(t *testing.T) { + // legacy 全空槽 → 升级后三槽均为模板新身份,仍是合法 v2 pool。 + legacy := &ClaudeEnvironmentProfilePool{ + Version: 1, + Capacity: 3, + Slots: []ClaudeEnvironmentProfileSlot{ + {Slot: 0, Environment: EnvironmentClassWindows, State: EnvironmentProfileSlotEmpty}, + {Slot: 1, Environment: EnvironmentClassLinux, State: EnvironmentProfileSlotEmpty}, + }, + } + upgraded := upgradeLegacyClaudePoolToV2(legacy, "2.1.161") + require.NoError(t, upgraded.Normalize()) + require.True(t, upgraded.IsV2()) + require.Len(t, upgraded.Slots, 3) + for i, slot := range upgraded.Slots { + require.True(t, isV2ClaudeEnvironmentProfile(slot.Profile), "slot %d", i) + require.NotEmpty(t, slot.Profile.DeviceID) + } +} + +// claudeProfilePoolTestIdentity 生成带固定 client_id/device_id/session_seed 的最小 legacy profile。 +func claudeProfilePoolTestIdentity(tag string) *ClaudeEnvironmentProfile { + return &ClaudeEnvironmentProfile{ + Source: claudeEnvironmentProfileSourceAutoDefault, + ClientID: "client-" + tag, + DeviceID: "device-" + tag, + SessionSeed: "seed-" + tag, + } +} From 6768274fe3a88971c36ee88cdb2dcbc9f10243f7 Mon Sep 17 00:00:00 2001 From: Claude Code via ted Date: Wed, 24 Jun 2026 20:18:22 +0800 Subject: [PATCH 08/12] fix(ua-auto-fetch): make codex/claude CLI version daily refresh actually work The VersionFetcherService never refreshed codex/claude CLI versions into profiles due to three compounding defects: ua_auto_fetch defaulted to disabled (Start returned immediately), the first fetch waited a full interval after boot, and registry.Swap only updated an in-memory atomic.Pointer with no persistence (lost on restart, never written to profile/DB). - Default gateway.ua_auto_fetch.enabled to true (config + deploy example) - Bootstrap from DB on Start: load persisted versions into the registry so profiles use the last fetched version immediately on boot - Fetch once immediately on Start before entering the ticker loop - Split all-or-nothing: claude and codex fetch/persist independently so one side failing no longer discards the other's fresh version - Persist fetched versions to the setting table (claude_cli_version JSON {cli,sdk}, codex_cli_version) and reload them on boot; falls back to hardcoded defaults when DB is empty - Wire SettingRepository into NewVersionFetcherService - Tests: update constructor signature, partial-success semantics, and add persistence / bootstrap coverage --- backend/cmd/server/wire_gen.go | 2 +- backend/internal/config/config.go | 4 +- backend/internal/service/domain_constants.go | 5 + .../service/version_fetcher_service.go | 175 +++++++++++++--- .../service/version_fetcher_service_test.go | 186 +++++++++++++++++- deploy/config.example.yaml | 6 +- 6 files changed, 343 insertions(+), 35 deletions(-) diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 4f7faaec..09f19319 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -208,7 +208,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { registry := payment.ProvideRegistry() defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey) paymentService := service.ProvidePaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService, notificationEmailService) - versionFetcherService := service.NewVersionFetcherService(identityRegistry, configConfig) + versionFetcherService := service.NewVersionFetcherService(identityRegistry, configConfig, settingRepository) settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService) opsHandler := admin.NewOpsHandler(opsService) updateCache := repository.NewUpdateCache(redisClient) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 196ff09f..33361000 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -726,7 +726,7 @@ type GatewayConfig struct { OpenAIHTTP2 GatewayOpenAIHTTP2Config `mapstructure:"openai_http2"` // ImageConcurrency: 图片生成独立并发限制配置(默认关闭) ImageConcurrency ImageConcurrencyConfig `mapstructure:"image_concurrency"` - // UAAutoFetch: Claude/Codex 客户端身份版本后台拉取配置(默认关闭) + // UAAutoFetch: Claude/Codex 客户端身份版本后台拉取配置(默认开启,拉取结果持久化到 setting 表,重启不丢) UAAutoFetch UAAutoFetchConfig `mapstructure:"ua_auto_fetch"` // HTTP 上游连接池配置(性能优化:支持高并发场景调优) @@ -1889,7 +1889,7 @@ func setDefaults() { viper.SetDefault("gateway.image_concurrency.overflow_mode", ImageConcurrencyOverflowModeReject) viper.SetDefault("gateway.image_concurrency.wait_timeout_seconds", 30) viper.SetDefault("gateway.image_concurrency.max_waiting_requests", 100) - viper.SetDefault("gateway.ua_auto_fetch.enabled", false) + viper.SetDefault("gateway.ua_auto_fetch.enabled", true) viper.SetDefault("gateway.ua_auto_fetch.interval", time.Hour) viper.SetDefault("gateway.antigravity_fallback_cooldown_minutes", 1) viper.SetDefault("gateway.antigravity_extra_retries", 10) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 9595615e..d97f3ec5 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -410,6 +410,11 @@ const ( // SettingKeyMaxClaudeCodeVersion 最高 Claude Code 版本号限制 (semver, 如 "3.0.0",空值=不检查) SettingKeyMaxClaudeCodeVersion = "max_claude_code_version" + // UA 自动拉取持久化的 CLI 版本(VersionFetcherService 写入,启动时回灌 registry)。 + // claude 值为 JSON {"cli":"x","sdk":"y"};codex 值为版本字符串。 + SettingKeyClaudeCLIVersion = "claude_cli_version" + SettingKeyCodexCLIVersion = "codex_cli_version" + // SettingKeyAllowUngroupedKeyScheduling 允许未分组 API Key 调度(默认 false:未分组 Key 返回 403) SettingKeyAllowUngroupedKeyScheduling = "allow_ungrouped_key_scheduling" diff --git a/backend/internal/service/version_fetcher_service.go b/backend/internal/service/version_fetcher_service.go index 30169828..e9bb36ed 100644 --- a/backend/internal/service/version_fetcher_service.go +++ b/backend/internal/service/version_fetcher_service.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "net/http" @@ -23,23 +24,25 @@ const ( ) type VersionFetcherService struct { - registry *clientidentity.Registry - cfg *config.Config - client *http.Client - npmURL string - codexURL string - stopCh chan struct{} - stopOnce sync.Once + registry *clientidentity.Registry + cfg *config.Config + settingRepo SettingRepository + client *http.Client + npmURL string + codexURL string + stopCh chan struct{} + stopOnce sync.Once } -func NewVersionFetcherService(registry *clientidentity.Registry, cfg *config.Config) *VersionFetcherService { +func NewVersionFetcherService(registry *clientidentity.Registry, cfg *config.Config, settingRepo SettingRepository) *VersionFetcherService { return &VersionFetcherService{ - registry: registry, - cfg: cfg, - client: http.DefaultClient, - npmURL: defaultNPMRegistryBaseURL, - codexURL: defaultCodexReleaseURL, - stopCh: make(chan struct{}), + registry: registry, + cfg: cfg, + settingRepo: settingRepo, + client: http.DefaultClient, + npmURL: defaultNPMRegistryBaseURL, + codexURL: defaultCodexReleaseURL, + stopCh: make(chan struct{}), } } @@ -49,12 +52,18 @@ func (s *VersionFetcherService) Start() { return } + // 启动即用上次持久化的版本回灌 registry,避免进程重启后退回硬编码默认值、 + // 也不必等满一个 interval 才首次拉取。 + s.bootstrapFromDB() + interval := s.cfg.Gateway.UAAutoFetch.Interval if interval == 0 { interval = defaultVersionFetchInterval } go func() { + // 启动后立即拉取一次,刷新为最新版本;随后按 ticker 周期更新。 + s.fetchAndUpdate() ticker := time.NewTicker(interval) defer ticker.Stop() @@ -87,12 +96,22 @@ func (s *VersionFetcherService) fetchAndUpdate() { defer cancel() claudeVersion, claudeSDKVersion, codexVersion := s.fetchVersions(ctx) - if claudeVersion == "" || claudeSDKVersion == "" || codexVersion == "" { + + // 拆分“全有或全无”:claude 与 codex 各自成功各自合并进当前快照并持久化, + // 避免一侧失败导致另一侧的新版本也被丢弃。 + if claudeVersion == "" && codexVersion == "" { return } - s.registry.Swap(&clientidentity.Snapshots{ - Claude: clientidentity.ClaudeSnapshot{ + current := s.registry.Get() + claude := current.Claude + codex := current.Codex + + if claudeVersion != "" { + if claudeSDKVersion == "" { + claudeSDKVersion = claude.VersionFields.SDKVersion + } + claude = clientidentity.ClaudeSnapshot{ Headers: s.buildClaudeHeaders(claudeVersion, claudeSDKVersion), VersionFields: clientidentity.VersionFields{ CLIVersion: claudeVersion, @@ -100,15 +119,117 @@ func (s *VersionFetcherService) fetchAndUpdate() { CCVersion: claudeVersion, }, TLSProfileName: clientidentity.TLSProfileClaudeCLIDefault, - }, - Codex: clientidentity.CodexSnapshot{ + } + } + if codexVersion != "" { + codex = clientidentity.CodexSnapshot{ Headers: s.buildCodexHeaders(codexVersion), VersionFields: clientidentity.VersionFields{ CLIVersion: codexVersion, }, TLSProfileName: clientidentity.TLSProfileCodexCLIDefault, - }, - }) + } + } + + s.registry.Swap(&clientidentity.Snapshots{Claude: claude, Codex: codex}) + s.persistVersions(ctx, claudeVersion, claudeSDKVersion, codexVersion) +} + +// bootstrapFromDB 在启动时把 DB 里持久化的版本回灌 registry,使进程一启动即用 +// 上次拉取到的版本,无需等待首次 ticker 触发。任一字段缺失则保留硬编码默认。 +func (s *VersionFetcherService) bootstrapFromDB() { + if s == nil || s.registry == nil || s.settingRepo == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), versionFetchTimeout) + defer cancel() + + claudeVersion, claudeSDKVersion, codexVersion := s.loadPersistedVersions(ctx) + if claudeVersion == "" && codexVersion == "" { + return + } + + current := s.registry.Get() + claude := current.Claude + codex := current.Codex + + if claudeVersion != "" { + if claudeSDKVersion == "" { + claudeSDKVersion = claude.VersionFields.SDKVersion + } + claude = clientidentity.ClaudeSnapshot{ + Headers: s.buildClaudeHeaders(claudeVersion, claudeSDKVersion), + VersionFields: clientidentity.VersionFields{ + CLIVersion: claudeVersion, + SDKVersion: claudeSDKVersion, + CCVersion: claudeVersion, + }, + TLSProfileName: clientidentity.TLSProfileClaudeCLIDefault, + } + } + if codexVersion != "" { + codex = clientidentity.CodexSnapshot{ + Headers: s.buildCodexHeaders(codexVersion), + VersionFields: clientidentity.VersionFields{ + CLIVersion: codexVersion, + }, + TLSProfileName: clientidentity.TLSProfileCodexCLIDefault, + } + } + + s.registry.Swap(&clientidentity.Snapshots{Claude: claude, Codex: codex}) + slog.Info("version_fetcher_bootstrap_from_db", "claude", claudeVersion, "claude_sdk", claudeSDKVersion, "codex", codexVersion) +} + +// persistVersions 把本次拉取到的版本写入 setting 表,空值跳过对应 key。 +// 写入失败仅告警,不影响内存快照。 +func (s *VersionFetcherService) persistVersions(ctx context.Context, claudeVer, claudeSDKVer, codexVer string) { + if s == nil || s.settingRepo == nil { + return + } + if claudeVer != "" { + payload, err := json.Marshal(struct { + CLI string `json:"cli"` + SDK string `json:"sdk"` + }{CLI: claudeVer, SDK: claudeSDKVer}) + if err == nil { + if err := s.settingRepo.Set(ctx, SettingKeyClaudeCLIVersion, string(payload)); err != nil { + slog.Warn("version_fetcher_persist_claude_failed", "error", err) + } + } + } + if codexVer != "" { + if err := s.settingRepo.Set(ctx, SettingKeyCodexCLIVersion, codexVer); err != nil { + slog.Warn("version_fetcher_persist_codex_failed", "error", err) + } + } +} + +// loadPersistedVersions 从 setting 表读取持久化版本。缺失(ErrSettingNotFound)视为空值。 +func (s *VersionFetcherService) loadPersistedVersions(ctx context.Context) (claudeVer, claudeSDKVer, codexVer string) { + if s == nil || s.settingRepo == nil { + return "", "", "" + } + + if raw, err := s.settingRepo.GetValue(ctx, SettingKeyClaudeCLIVersion); err == nil { + var parsed struct { + CLI string `json:"cli"` + SDK string `json:"sdk"` + } + if json.Unmarshal([]byte(raw), &parsed) == nil { + claudeVer = strings.TrimSpace(parsed.CLI) + claudeSDKVer = strings.TrimSpace(parsed.SDK) + } + } else if !errors.Is(err, ErrSettingNotFound) { + slog.Warn("version_fetcher_load_claude_failed", "error", err) + } + + if raw, err := s.settingRepo.GetValue(ctx, SettingKeyCodexCLIVersion); err == nil { + codexVer = strings.TrimSpace(raw) + } else if !errors.Is(err, ErrSettingNotFound) { + slog.Warn("version_fetcher_load_codex_failed", "error", err) + } + return claudeVer, claudeSDKVer, codexVer } func (s *VersionFetcherService) fetchVersions(ctx context.Context) (claudeVersion, claudeSDKVersion, codexVersion string) { @@ -130,9 +251,15 @@ func (s *VersionFetcherService) fetchVersions(ctx context.Context) (claudeVersio wg.Wait() - if claudeErr != nil || codexErr != nil { - slog.Warn("version_fetcher_discard_update", "claude_error", claudeErr, "codex_error", codexErr) - return "", "", "" + // 拆分后的语义:各自返回各自拉到的版本,失败侧返回空串。 + // 仅在两侧都失败时记录一次诊断日志,调用方据空串决定是否跳过 Swap/持久化。 + if claudeErr != nil { + slog.Warn("version_fetcher_claude_failed", "error", claudeErr) + claudeVer, claudeSDKVer = "", "" + } + if codexErr != nil { + slog.Warn("version_fetcher_codex_failed", "error", codexErr) + codexVer = "" } return claudeVer, claudeSDKVer, codexVer diff --git a/backend/internal/service/version_fetcher_service_test.go b/backend/internal/service/version_fetcher_service_test.go index e9aa77e6..192538f0 100644 --- a/backend/internal/service/version_fetcher_service_test.go +++ b/backend/internal/service/version_fetcher_service_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "sync" "testing" "time" @@ -21,7 +22,7 @@ func TestVersionFetcherDisabledDoesNotStart(t *testing.T) { } registry := clientidentity.NewRegistry() initial := registry.Get() - svc := NewVersionFetcherService(registry, cfg) + svc := NewVersionFetcherService(registry, cfg, nil) svc.Start() time.Sleep(50 * time.Millisecond) @@ -209,7 +210,8 @@ func TestFetchClaudeVersionsFallsBackToSDKLatest(t *testing.T) { assert.Equal(t, "0.62.0", sdkVersion) } -func TestFetchVersionsDiscardsWhenAnySourceFails(t *testing.T) { +func TestFetchVersionsKeepsPartialSuccess(t *testing.T) { + // 拆分“全有或全无”后:claude 拉取成功、codex 失败时,claude 侧仍应返回版本,codex 侧为空。 svc, server := newVersionFetcherTestService(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/@anthropic-ai/claude-code": @@ -225,8 +227,8 @@ func TestFetchVersionsDiscardsWhenAnySourceFails(t *testing.T) { defer server.Close() claudeVersion, claudeSDKVersion, codexVersion := svc.fetchVersions(context.Background()) - assert.Empty(t, claudeVersion) - assert.Empty(t, claudeSDKVersion) + assert.Equal(t, "2.1.161", claudeVersion) + assert.Equal(t, "0.62.0", claudeSDKVersion) assert.Empty(t, codexVersion) } @@ -257,7 +259,181 @@ func newVersionFetcherTestService(t *testing.T, handler http.HandlerFunc) (*Vers t.Helper() server := httptest.NewServer(handler) - svc := NewVersionFetcherService(clientidentity.NewRegistry(), &config.Config{}) + svc := NewVersionFetcherService(clientidentity.NewRegistry(), &config.Config{}, nil) + svc.client = server.Client() + svc.npmURL = server.URL + svc.codexURL = server.URL + "/repos/openai/codex/releases/latest" + return svc, server +} + +// memorySettingRepo 是 SettingRepository 的内存实现,用于测试持久化与启动加载。 +type memorySettingRepo struct { + mu sync.Mutex + data map[string]string +} + +func newMemorySettingRepo() *memorySettingRepo { + return &memorySettingRepo{data: map[string]string{}} +} + +func (m *memorySettingRepo) Get(ctx context.Context, key string) (*Setting, error) { + m.mu.Lock() + defer m.mu.Unlock() + v, ok := m.data[key] + if !ok { + return nil, ErrSettingNotFound + } + return &Setting{Key: key, Value: v}, nil +} + +func (m *memorySettingRepo) GetValue(ctx context.Context, key string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + v, ok := m.data[key] + if !ok { + return "", ErrSettingNotFound + } + return v, nil +} + +func (m *memorySettingRepo) Set(ctx context.Context, key, value string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.data[key] = value + return nil +} + +func (m *memorySettingRepo) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := map[string]string{} + for _, k := range keys { + if v, ok := m.data[k]; ok { + out[k] = v + } + } + return out, nil +} + +func (m *memorySettingRepo) SetMultiple(ctx context.Context, settings map[string]string) error { + m.mu.Lock() + defer m.mu.Unlock() + for k, v := range settings { + m.data[k] = v + } + return nil +} + +func (m *memorySettingRepo) GetAll(ctx context.Context) (map[string]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := map[string]string{} + for k, v := range m.data { + out[k] = v + } + return out, nil +} + +func (m *memorySettingRepo) Delete(ctx context.Context, key string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.data, key) + return nil +} + +func TestFetchAndUpdatePersistsVersionsAndPartialSuccess(t *testing.T) { + repo := newMemorySettingRepo() + registry := clientidentity.NewRegistry() + svc := NewVersionFetcherService(registry, &config.Config{}, repo) + svc, server := withTestHTTP(svc, t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/@anthropic-ai/claude-code": + _, _ = w.Write([]byte(`{"dist-tags":{"latest":"2.2.0"}}`)) + case "/@anthropic-ai/claude-code/2.2.0": + _, _ = w.Write([]byte(`{"dependencies":{"@anthropic-ai/sdk":"^0.95.0"}}`)) + case "/repos/openai/codex/releases/latest": + _, _ = w.Write([]byte(`{"tag_name":"v0.130.0","prerelease":false}`)) + default: + http.NotFound(w, r) + } + }) + defer server.Close() + + svc.fetchAndUpdate() + + snapshot := registry.Get() + assert.Equal(t, "2.2.0", snapshot.Claude.VersionFields.CLIVersion) + assert.Equal(t, "0.95.0", snapshot.Claude.VersionFields.SDKVersion) + assert.Equal(t, "0.130.0", snapshot.Codex.VersionFields.CLIVersion) + + // 持久化写入 setting 表。 + claudeRaw, err := repo.GetValue(context.Background(), SettingKeyClaudeCLIVersion) + require.NoError(t, err) + assert.Contains(t, claudeRaw, "2.2.0") + assert.Contains(t, claudeRaw, "0.95.0") + codexRaw, err := repo.GetValue(context.Background(), SettingKeyCodexCLIVersion) + require.NoError(t, err) + assert.Equal(t, "0.130.0", codexRaw) +} + +func TestFetchAndUpdatePersistsCodexOnlyWhenClaudeFails(t *testing.T) { + repo := newMemorySettingRepo() + registry := clientidentity.NewRegistry() + initialClaude := registry.Get().Claude + svc := NewVersionFetcherService(registry, &config.Config{}, repo) + svc, server := withTestHTTP(svc, t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/@anthropic-ai/claude-code": + http.Error(w, "npm outage", http.StatusBadGateway) + case "/repos/openai/codex/releases/latest": + _, _ = w.Write([]byte(`{"tag_name":"v0.130.0","prerelease":false}`)) + default: + http.NotFound(w, r) + } + }) + defer server.Close() + + svc.fetchAndUpdate() + + snapshot := registry.Get() + // claude 侧保留默认快照未被覆盖。 + assert.Equal(t, initialClaude.VersionFields.CLIVersion, snapshot.Claude.VersionFields.CLIVersion) + // codex 侧成功更新并持久化。 + assert.Equal(t, "0.130.0", snapshot.Codex.VersionFields.CLIVersion) + _, err := repo.GetValue(context.Background(), SettingKeyCodexCLIVersion) + require.NoError(t, err) + _, err = repo.GetValue(context.Background(), SettingKeyClaudeCLIVersion) + assert.ErrorIs(t, err, ErrSettingNotFound) +} + +func TestBootstrapFromDBRestoresPersistedVersions(t *testing.T) { + repo := newMemorySettingRepo() + repo.Set(context.Background(), SettingKeyClaudeCLIVersion, `{"cli":"2.3.0","sdk":"0.96.0"}`) + repo.Set(context.Background(), SettingKeyCodexCLIVersion, "0.140.0") + + registry := clientidentity.NewRegistry() + svc := NewVersionFetcherService(registry, &config.Config{}, repo) + svc.bootstrapFromDB() + + snapshot := registry.Get() + assert.Equal(t, "2.3.0", snapshot.Claude.VersionFields.CLIVersion) + assert.Equal(t, "0.96.0", snapshot.Claude.VersionFields.SDKVersion) + assert.Equal(t, "0.140.0", snapshot.Codex.VersionFields.CLIVersion) +} + +func TestBootstrapFromDBNoOpWhenEmpty(t *testing.T) { + repo := newMemorySettingRepo() + registry := clientidentity.NewRegistry() + initial := registry.Get() + svc := NewVersionFetcherService(registry, &config.Config{}, repo) + svc.bootstrapFromDB() + assert.Same(t, initial, registry.Get()) +} + +// withTestHTTP 给已有 svc 装上指向测试 server 的 HTTP 客户端与 URL,返回 svc 与 server。 +func withTestHTTP(svc *VersionFetcherService, t *testing.T, handler http.HandlerFunc) (*VersionFetcherService, *httptest.Server) { + t.Helper() + server := httptest.NewServer(handler) svc.client = server.Client() svc.npmURL = server.URL svc.codexURL = server.URL + "/repos/openai/codex/releases/latest" diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 30c69735..6937f8ef 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -391,10 +391,10 @@ gateway: # Max image requests waiting in this process when overflow_mode=wait, 0=unlimited # wait 模式当前进程允许排队等待的图片请求数,0=不限制 max_waiting_requests: 100 - # Claude/Codex UA auto fetcher. Default false keeps built-in hardcoded identity. - # Claude/Codex UA 自动拉取。默认 false,保持内置硬编码身份。 + # Claude/Codex UA auto fetcher. Default true keeps CLI identity versions fresh. + # Claude/Codex UA 自动拉取。默认 true,启动即拉取并持久化到 setting 表,重启不丢;离线时回退内置硬编码身份。 ua_auto_fetch: - enabled: false + enabled: true interval: 1h # SSE max line size in bytes (default: 40MB) # SSE 单行最大字节数(默认 40MB) From 32a46e33863f53dffdb623fc6948c9a7ff881581 Mon Sep 17 00:00:00 2001 From: Claude Code via ted Date: Wed, 24 Jun 2026 22:12:56 +0800 Subject: [PATCH 09/12] fix(migrations): accept pre-rename checksums for Sub2API->claude2api cleanup The repository-presentation cleanup (9afaa312) rewrote the first comment line of several migration files from "Sub2API" to "claude2api". This changed their trimmed-content checksums while leaving DDL and resulting schema identical. Existing deployments recorded the pre-cleanup checksums in schema_migrations, so rebuilding the image from source fails startup with a checksum mismatch. Add compatibility rules for the four affected, already-applied files (001_init, 002_account_type_migration, 003_subscription, 052_migrate_upstream_to_apikey) so the new checksum is accepted against the legacy DB checksum without modifying migrations or the database. 051 is unaffected (its content trims to empty, identical checksum). --- backend/internal/repository/migrations_runner.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/internal/repository/migrations_runner.go b/backend/internal/repository/migrations_runner.go index 7a649687..ef2490fd 100644 --- a/backend/internal/repository/migrations_runner.go +++ b/backend/internal/repository/migrations_runner.go @@ -77,6 +77,12 @@ var migrationChecksumCompatibilityRules = map[string]migrationChecksumCompatibil "119_enforce_payment_orders_out_trade_no_unique.sql": newMigrationChecksumCompatibilityRule("0bbe809ae48a9d811dabda1ba1c74955bd71c4a9cc610f9128816818dfa6c11e", "ebd2c67cce0116393fb4f1b5d5116a67c6aceb73820dfb5133d1ff6f36d72d34"), "120_enforce_payment_orders_out_trade_no_unique_notx.sql": newMigrationChecksumCompatibilityRule("34aadc0db59a4e390f92a12b73bd74642d9724f33124f73638ae00089ea5e074", "e77921f79d539bc24575cb9c16cbe566d2b23ce816190343d0a7568f6a3fcf61", "707431450603e70a43ce9fbd61e0c12fa67da4875158ccefabacea069587ab22", "04b082b5a239c525154fe9185d324ee2b05ff90da9297e10dba19f9be79aa59a"), "123_fix_legacy_auth_source_grant_on_signup_defaults.sql": newMigrationChecksumCompatibilityRule("2ce43c2cd89e9f9e1febd34a407ed9e84d177386c5544b6f02c1f58a21129f57", "6cd33422f215dcd1f486ab6f35c0ea5805d9ca69bb25906d94bc649156657145"), + // 仓库命名清理(Sub2API -> claude2api)仅改动迁移文件首行/注释文字,DDL 与 schema 完全不变; + // 旧库记录的是清理前 checksum,放行以兼容历史部署,避免要求人工修 checksum 或重建数据库。 + "001_init.sql": newMigrationChecksumCompatibilityRule("dc7c7282d64c54248d21a39673c651f50529b712643f140e1c6150adccceb986", "9ba0369779484625edcea7a7d1d4582397e31546db9149b05004990a3f16c630"), + "002_account_type_migration.sql": newMigrationChecksumCompatibilityRule("02fa8b90d345b288a0a1e3f2d5a8a3a569827d394f5ddae7bcadc1e7e95d89ea", "aad3816e44f58ff007ea4df8092aae580f3f85180314c1deb1b1054b20892bbf"), + "003_subscription.sql": newMigrationChecksumCompatibilityRule("518aac1e666fb0358f9ac0252117795dfd08f98e4c68f2ae5c8513636a2d2a9f", "4642fcb1ccd7954b1d3eef8f795cfba2ce21431257346cc5a7568cde61a60b13"), + "052_migrate_upstream_to_apikey.sql": newMigrationChecksumCompatibilityRule("7662a2b3ace749ae153d9033fc4482fff40df198977a0fef38762afe85e0233f", "d2ea657ec24995664a8ddc1bfb9c3fe317646c7bcd12517dee8478bc6c36244a"), } // ApplyMigrations 将嵌入的 SQL 迁移文件应用到指定的数据库。 From a4620b38d4ddd4665692c49d6c02b4ef62d63205 Mon Sep 17 00:00:00 2001 From: Claude Code via ted Date: Wed, 24 Jun 2026 23:57:21 +0800 Subject: [PATCH 10/12] chore: gofmt import ordering and rebrand subtitle to Local-first AI API gateway - gofmt: normalize import grouping/ordering and indentation across service/handler files (no logic change) - rebrand UI subtitle 'Subscription to API Conversion Platform' -> 'Local-first AI API gateway' (en.ts, SettingsView.vue, VISUAL_GUIDE.md) --- .../handler/auth_current_user_test.go | 22 +++++++++---------- .../handler/openai_gateway_handler_test.go | 2 +- .../handler/openai_images_failover_test.go | 2 ++ backend/internal/server/api_contract_test.go | 3 ++- .../service/api_key_auth_cache_impl.go | 2 +- backend/internal/service/api_key_service.go | 2 +- ..._cache_service_user_platform_quota_test.go | 6 ++--- backend/internal/service/channel_test.go | 1 - backend/internal/service/gemini_session.go | 2 +- .../service/openai_fast_policy_ws_test.go | 2 +- .../service/openai_gateway_service.go | 2 +- .../service/openai_gateway_service_test.go | 2 +- backend/internal/service/openai_ws_client.go | 2 +- .../service/openai_ws_fallback_test.go | 2 +- .../internal/service/openai_ws_forwarder.go | 2 +- ...penai_ws_forwarder_ingress_session_test.go | 2 +- .../openai_ws_forwarder_ingress_test.go | 2 +- .../openai_ws_forwarder_success_test.go | 2 +- .../service/openai_ws_http_bridge_test.go | 2 +- .../openai_ws_ratelimit_signal_test.go | 2 +- .../openai_ws_v2_passthrough_adapter.go | 2 +- .../internal/service/sticky_session_test.go | 4 ++-- .../internal/service/subscription_service.go | 2 +- .../service/usage_record_worker_pool.go | 2 +- frontend/src/i18n/locales/en.ts | 2 +- frontend/src/views/admin/SettingsView.vue | 2 +- frontend/src/views/auth/VISUAL_GUIDE.md | 4 ++-- 27 files changed, 42 insertions(+), 40 deletions(-) diff --git a/backend/internal/handler/auth_current_user_test.go b/backend/internal/handler/auth_current_user_test.go index a351a34a..2f90ec99 100644 --- a/backend/internal/handler/auth_current_user_test.go +++ b/backend/internal/handler/auth_current_user_test.go @@ -29,19 +29,19 @@ func TestAuthHandlerGetCurrentUserReturnsProfileCompatibilityFields(t *testing.T AvatarURL: "https://cdn.example.com/linuxdo.png", AvatarSource: "remote_url", }, - identities: []service.UserAuthIdentityRecord{ - { - ProviderType: "linuxdo", - ProviderKey: "linuxdo", - ProviderSubject: "linuxdo-subject-31", - VerifiedAt: &verifiedAt, - Metadata: map[string]any{ - "username": "linuxdo-handle", - "avatar_url": "https://cdn.example.com/linuxdo.png", - }, + identities: []service.UserAuthIdentityRecord{ + { + ProviderType: "linuxdo", + ProviderKey: "linuxdo", + ProviderSubject: "linuxdo-subject-31", + VerifiedAt: &verifiedAt, + Metadata: map[string]any{ + "username": "linuxdo-handle", + "avatar_url": "https://cdn.example.com/linuxdo.png", }, }, - } + }, + } handler := &AuthHandler{ userService: service.NewUserService(repo, nil, nil, nil), diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 5f090f44..cfe9bf00 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -11,12 +11,12 @@ import ( "testing" "time" + coderws "github.com/coder/websocket" "github.com/dofastted/claude2api/internal/config" pkghttputil "github.com/dofastted/claude2api/internal/pkg/httputil" "github.com/dofastted/claude2api/internal/pkg/pagination" "github.com/dofastted/claude2api/internal/server/middleware" "github.com/dofastted/claude2api/internal/service" - coderws "github.com/coder/websocket" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/backend/internal/handler/openai_images_failover_test.go b/backend/internal/handler/openai_images_failover_test.go index 58da0fa8..bd7d3ed3 100644 --- a/backend/internal/handler/openai_images_failover_test.go +++ b/backend/internal/handler/openai_images_failover_test.go @@ -98,6 +98,7 @@ func TestOpenAIGatewayHandlerImages_ServerErrorFailsOverAndReturnsClearErrorWhen Concurrency: 0, Priority: 0, Credentials: map[string]any{"access_token": "token-1"}, + Extra: map[string]any{"codex_single_environment": false}, }, { ID: 2, @@ -109,6 +110,7 @@ func TestOpenAIGatewayHandlerImages_ServerErrorFailsOverAndReturnsClearErrorWhen Concurrency: 0, Priority: 1, Credentials: map[string]any{"access_token": "token-2"}, + Extra: map[string]any{"codex_single_environment": false}, }, } accountRepo := openAIImagesFailoverAccountRepo{accounts: accounts} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 432f38bd..b7ec8b03 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -588,6 +588,7 @@ func TestAPIContracts(t *testing.T) { "image_size_source": null, "image_size_breakdown": null, "media_type": null, + "local_intercept": false, "cache_ttl_overridden": false, "created_at": "2025-01-02T03:04:05Z", "user_agent": null @@ -1033,7 +1034,7 @@ func TestAPIContracts(t *testing.T) { "google_oauth_frontend_redirect_url": "/auth/oauth/callback", "site_name": "claude2api", "site_logo": "", - "site_subtitle": "Subscription to API Conversion Platform", + "site_subtitle": "Local-first AI API gateway", "api_base_url": "", "api_key_acl_trust_forwarded_ip": false, "contact_info": "", diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 6832a63f..f2bd92c8 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -10,8 +10,8 @@ import ( "math/rand/v2" "time" - "github.com/dofastted/claude2api/internal/config" "github.com/dgraph-io/ristretto" + "github.com/dofastted/claude2api/internal/config" ) const apiKeyAuthSnapshotVersion = 12 // v12: include exclusive group authorization fields diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index b1e72085..3b0bbc7f 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -11,12 +11,12 @@ import ( "sync" "time" + "github.com/dgraph-io/ristretto" "github.com/dofastted/claude2api/internal/config" infraerrors "github.com/dofastted/claude2api/internal/pkg/errors" "github.com/dofastted/claude2api/internal/pkg/ip" "github.com/dofastted/claude2api/internal/pkg/pagination" "github.com/dofastted/claude2api/internal/pkg/timezone" - "github.com/dgraph-io/ristretto" "golang.org/x/sync/singleflight" ) diff --git a/backend/internal/service/billing_cache_service_user_platform_quota_test.go b/backend/internal/service/billing_cache_service_user_platform_quota_test.go index a28bac01..00ef2014 100644 --- a/backend/internal/service/billing_cache_service_user_platform_quota_test.go +++ b/backend/internal/service/billing_cache_service_user_platform_quota_test.go @@ -769,9 +769,9 @@ func TestHasUserPlatformQuotaLimit(t *testing.T) { daily := 5.0 tests := []struct { - name string - setup func() *BillingCacheService - want bool + name string + setup func() *BillingCacheService + want bool }{ { name: "has_limit", diff --git a/backend/internal/service/channel_test.go b/backend/internal/service/channel_test.go index 2f371f8a..6b4bbef8 100644 --- a/backend/internal/service/channel_test.go +++ b/backend/internal/service/channel_test.go @@ -513,7 +513,6 @@ func TestSupportedModels_WildcardExpandedFromPricing(t *testing.T) { } } - func TestSupportedModels_MissingPricingKeepsNilPricing(t *testing.T) { ch := &Channel{ ModelMapping: map[string]map[string]string{ diff --git a/backend/internal/service/gemini_session.go b/backend/internal/service/gemini_session.go index 5a6db082..d7ea0738 100644 --- a/backend/internal/service/gemini_session.go +++ b/backend/internal/service/gemini_session.go @@ -7,8 +7,8 @@ import ( "strconv" "strings" - "github.com/dofastted/claude2api/internal/pkg/antigravity" "github.com/cespare/xxhash/v2" + "github.com/dofastted/claude2api/internal/pkg/antigravity" ) // shortHash 使用 XXHash64 + Base36 生成短 hash(16 字符) diff --git a/backend/internal/service/openai_fast_policy_ws_test.go b/backend/internal/service/openai_fast_policy_ws_test.go index 3ea48e63..328a7325 100644 --- a/backend/internal/service/openai_fast_policy_ws_test.go +++ b/backend/internal/service/openai_fast_policy_ws_test.go @@ -11,10 +11,10 @@ import ( "testing" "time" + coderws "github.com/coder/websocket" "github.com/dofastted/claude2api/internal/config" "github.com/dofastted/claude2api/internal/pkg/apicompat" "github.com/dofastted/claude2api/internal/pkg/claude" - coderws "github.com/coder/websocket" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 72ec946d..15f96944 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -20,6 +20,7 @@ import ( "sync/atomic" "time" + "github.com/cespare/xxhash/v2" "github.com/dofastted/claude2api/internal/config" "github.com/dofastted/claude2api/internal/pkg/apicompat" "github.com/dofastted/claude2api/internal/pkg/clientidentity" @@ -29,7 +30,6 @@ import ( "github.com/dofastted/claude2api/internal/pkg/openai_compat" "github.com/dofastted/claude2api/internal/util/responseheaders" "github.com/dofastted/claude2api/internal/util/urlvalidator" - "github.com/cespare/xxhash/v2" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/tidwall/gjson" diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 581b9357..76220122 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -13,9 +13,9 @@ import ( "testing" "time" + "github.com/cespare/xxhash/v2" "github.com/dofastted/claude2api/internal/config" "github.com/dofastted/claude2api/internal/pkg/openai" - "github.com/cespare/xxhash/v2" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" diff --git a/backend/internal/service/openai_ws_client.go b/backend/internal/service/openai_ws_client.go index 32c72c40..d2f3dda0 100644 --- a/backend/internal/service/openai_ws_client.go +++ b/backend/internal/service/openai_ws_client.go @@ -11,9 +11,9 @@ import ( "sync/atomic" "time" - openaiwsv2 "github.com/dofastted/claude2api/internal/service/openai_ws_v2" coderws "github.com/coder/websocket" "github.com/coder/websocket/wsjson" + openaiwsv2 "github.com/dofastted/claude2api/internal/service/openai_ws_v2" ) const openAIWSMessageReadLimitBytes int64 = 16 * 1024 * 1024 diff --git a/backend/internal/service/openai_ws_fallback_test.go b/backend/internal/service/openai_ws_fallback_test.go index 4e928c6f..17d210f1 100644 --- a/backend/internal/service/openai_ws_fallback_test.go +++ b/backend/internal/service/openai_ws_fallback_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - "github.com/dofastted/claude2api/internal/config" coderws "github.com/coder/websocket" + "github.com/dofastted/claude2api/internal/config" "github.com/stretchr/testify/require" ) diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index dd658c1a..8869a1ef 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -15,10 +15,10 @@ import ( "strings" "time" + coderws "github.com/coder/websocket" "github.com/dofastted/claude2api/internal/pkg/logger" "github.com/dofastted/claude2api/internal/pkg/openai" "github.com/dofastted/claude2api/internal/util/responseheaders" - coderws "github.com/coder/websocket" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go index 14bd7776..1d7f672b 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -11,8 +11,8 @@ import ( "testing" "time" - "github.com/dofastted/claude2api/internal/config" coderws "github.com/coder/websocket" + "github.com/dofastted/claude2api/internal/config" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" diff --git a/backend/internal/service/openai_ws_forwarder_ingress_test.go b/backend/internal/service/openai_ws_forwarder_ingress_test.go index 3578d448..5d72b5e2 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_test.go @@ -8,8 +8,8 @@ import ( "net" "testing" - "github.com/dofastted/claude2api/internal/config" coderws "github.com/coder/websocket" + "github.com/dofastted/claude2api/internal/config" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index 712bfe1c..b6c5a63e 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -14,8 +14,8 @@ import ( "testing" "time" - "github.com/dofastted/claude2api/internal/config" coderws "github.com/coder/websocket" + "github.com/dofastted/claude2api/internal/config" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "github.com/stretchr/testify/require" diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 1a1da043..16286207 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/dofastted/claude2api/internal/config" coderws "github.com/coder/websocket" + "github.com/dofastted/claude2api/internal/config" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" diff --git a/backend/internal/service/openai_ws_ratelimit_signal_test.go b/backend/internal/service/openai_ws_ratelimit_signal_test.go index 393fea30..3b313731 100644 --- a/backend/internal/service/openai_ws_ratelimit_signal_test.go +++ b/backend/internal/service/openai_ws_ratelimit_signal_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "github.com/dofastted/claude2api/internal/pkg/pagination" coderws "github.com/coder/websocket" + "github.com/dofastted/claude2api/internal/pkg/pagination" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "github.com/stretchr/testify/require" diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index cdfbeae8..e011b474 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -9,10 +9,10 @@ import ( "strings" "sync/atomic" + coderws "github.com/coder/websocket" "github.com/dofastted/claude2api/internal/pkg/logger" "github.com/dofastted/claude2api/internal/pkg/openai" openaiwsv2 "github.com/dofastted/claude2api/internal/service/openai_ws_v2" - coderws "github.com/coder/websocket" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" ) diff --git a/backend/internal/service/sticky_session_test.go b/backend/internal/service/sticky_session_test.go index 11ace7bd..02369b19 100644 --- a/backend/internal/service/sticky_session_test.go +++ b/backend/internal/service/sticky_session_test.go @@ -122,8 +122,8 @@ func TestShouldClearStickySession(t *testing.T) { { name: "overloaded account", account: &Account{ - Status: StatusActive, - Schedulable: true, + Status: StatusActive, + Schedulable: true, OverloadUntil: &future, }, requestedModel: "", diff --git a/backend/internal/service/subscription_service.go b/backend/internal/service/subscription_service.go index 667ed99f..20d01eda 100644 --- a/backend/internal/service/subscription_service.go +++ b/backend/internal/service/subscription_service.go @@ -11,11 +11,11 @@ import ( "strings" "time" + "github.com/dgraph-io/ristretto" dbent "github.com/dofastted/claude2api/ent" "github.com/dofastted/claude2api/internal/config" infraerrors "github.com/dofastted/claude2api/internal/pkg/errors" "github.com/dofastted/claude2api/internal/pkg/pagination" - "github.com/dgraph-io/ristretto" "golang.org/x/sync/singleflight" ) diff --git a/backend/internal/service/usage_record_worker_pool.go b/backend/internal/service/usage_record_worker_pool.go index fe87e174..b77833aa 100644 --- a/backend/internal/service/usage_record_worker_pool.go +++ b/backend/internal/service/usage_record_worker_pool.go @@ -8,9 +8,9 @@ import ( "sync/atomic" "time" + "github.com/alitto/pond/v2" "github.com/dofastted/claude2api/internal/config" "github.com/dofastted/claude2api/internal/pkg/logger" - "github.com/alitto/pond/v2" "go.uber.org/zap" ) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index fa7527b5..e614d9c1 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5867,7 +5867,7 @@ export default { siteNamePlaceholder: 'claude2api', siteNameHint: 'Displayed in emails and page titles', siteSubtitle: 'Site Subtitle', - siteSubtitlePlaceholder: 'Subscription to API Conversion Platform', + siteSubtitlePlaceholder: 'Local-first AI API gateway', siteSubtitleHint: 'Displayed on login and register pages', apiBaseUrl: 'API Base URL', apiBaseUrlPlaceholder: 'https://api.example.com', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 43f6a6ce..496f33d9 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -7749,7 +7749,7 @@ const form = reactive({ default_user_rpm_limit: 0, site_name: "claude2api", site_logo: "", - site_subtitle: "Subscription to API Conversion Platform", + site_subtitle: "Local-first AI API gateway", api_base_url: "", contact_info: "", doc_url: "", diff --git a/frontend/src/views/auth/VISUAL_GUIDE.md b/frontend/src/views/auth/VISUAL_GUIDE.md index 7d7e9e97..540bd3f0 100644 --- a/frontend/src/views/auth/VISUAL_GUIDE.md +++ b/frontend/src/views/auth/VISUAL_GUIDE.md @@ -40,7 +40,7 @@ Centered: Both horizontally and vertically ┌─────────────────────────────────────────────┐ │ │ │ 🔷 claude2api │ -│ Subscription to API Conversion Platform │ +│ Local-first AI API gateway │ │ │ │ ┌─────────────────────────────────────┐ │ │ │ │ │ @@ -134,7 +134,7 @@ Centered: Both horizontally and vertically ┌─────────────────────────────────────────────┐ │ │ │ 🔷 claude2api │ -│ Subscription to API Conversion Platform │ +│ Local-first AI API gateway │ │ │ │ ┌─────────────────────────────────────┐ │ │ │ │ │ From 17f7a7d10f0e2771d2c5f97f17eeb7f007b87ab2 Mon Sep 17 00:00:00 2001 From: deploy Date: Wed, 24 Jun 2026 23:00:20 +0800 Subject: [PATCH 11/12] fix(migrations): add 038 to pre-rename checksum compat (complete 32a46e33) 32a46e33 covered the four files touched by 9afaa312's Sub2API->claude2api cleanup but missed 038_ops_errors_..._classification.sql, which the same cleanup also rewrote (first-line comment + one owner-normalization WHERE condition sub2api->claude2api). The migration was already applied on the green/sub2api-migrated DB, so DDL/data are unaffected; only its trimmed checksum changed. Without this rule, rebuilding from source fails startup with a 038 checksum mismatch (db=4cc121d9..., file=3281353a...). Verified by booting the image against a restored production snapshot. --- backend/internal/repository/migrations_runner.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/internal/repository/migrations_runner.go b/backend/internal/repository/migrations_runner.go index ef2490fd..1bff1af0 100644 --- a/backend/internal/repository/migrations_runner.go +++ b/backend/internal/repository/migrations_runner.go @@ -83,6 +83,9 @@ var migrationChecksumCompatibilityRules = map[string]migrationChecksumCompatibil "002_account_type_migration.sql": newMigrationChecksumCompatibilityRule("02fa8b90d345b288a0a1e3f2d5a8a3a569827d394f5ddae7bcadc1e7e95d89ea", "aad3816e44f58ff007ea4df8092aae580f3f85180314c1deb1b1054b20892bbf"), "003_subscription.sql": newMigrationChecksumCompatibilityRule("518aac1e666fb0358f9ac0252117795dfd08f98e4c68f2ae5c8513636a2d2a9f", "4642fcb1ccd7954b1d3eef8f795cfba2ce21431257346cc5a7568cde61a60b13"), "052_migrate_upstream_to_apikey.sql": newMigrationChecksumCompatibilityRule("7662a2b3ace749ae153d9033fc4482fff40df198977a0fef38762afe85e0233f", "d2ea657ec24995664a8ddc1bfb9c3fe317646c7bcd12517dee8478bc6c36244a"), + // 038 与上述四个文件同属 9afaa312 命名清理:其首行注释及一处 owner 归一化 WHERE 条件由 sub2api 改为 claude2api, + // 改变了 trimmed-content checksum,但该迁移在历史库早已应用、不会重跑,故 schema/数据不受影响,仅需放行 checksum。 + "038_ops_errors_resolution_retry_results_and_standardize_classification.sql": newMigrationChecksumCompatibilityRule("3281353a2e475fa22932b101160fc25bdaaaf4a6051ca5f4f974c21ec39b85a2", "4cc121d97c7f59e9def9397b7d0314d4dfbfe4cd831698359456dd49bf995ece"), } // ApplyMigrations 将嵌入的 SQL 迁移文件应用到指定的数据库。 From d086387f921a519f5ca2d0931c561d16bf7f7761 Mon Sep 17 00:00:00 2001 From: Claude Code via ted Date: Thu, 25 Jun 2026 00:07:41 +0800 Subject: [PATCH 12/12] fix(lint): check repo.Set error returns in version_fetcher_service_test (errcheck) --- backend/internal/service/version_fetcher_service_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/version_fetcher_service_test.go b/backend/internal/service/version_fetcher_service_test.go index 192538f0..ca43d8fc 100644 --- a/backend/internal/service/version_fetcher_service_test.go +++ b/backend/internal/service/version_fetcher_service_test.go @@ -408,8 +408,8 @@ func TestFetchAndUpdatePersistsCodexOnlyWhenClaudeFails(t *testing.T) { func TestBootstrapFromDBRestoresPersistedVersions(t *testing.T) { repo := newMemorySettingRepo() - repo.Set(context.Background(), SettingKeyClaudeCLIVersion, `{"cli":"2.3.0","sdk":"0.96.0"}`) - repo.Set(context.Background(), SettingKeyCodexCLIVersion, "0.140.0") + require.NoError(t, repo.Set(context.Background(), SettingKeyClaudeCLIVersion, `{"cli":"2.3.0","sdk":"0.96.0"}`)) + require.NoError(t, repo.Set(context.Background(), SettingKeyCodexCLIVersion, "0.140.0")) registry := clientidentity.NewRegistry() svc := NewVersionFetcherService(registry, &config.Config{}, repo)