feat(claude): 自动同步 Claude Code 客户端版本号

出站伪装用的 Claude Code 版本号此前硬编码在 claude.CLICurrentVersion,
只能靠环境变量 SUB2API_CLAUDE_CLI_VERSION 覆盖且需重启;Anthropic 抬高
版本下限时(如 Opus 5.5 要求 >= 2.1.280)必须改代码发版才能跟上。

改为每小时从官方 GitHub Releases 同步最新稳定版,无需重启也无需发版。

- 新增 ClaudeCodeVersionSyncService:1 小时间隔,数据源 anthropics/claude-code
  的 releases/latest,失败回退最近 30 条列表;过滤 draft/prerelease;
  只向前推进,拉取失败保留上次值不降级;面板可关闭
- 新增三个设置项:claude_code_client_version(管理员手动固定)、
  claude_code_client_version_synced(同步服务独占写)、
  claude_code_version_auto_sync_enabled(开关,默认开)
- 取值优先级:面板手动值 -> 同步值 -> SUB2API_CLAUDE_CLI_VERSION -> 内置基线,
  每层经 IsSupportedCLIVersion 校验,含 60s 缓存与 singleflight
- 版本号运行期化:claude.DefaultHeaders 由包级 var 改为函数,新增
  EffectiveCLIVersion/DefaultUserAgent 与 SetCLIVersionResolver(未注入时
  与 CLIVersion() 等价);identity 的 defaultFingerprint 不再在包 init 固化,
  指纹下限与主版本超前校验改用运行期值
- 守住一致性铁律:buildUpstreamRequest 与 count_tokens 各取一次 UA 字符串,
  同时喂给出站 User-Agent 头与请求体 billing 的 cc_version,避免缓存翻转
  瞬间头体版本不一致被判非正版客户端
- 管理后台设置页新增配置区块,含中英文案与「当前同步到」只读展示
This commit is contained in:
alfadb
2026-09-23 13:35:44 +08:00
parent 5e244e7382
commit ca08825935
37 changed files with 1208 additions and 70 deletions
+5
View File
@@ -99,6 +99,7 @@ func provideCleanup(
accountExpiry *service.AccountExpiryService,
cnProviderBalanceCheck *service.CNProviderBalanceCheckService,
codexVersionSync *service.OpenAICodexVersionSyncService,
claudeCodeVersionSync *service.ClaudeCodeVersionSyncService,
proxyExpiry *service.ProxyExpiryService,
subscriptionExpiry *service.SubscriptionExpiryService,
usageCleanup *service.UsageCleanupService,
@@ -272,6 +273,10 @@ func provideCleanup(
codexVersionSync.Stop()
return nil
}},
{"ClaudeCodeVersionSyncService", func() error {
claudeCodeVersionSync.Stop()
return nil
}},
{"ProxyExpiryService", func() error {
proxyExpiry.Stop()
return nil
+7 -1
View File
@@ -341,6 +341,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
cnProviderBalanceCheckService := service.ProvideCNProviderBalanceCheckService(accountRepository, cnProviderBalanceService, cnProviderQuotaService, configConfig)
openAICodexVersionSyncService := service.ProvideOpenAICodexVersionSyncService(settingRepository, settingService, gitHubReleaseClient)
claudeCodeVersionSyncService := service.ProvideClaudeCodeVersionSyncService(settingRepository, settingService, gitHubReleaseClient)
proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository)
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db)
batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig)
@@ -350,7 +351,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher)
channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService)
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager)
application := &Application{
Server: httpServer,
PromptAudit: promptService,
@@ -405,6 +406,7 @@ func provideCleanup(
accountExpiry *service.AccountExpiryService,
cnProviderBalanceCheck *service.CNProviderBalanceCheckService,
codexVersionSync *service.OpenAICodexVersionSyncService,
claudeCodeVersionSync *service.ClaudeCodeVersionSyncService,
proxyExpiry *service.ProxyExpiryService,
subscriptionExpiry *service.SubscriptionExpiryService,
usageCleanup *service.UsageCleanupService,
@@ -577,6 +579,10 @@ func provideCleanup(
codexVersionSync.Stop()
return nil
}},
{"ClaudeCodeVersionSyncService", func() error {
claudeCodeVersionSync.Stop()
return nil
}},
{"ProxyExpiryService", func() error {
proxyExpiry.Stop()
return nil
+2
View File
@@ -41,6 +41,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
)
accountExpirySvc := service.NewAccountExpiryService(nil, time.Second)
codexVersionSyncSvc := service.NewOpenAICodexVersionSyncService(nil, nil, nil, time.Second)
claudeCodeVersionSyncSvc := service.NewClaudeCodeVersionSyncService(nil, nil, nil, time.Second)
proxyExpirySvc := service.NewProxyExpiryService(nil, time.Second)
subscriptionExpirySvc := service.NewSubscriptionExpiryService(nil, time.Second)
pricingSvc := service.NewPricingService(cfg, nil)
@@ -68,6 +69,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
accountExpirySvc,
nil, // cnProviderBalanceCheck
codexVersionSyncSvc,
claudeCodeVersionSyncSvc,
proxyExpirySvc,
subscriptionExpirySvc,
&service.UsageCleanupService{},
@@ -303,6 +303,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
OpenAICodexClientVersion: settings.OpenAICodexClientVersion,
OpenAICodexClientVersionSynced: settings.OpenAICodexClientVersionSynced,
OpenAICodexVersionAutoSyncEnabled: settings.OpenAICodexVersionAutoSyncEnabled,
ClaudeCodeClientVersion: settings.ClaudeCodeClientVersion,
ClaudeCodeClientVersionSynced: settings.ClaudeCodeClientVersionSynced,
ClaudeCodeVersionAutoSyncEnabled: settings.ClaudeCodeVersionAutoSyncEnabled,
MinCodexVersion: settings.MinCodexVersion,
MaxCodexVersion: settings.MaxCodexVersion,
CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist,
@@ -491,6 +491,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.OpenAICodexVersionAutoSyncEnabled != after.OpenAICodexVersionAutoSyncEnabled {
changed = append(changed, "openai_codex_version_auto_sync_enabled")
}
if before.ClaudeCodeClientVersion != after.ClaudeCodeClientVersion {
changed = append(changed, "claude_code_client_version")
}
if before.ClaudeCodeVersionAutoSyncEnabled != after.ClaudeCodeVersionAutoSyncEnabled {
changed = append(changed, "claude_code_version_auto_sync_enabled")
}
if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource {
changed = append(changed, "payment_visible_method_alipay_source")
}
@@ -257,6 +257,8 @@ type UpdateSettingsRequest struct {
OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
OpenAICodexClientVersion *string `json:"openai_codex_client_version"`
OpenAICodexVersionAutoSyncEnabled *bool `json:"openai_codex_version_auto_sync_enabled"`
ClaudeCodeClientVersion *string `json:"claude_code_client_version"`
ClaudeCodeVersionAutoSyncEnabled *bool `json:"claude_code_version_auto_sync_enabled"`
// codex_cli_only 加固(global-only)
MinCodexVersion string `json:"min_codex_version"`
@@ -1455,6 +1457,15 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
req.OpenAICodexClientVersion = &normalized
}
if req.ClaudeCodeClientVersion != nil {
// 该值会被拼进出站 User-Agent 与 billing attribution,必须是合法版本号;空串表示跟随自动同步。
normalized := strings.TrimSpace(*req.ClaudeCodeClientVersion)
if normalized != "" && service.NormalizeClaudeCodeClientVersion(normalized) == "" {
response.Error(c, http.StatusBadRequest, "claude_code_client_version must be empty or a valid version (e.g. 2.1.258)")
return
}
req.ClaudeCodeClientVersion = &normalized
}
// codex_cli_only 加固:最低/最高 Codex 版本(空=禁用,或合法 semver;max>=min)
if req.MinCodexVersion != "" && !semverPattern.MatchString(req.MinCodexVersion) {
@@ -1768,6 +1779,20 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.OpenAICodexVersionAutoSyncEnabled
}(),
ClaudeCodeClientVersion: func() string {
if req.ClaudeCodeClientVersion != nil {
return *req.ClaudeCodeClientVersion
}
return previousSettings.ClaudeCodeClientVersion
}(),
// 同步值由自动同步任务独占写入,面板保存时原样带回,避免被清空。
ClaudeCodeClientVersionSynced: previousSettings.ClaudeCodeClientVersionSynced,
ClaudeCodeVersionAutoSyncEnabled: func() bool {
if req.ClaudeCodeVersionAutoSyncEnabled != nil {
return *req.ClaudeCodeVersionAutoSyncEnabled
}
return previousSettings.ClaudeCodeVersionAutoSyncEnabled
}(),
MinCodexVersion: strings.TrimSpace(req.MinCodexVersion),
MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion),
CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist),
@@ -2311,6 +2336,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
OpenAICodexClientVersion: updatedSettings.OpenAICodexClientVersion,
OpenAICodexClientVersionSynced: updatedSettings.OpenAICodexClientVersionSynced,
OpenAICodexVersionAutoSyncEnabled: updatedSettings.OpenAICodexVersionAutoSyncEnabled,
ClaudeCodeClientVersion: updatedSettings.ClaudeCodeClientVersion,
ClaudeCodeClientVersionSynced: updatedSettings.ClaudeCodeClientVersionSynced,
ClaudeCodeVersionAutoSyncEnabled: updatedSettings.ClaudeCodeVersionAutoSyncEnabled,
MinCodexVersion: updatedSettings.MinCodexVersion,
MaxCodexVersion: updatedSettings.MaxCodexVersion,
CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist,
+3
View File
@@ -217,6 +217,9 @@ type SystemSettings struct {
OpenAICodexClientVersion string `json:"openai_codex_client_version"`
OpenAICodexClientVersionSynced string `json:"openai_codex_client_version_synced"`
OpenAICodexVersionAutoSyncEnabled bool `json:"openai_codex_version_auto_sync_enabled"`
ClaudeCodeClientVersion string `json:"claude_code_client_version"`
ClaudeCodeClientVersionSynced string `json:"claude_code_client_version_synced"`
ClaudeCodeVersionAutoSyncEnabled bool `json:"claude_code_version_auto_sync_enabled"`
// codex_cli_only 加固
MinCodexVersion string `json:"min_codex_version"`
@@ -0,0 +1,43 @@
package claude
import "sync/atomic"
// cliVersionResolver 返回运行期生效的 Claude CLI 版本号(管理员面板手动值 →
// 后台同步值)。由 SettingService 在装配时注入(wire.go),解析器内部自带
// TTL 缓存,热路径不触库。
//
// 未注入时(所有不经过 wire 的单元测试)EffectiveCLIVersion() 与 CLIVersion()
// 完全等价,行为不变。
type cliVersionResolverFunc func() string
var cliVersionResolver atomic.Pointer[cliVersionResolverFunc]
// SetCLIVersionResolver 注入运行期版本解析器;传 nil 可撤销注入。
func SetCLIVersionResolver(resolver func() string) {
if resolver == nil {
cliVersionResolver.Store(nil)
return
}
r := cliVersionResolverFunc(resolver)
cliVersionResolver.Store(&r)
}
// EffectiveCLIVersion 返回当前生效的 Claude CLI 版本号。
// resolver 返回值必须通过 IsSupportedCLIVersion 校验(严格三段 semver 且不低于
// 内置基线),否则回退 CLIVersion()(环境变量覆盖 → 内置基线)。
// 运行期值恒 >= 内置基线,identity_service 的"只升不降"语义不受影响。
func EffectiveCLIVersion() string {
if r := cliVersionResolver.Load(); r != nil {
if v := (*r)(); IsSupportedCLIVersion(v) {
return v
}
}
return CLIVersion()
}
// DefaultUserAgent 返回 Claude Code CLI 伪装 User-Agent。
// 所有出站路径都必须经本函数取 UA,保证 User-Agent 头与请求体 billing
// attribution 块中的 cc_version 始终源自同一个版本号。
func DefaultUserAgent() string {
return "claude-cli/" + EffectiveCLIVersion() + " (external, cli)"
}
@@ -0,0 +1,73 @@
package claude
import (
"strings"
"testing"
)
// withCLIVersionResolver 注入测试解析器并在用例结束时还原,避免用例间污染。
func withCLIVersionResolver(t *testing.T, resolver func() string) {
t.Helper()
SetCLIVersionResolver(resolver)
t.Cleanup(func() { SetCLIVersionResolver(nil) })
}
// a. resolver 未注入时,EffectiveCLIVersion() 必须等于 CLIVersion(),
// 且 DefaultHeaders()["User-Agent"] 与改造前的 init 固化值完全一致。
func TestEffectiveCLIVersionDefaultsToCLIVersion(t *testing.T) {
SetCLIVersionResolver(nil)
if got := EffectiveCLIVersion(); got != CLIVersion() {
t.Fatalf("EffectiveCLIVersion() = %q, want CLIVersion() = %q", got, CLIVersion())
}
wantUA := "claude-cli/" + CLIVersion() + " (external, cli)"
if got := DefaultHeaders()["User-Agent"]; got != wantUA {
t.Fatalf("DefaultHeaders()[User-Agent] = %q, want %q", got, wantUA)
}
}
// b. 注入返回更高版本的 resolver 后,DefaultHeaders()["User-Agent"] 跟随变化。
func TestEffectiveCLIVersionFollowsResolver(t *testing.T) {
const upgraded = "9.9.9"
withCLIVersionResolver(t, func() string { return upgraded })
if got := EffectiveCLIVersion(); got != upgraded {
t.Fatalf("EffectiveCLIVersion() = %q, want %q", got, upgraded)
}
wantUA := "claude-cli/" + upgraded + " (external, cli)"
if got := DefaultHeaders()["User-Agent"]; got != wantUA {
t.Fatalf("DefaultHeaders()[User-Agent] = %q, want %q", got, wantUA)
}
if got := DefaultHeaders()["X-App"]; got != "cli" {
t.Fatalf("DefaultHeaders()[X-App] = %q, want cli", got)
}
}
// c. resolver 返回非法值(空串、非 semver、低于内置基线)时回退 CLIVersion()。
func TestEffectiveCLIVersionFallsBackOnInvalidResolverValues(t *testing.T) {
for name, invalid := range map[string]string{
"empty": "",
"not semver": "abc",
"below baseline": "1.0.0",
"whitespace": " ",
} {
t.Run(name, func(t *testing.T) {
withCLIVersionResolver(t, func() string { return invalid })
if got := EffectiveCLIVersion(); got != CLIVersion() {
t.Fatalf("resolver %q: EffectiveCLIVersion() = %q, want fallback %q", invalid, got, CLIVersion())
}
ua := DefaultHeaders()["User-Agent"]
if !strings.HasPrefix(ua, "claude-cli/"+CLIVersion()+" ") {
t.Fatalf("UA %q does not carry fallback version %q", ua, CLIVersion())
}
})
}
}
// 撤销注入(传 nil)后回到 CLIVersion()。
func TestSetCLIVersionResolverNilRestoresFallback(t *testing.T) {
withCLIVersionResolver(t, func() string { return "9.9.9" })
SetCLIVersionResolver(nil)
if got := EffectiveCLIVersion(); got != CLIVersion() {
t.Fatalf("after nil resolver: EffectiveCLIVersion() = %q, want %q", got, CLIVersion())
}
}
@@ -60,7 +60,7 @@ func TestResolveCLIVersion(t *testing.T) {
// 两者由不同代码路径写入同一个请求,不一致会被上游判为非正版客户端。
func TestDefaultHeadersUserAgentMatchesCLIVersion(t *testing.T) {
want := "claude-cli/" + CLIVersion() + " (external, cli)"
if got := DefaultHeaders["User-Agent"]; got != want {
if got := DefaultHeaders()["User-Agent"]; got != want {
t.Fatalf("DefaultHeaders[User-Agent] = %q, want %q", got, want)
}
}
+20 -15
View File
@@ -108,21 +108,26 @@ func FullClaudeCodeMimicryBetas() []string {
}
// DefaultHeaders 是 Claude Code 客户端默认请求头。
var DefaultHeaders = map[string]string{
// Keep these in sync with recent Claude CLI traffic to reduce the chance
// that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage.
// 版本参考:对齐 Parrot (src/transform/cc_mimicry.py:49) 的 CLI_USER_AGENT。
"User-Agent": "claude-cli/" + CLIVersion() + " (external, cli)",
"X-Stainless-Lang": "js",
"X-Stainless-Package-Version": "0.94.0",
"X-Stainless-OS": "Linux",
"X-Stainless-Arch": "arm64",
"X-Stainless-Runtime": "node",
"X-Stainless-Runtime-Version": "v24.3.0",
"X-Stainless-Retry-Count": "0",
"X-Stainless-Timeout": "600",
"X-App": "cli",
"Anthropic-Dangerous-Direct-Browser-Access": "true",
// 每次调用现构造:User-Agent 走 DefaultUserAgent()(运行期可变版本号),
// 不再在包 init 时固化。同一次请求内应只取一次 UA 字符串并在出站头与
// billing 两条路径间复用,避免版本缓存翻转瞬间头/体不一致。
func DefaultHeaders() map[string]string {
return map[string]string{
// Keep these in sync with recent Claude CLI traffic to reduce the chance
// that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage.
// 版本参考:对齐 Parrot (src/transform/cc_mimicry.py:49) 的 CLI_USER_AGENT。
"User-Agent": DefaultUserAgent(),
"X-Stainless-Lang": "js",
"X-Stainless-Package-Version": "0.94.0",
"X-Stainless-OS": "Linux",
"X-Stainless-Arch": "arm64",
"X-Stainless-Runtime": "node",
"X-Stainless-Runtime-Version": "v24.3.0",
"X-Stainless-Retry-Count": "0",
"X-Stainless-Timeout": "600",
"X-App": "cli",
"Anthropic-Dangerous-Direct-Browser-Access": "true",
}
}
// Model 表示一个 Claude 模型
@@ -21,7 +21,7 @@ const fable51MinCLIVersion = "2.1.251"
func TestCLICurrentVersionMatchesDefaultUserAgent(t *testing.T) {
t.Parallel()
ua := DefaultHeaders["User-Agent"]
ua := DefaultHeaders()["User-Agent"]
const prefix = "claude-cli/"
require.True(t, strings.HasPrefix(ua, prefix), "unexpected User-Agent: %q", ua)
@@ -299,7 +299,7 @@ func (s *AccountTestService) validateUpstreamBaseURL(raw string) (string, error)
}
// generateSessionString generates a Claude Code style session string.
// The output format is determined by the UA version in claude.DefaultHeaders,
// The output format is determined by the UA version in claude.DefaultHeaders(),
// ensuring consistency between the user_id format and the UA sent to upstream.
func generateSessionString() (string, error) {
b := make([]byte, 32)
@@ -308,7 +308,7 @@ func generateSessionString() (string, error) {
}
hex64 := hex.EncodeToString(b)
sessionUUID := uuid.New().String()
uaVersion := ExtractCLIVersion(claude.DefaultHeaders["User-Agent"])
uaVersion := ExtractCLIVersion(claude.DefaultHeaders()["User-Agent"])
return FormatMetadataUserID(hex64, "", sessionUUID, uaVersion), nil
}
@@ -562,7 +562,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
req.Header.Set("anthropic-version", "2023-06-01")
// Apply Claude Code client headers
for key, value := range claude.DefaultHeaders {
for key, value := range claude.DefaultHeaders() {
req.Header.Set(key, value)
}
@@ -77,7 +77,7 @@ func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Co
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("anthropic-version", "2023-06-01")
for key, value := range claude.DefaultHeaders {
for key, value := range claude.DefaultHeaders() {
req.Header.Set(key, value)
}
req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader)
@@ -263,7 +263,7 @@ func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, a
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("anthropic-version", "2023-06-01")
for key, value := range claude.DefaultHeaders {
for key, value := range claude.DefaultHeaders() {
req.Header.Set(key, value)
}
// Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer,其余保持
@@ -0,0 +1,219 @@
package service
import (
"context"
"log/slog"
"strings"
"sync"
"time"
)
const (
// claudeCodeVersionSyncInterval 自动同步间隔。Claude Code CLI 发版频率是小时级,
// 1 小时足够跟上官方节奏,同时把对 GitHub API 的调用压到每天 24 次。
claudeCodeVersionSyncInterval = time.Hour
// claudeCodeVersionSyncTimeout 单次同步的整体超时。
claudeCodeVersionSyncTimeout = 30 * time.Second
// claudeCodeVersionSyncRepo 官方 Claude Code CLI 仓库。
claudeCodeVersionSyncRepo = "anthropics/claude-code"
// claudeCodeVersionSyncPerPage 回退路径单次拉取的 release 数量(主路径见
// fetchLatestStableVersion)。
claudeCodeVersionSyncPerPage = 30
// claudeCodeVersionTagPrefix 客户端 release 的 tag 前缀(如 v2.1.280)。
// 同仓库的 tag 家族单一,但显式按前缀过滤仍能挡住上游改版或混入的其他 tag。
claudeCodeVersionTagPrefix = "v"
)
// ClaudeCodeVersionSyncService 周期性把官方 Claude Code CLI 的最新稳定版版本号同步到设置,
// 供出站规范身份使用,避免为了跟上游版本而发新版本。
//
// 同步值写入 SettingKeyClaudeCodeClientVersionSynced(本服务独占写入);管理员在面板填写的
// SettingKeyClaudeCodeClientVersion 优先级更高,因此手工固定版本不会被同步覆盖。
type ClaudeCodeVersionSyncService struct {
settingRepo SettingRepository
settingService *SettingService
githubClient GitHubReleaseClient
interval time.Duration
stopCh chan struct{}
stopOnce sync.Once
wg sync.WaitGroup
}
func NewClaudeCodeVersionSyncService(
settingRepo SettingRepository,
settingService *SettingService,
githubClient GitHubReleaseClient,
interval time.Duration,
) *ClaudeCodeVersionSyncService {
return &ClaudeCodeVersionSyncService{
settingRepo: settingRepo,
settingService: settingService,
githubClient: githubClient,
interval: interval,
stopCh: make(chan struct{}),
}
}
func (s *ClaudeCodeVersionSyncService) Start() {
if s == nil || s.settingRepo == nil || s.githubClient == nil || s.interval <= 0 {
return
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
s.runInitial()
for {
select {
case <-ticker.C:
s.runOnce()
case <-s.stopCh:
return
}
}
}()
}
func (s *ClaudeCodeVersionSyncService) Stop() {
if s == nil {
return
}
s.stopOnce.Do(func() {
close(s.stopCh)
})
s.wg.Wait()
}
// runInitial 执行启动时的首次同步。若同步值在一个同步周期内已被刷新过则跳过:
// 频繁重启、滚动发布或崩溃重启会让「启动即同步」放大成对 GitHub 的连续请求,
// 而同步间隔只有 1 小时,重启后没有立刻重新拉取的必要。
func (s *ClaudeCodeVersionSyncService) runInitial() {
if s.syncedWithinInterval() {
return
}
s.runOnce()
}
// syncedWithinInterval 判断已同步值是否仍在一个同步周期内。
// 借设置行自身的 UpdatedAt 判断,无需额外记录时间戳的设置项。
// 读取失败或尚无有效同步值时返回 false,让启动同步照常执行。
func (s *ClaudeCodeVersionSyncService) syncedWithinInterval() bool {
if s.interval <= 0 {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), claudeCodeVersionSyncTimeout)
defer cancel()
setting, err := s.settingRepo.Get(ctx, SettingKeyClaudeCodeClientVersionSynced)
if err != nil || setting == nil || setting.UpdatedAt.IsZero() {
return false
}
if NormalizeClaudeCodeClientVersion(setting.Value) == "" {
return false
}
return time.Since(setting.UpdatedAt) < s.interval
}
func (s *ClaudeCodeVersionSyncService) runOnce() {
ctx, cancel := context.WithTimeout(context.Background(), claudeCodeVersionSyncTimeout)
defer cancel()
if !s.autoSyncEnabled(ctx) {
return
}
latest := s.fetchLatestStableVersion(ctx)
if latest == "" {
return
}
current := NormalizeClaudeCodeClientVersion(s.currentSyncedVersion(ctx))
// 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步的版本号降级。
if current != "" && CompareVersions(latest, current) <= 0 {
return
}
if err := s.settingRepo.Set(ctx, SettingKeyClaudeCodeClientVersionSynced, latest); err != nil {
slog.Warn("claude_code_version_sync_persist_failed", "version", latest, "error", err)
return
}
s.settingService.InvalidateClaudeCodeClientVersionCache()
slog.Info("claude_code_version_synced", "previous", current, "version", latest)
}
// fetchLatestStableVersion 取官方最新稳定版 CLI 版本号;取不到时返回空串,
// 由调用方保持既有值(不清空、不降级),各失败分支自行落日志。
//
// 主路径 /releases/latest:该端点本身就排除 draft 与 prerelease,直接给出最新正式发布。
//
// 回退列表扫描:latest 抓取失败或返回值未通过过滤时,扫一页 release 继续跟随官方版本,
// 否则版本号会静默停更。两条路径共用同一套过滤(前缀 / draft / prerelease / 版本号形态),
// 语义不会分叉。
func (s *ClaudeCodeVersionSyncService) fetchLatestStableVersion(ctx context.Context) string {
release, err := s.githubClient.FetchLatestRelease(ctx, claudeCodeVersionSyncRepo)
if err != nil {
slog.Warn("claude_code_version_sync_latest_fetch_failed", "error", err)
} else if version := latestClaudeCodeStableReleaseVersion([]*GitHubRelease{release}); version != "" {
return version
}
// 主路径没拿到可用版本(抓取失败,或 latest 未通过过滤)。
releases, err := s.githubClient.FetchRecentReleases(ctx, claudeCodeVersionSyncRepo, claudeCodeVersionSyncPerPage)
if err != nil {
slog.Warn("claude_code_version_sync_fetch_failed", "error", err)
return ""
}
version := latestClaudeCodeStableReleaseVersion(releases)
if version == "" {
slog.Warn("claude_code_version_sync_no_stable_release", "repo", claudeCodeVersionSyncRepo)
}
return version
}
// autoSyncEnabled 读取面板开关。缺失或空值视为开启,与设置默认值一致;
// 读取失败时保持开启,避免一次数据库抖动就静默停掉版本跟随。
func (s *ClaudeCodeVersionSyncService) autoSyncEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyClaudeCodeVersionAutoSyncEnabled)
if err != nil {
return true
}
if strings.TrimSpace(value) == "" {
return true
}
return strings.TrimSpace(value) == "true"
}
func (s *ClaudeCodeVersionSyncService) currentSyncedVersion(ctx context.Context) string {
value, err := s.settingRepo.GetValue(ctx, SettingKeyClaudeCodeClientVersionSynced)
if err != nil {
return ""
}
return value
}
// latestClaudeCodeStableReleaseVersion 从 release 列表里挑出最大的稳定版 CLI 版本号。
// 过滤条件:tag 前缀为 v、非草稿、非预发布、版本号须通过 NormalizeClaudeCodeClientVersion
// (严格三段纯数字 semver,且不低于内置基线)。取最大值而非最新发布,
// 避免重新发布历史 tag 造成回退。
// 主路径的单条 /releases/latest 结果也走本函数(单元素切片),保证两条取数路径的过滤语义一致。
func latestClaudeCodeStableReleaseVersion(releases []*GitHubRelease) string {
best := ""
for _, release := range releases {
if release == nil || release.Draft || release.Prerelease {
continue
}
tag := strings.TrimSpace(release.TagName)
if !strings.HasPrefix(tag, claudeCodeVersionTagPrefix) {
continue
}
version := NormalizeClaudeCodeClientVersion(strings.TrimPrefix(tag, claudeCodeVersionTagPrefix))
if version == "" || strings.Contains(version, "-") {
continue
}
if best == "" || CompareVersions(version, best) > 0 {
best = version
}
}
return best
}
@@ -0,0 +1,353 @@
package service
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// --- mock: 只实现同步任务用到的读写,其余方法不应被调用 ---
type claudeCodeVersionSyncSettingRepoStub struct {
SettingRepository // 嵌入接口,未实现的方法会 panic(不应被调用)
mu sync.Mutex
values map[string]string
getErr error
setErr error
updatedAt time.Time
writes []string
}
func newClaudeCodeVersionSyncSettingRepoStub(values map[string]string) *claudeCodeVersionSyncSettingRepoStub {
if values == nil {
values = map[string]string{}
}
return &claudeCodeVersionSyncSettingRepoStub{values: values}
}
func (r *claudeCodeVersionSyncSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.getErr != nil {
return "", r.getErr
}
return r.values[key], nil
}
func (r *claudeCodeVersionSyncSettingRepoStub) Set(_ context.Context, key, value string) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.setErr != nil {
return r.setErr
}
r.values[key] = value
r.writes = append(r.writes, value)
return nil
}
func (r *claudeCodeVersionSyncSettingRepoStub) syncedWrites() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.writes...)
}
func (r *claudeCodeVersionSyncSettingRepoStub) Get(_ context.Context, key string) (*Setting, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.getErr != nil {
return nil, r.getErr
}
value, ok := r.values[key]
if !ok {
return nil, ErrSettingNotFound
}
return &Setting{Key: key, Value: value, UpdatedAt: r.updatedAt}, nil
}
type claudeCodeVersionSyncGitHubStub struct {
GitHubReleaseClient // 嵌入接口,未实现的方法会 panic(不应被调用)
// 主路径 /releases/latest;latest 为 nil 且 latestErr 为 nil 时模拟「拿不到可用值」,
// 使调用落到回退的列表扫描上。
latest *GitHubRelease
latestErr error
latestCalls int
releases []*GitHubRelease
err error
calls int
}
func (c *claudeCodeVersionSyncGitHubStub) FetchLatestRelease(_ context.Context, _ string) (*GitHubRelease, error) {
c.latestCalls++
if c.latestErr != nil {
return nil, c.latestErr
}
return c.latest, nil
}
func (c *claudeCodeVersionSyncGitHubStub) FetchRecentReleases(_ context.Context, _ string, _ int) ([]*GitHubRelease, error) {
c.calls++
if c.err != nil {
return nil, c.err
}
return c.releases, nil
}
func newClaudeCodeVersionSyncService(
repo SettingRepository,
github GitHubReleaseClient,
) *ClaudeCodeVersionSyncService {
return NewClaudeCodeVersionSyncService(repo, &SettingService{}, github, claudeCodeVersionSyncInterval)
}
// 同仓库可能混入非 v 前缀 tag 与预发布 / 草稿,必须只认 v 前缀的稳定版,
// 否则会把无关 tag 的版本号当成客户端版本同步出去。
func TestLatestClaudeCodeStableReleaseVersion(t *testing.T) {
releases := []*GitHubRelease{
{TagName: "v2.1.281-beta.1", Prerelease: true},
{TagName: "v2.1.280"},
{TagName: "v2.1.279"},
{TagName: "v2.999.0", Draft: true},
{TagName: "not-a-tag"},
nil,
}
require.Equal(t, "2.1.280", latestClaudeCodeStableReleaseVersion(releases))
require.Empty(t, latestClaudeCodeStableReleaseVersion(nil))
require.Empty(t, latestClaudeCodeStableReleaseVersion([]*GitHubRelease{{TagName: "not-a-tag"}}))
// 预发布 tag 即使漏标 Prerelease 也要被版本号后缀挡住。
require.Empty(t, latestClaudeCodeStableReleaseVersion([]*GitHubRelease{{TagName: "v2.1.281-beta.1"}}))
// 低于内置基线的版本号即使形态合法也不得采信。
require.Empty(t, latestClaudeCodeStableReleaseVersion([]*GitHubRelease{{TagName: "v2.1.9"}}))
}
func TestClaudeCodeVersionSyncWritesLatestStableVersion(t *testing.T) {
repo := newClaudeCodeVersionSyncSettingRepoStub(nil)
github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{
{TagName: "v2.1.279"},
{TagName: "v2.1.280"},
}}
newClaudeCodeVersionSyncService(repo, github).runOnce()
require.Equal(t, []string{"2.1.280"}, repo.syncedWrites())
}
// 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步版本降级。
func TestClaudeCodeVersionSyncNeverMovesBackwards(t *testing.T) {
repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{
SettingKeyClaudeCodeClientVersionSynced: "2.1.280",
})
github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.279"}}}
newClaudeCodeVersionSyncService(repo, github).runOnce()
require.Empty(t, repo.syncedWrites())
}
func TestClaudeCodeVersionSyncSkippedWhenDisabled(t *testing.T) {
repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{
SettingKeyClaudeCodeVersionAutoSyncEnabled: "false",
})
github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.280"}}}
newClaudeCodeVersionSyncService(repo, github).runOnce()
require.Zero(t, github.latestCalls, "关闭自动同步后不应请求上游")
require.Zero(t, github.calls, "关闭自动同步后不应请求上游")
require.Empty(t, repo.syncedWrites())
}
// 面板开关缺失或为空一律视为开启,与设置默认值一致;读取失败同样按开启处理,
// 避免一次数据库抖动就静默停掉版本跟随。
func TestClaudeCodeVersionSyncEnabledByDefaultOrOnError(t *testing.T) {
for _, tt := range []struct {
name string
getErr error
value string
}{
{name: "缺失"},
{name: "空值", value: ""},
{name: "显式开启", value: "true"},
{name: "读取失败", getErr: errors.New("db down")},
} {
t.Run(tt.name, func(t *testing.T) {
repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{
SettingKeyClaudeCodeVersionAutoSyncEnabled: tt.value,
})
repo.getErr = tt.getErr
github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.280"}}}
newClaudeCodeVersionSyncService(repo, github).runOnce()
require.Equal(t, []string{"2.1.280"}, repo.syncedWrites())
})
}
}
// 抓取失败保持既有值,不清空、不降级。两条取数路径都失败才算真正拿不到。
func TestClaudeCodeVersionSyncKeepsValueOnFetchError(t *testing.T) {
repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{
SettingKeyClaudeCodeClientVersionSynced: "2.1.280",
})
github := &claudeCodeVersionSyncGitHubStub{
latestErr: errors.New("network down"),
err: errors.New("network down"),
}
newClaudeCodeVersionSyncService(repo, github).runOnce()
require.Equal(t, 1, github.latestCalls)
require.Equal(t, 1, github.calls)
require.Empty(t, repo.syncedWrites())
value, err := repo.GetValue(context.Background(), SettingKeyClaudeCodeClientVersionSynced)
require.NoError(t, err)
require.Equal(t, "2.1.280", value)
}
// 主路径 /releases/latest:该端点已排除 draft / prerelease,直接给出最新正式发布。
func TestClaudeCodeVersionSyncUsesLatestReleaseEndpoint(t *testing.T) {
repo := newClaudeCodeVersionSyncSettingRepoStub(nil)
github := &claudeCodeVersionSyncGitHubStub{
latest: &GitHubRelease{TagName: "v2.1.280"},
// 列表若被调用会给出不同答案,用于证明取值确实来自主路径。
releases: []*GitHubRelease{{TagName: "v2.1.279"}},
}
newClaudeCodeVersionSyncService(repo, github).runOnce()
require.Equal(t, 1, github.latestCalls)
require.Zero(t, github.calls, "主路径可用时不应再拉列表页")
require.Equal(t, []string{"2.1.280"}, repo.syncedWrites())
}
// 回退列表扫描:主路径拿不到可用稳定版时必须继续扫一页 release,
// 否则版本号会静默停更。各种拿不到的形态都要回退。
func TestClaudeCodeVersionSyncFallsBackToReleaseList(t *testing.T) {
tests := []struct {
name string
latest *GitHubRelease
latestErr error
}{
{name: "latest 前缀不符", latest: &GitHubRelease{TagName: "cli-2.1.280"}},
{name: "latest 是预发布", latest: &GitHubRelease{TagName: "v2.1.281-beta.1", Prerelease: true}},
{name: "latest 是草稿", latest: &GitHubRelease{TagName: "v2.1.281", Draft: true}},
{name: "latest 抓取失败", latestErr: errors.New("network down")},
// 上游返回空对象:不得因此 panic,按拿不到处理。
{name: "latest 为空", latest: nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := newClaudeCodeVersionSyncSettingRepoStub(nil)
github := &claudeCodeVersionSyncGitHubStub{
latest: tt.latest,
latestErr: tt.latestErr,
releases: []*GitHubRelease{
{TagName: "v2.1.281-beta.1", Prerelease: true},
{TagName: "v2.1.280"},
{TagName: "v2.1.279"},
},
}
newClaudeCodeVersionSyncService(repo, github).runOnce()
require.Equal(t, 1, github.latestCalls)
require.Equal(t, 1, github.calls)
require.Equal(t, []string{"2.1.280"}, repo.syncedWrites())
})
}
}
// 两条路径共用同一套过滤:主路径的单条 latest 同样要过前缀 / 版本号形态校验,
// 不能因为「端点保证是正式发布」就直接采信 tag。
func TestClaudeCodeVersionSyncLatestSharesFiltering(t *testing.T) {
repo := newClaudeCodeVersionSyncSettingRepoStub(nil)
github := &claudeCodeVersionSyncGitHubStub{
// 剥掉 v 前缀后不满足基线校验(2.1.9 低于内置基线 2.1.258)的 latest
// 必须被拒绝而不是直接采信。
latest: &GitHubRelease{TagName: "v2.1.9"},
releases: []*GitHubRelease{{TagName: "v2.1.280"}},
}
newClaudeCodeVersionSyncService(repo, github).runOnce()
require.Equal(t, []string{"2.1.280"}, repo.syncedWrites())
}
// 依赖缺失时 Start 必须直接返回,不能起一个空转的 goroutine。
func TestClaudeCodeVersionSyncStartRequiresDependencies(t *testing.T) {
require.NotPanics(t, func() {
svc := NewClaudeCodeVersionSyncService(nil, nil, nil, claudeCodeVersionSyncInterval)
svc.Start()
svc.Stop()
})
}
// 启动同步防抖:同步值仍在一个周期内时跳过,避免频繁重启/滚动发布把启动同步
// 放大成对 GitHub 的连续请求。
func TestClaudeCodeVersionSyncInitialSkipsWhenRecentlySynced(t *testing.T) {
repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{
SettingKeyClaudeCodeClientVersionSynced: "2.1.280",
})
repo.updatedAt = time.Now().Add(-time.Minute)
github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.281"}}}
newClaudeCodeVersionSyncService(repo, github).runInitial()
require.Zero(t, github.calls, "同步值仍在周期内时不应请求上游")
require.Empty(t, repo.syncedWrites())
}
func TestClaudeCodeVersionSyncInitialRunsWhenStaleOrMissing(t *testing.T) {
t.Run("同步值已过期", func(t *testing.T) {
repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{
SettingKeyClaudeCodeClientVersionSynced: "2.1.280",
})
repo.updatedAt = time.Now().Add(-2 * time.Hour)
github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.281"}}}
newClaudeCodeVersionSyncService(repo, github).runInitial()
require.Equal(t, 1, github.calls)
require.Equal(t, []string{"2.1.281"}, repo.syncedWrites())
})
// 首次部署尚无同步值:必须立刻同步,不能被防抖挡住。
t.Run("尚无同步值", func(t *testing.T) {
repo := newClaudeCodeVersionSyncSettingRepoStub(nil)
repo.updatedAt = time.Now()
github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.280"}}}
newClaudeCodeVersionSyncService(repo, github).runInitial()
require.Equal(t, 1, github.calls)
require.Equal(t, []string{"2.1.280"}, repo.syncedWrites())
})
}
// 版本比较必须按段取数字:字典序会把 2.1.9 判为大于 2.1.280,
// 从而让「取最大值」和「只向前推进」两处逻辑同时判错。
func TestClaudeCodeVersionComparisonIsNumericNotLexical(t *testing.T) {
require.Greater(t, CompareVersions("2.1.280", "2.1.9"), 0)
require.Equal(t, "2.1.280", latestClaudeCodeStableReleaseVersion([]*GitHubRelease{
{TagName: "v2.1.9"},
{TagName: "v2.1.280"},
}))
repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{
SettingKeyClaudeCodeClientVersionSynced: "2.1.280",
})
github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.258"}}}
newClaudeCodeVersionSyncService(repo, github).runOnce()
require.Empty(t, repo.syncedWrites(), "更低的版本号不得写入")
}
@@ -707,6 +707,15 @@ const (
SettingKeyOpenAICodexClientVersionSynced = "openai_codex_client_version_synced"
// SettingKeyOpenAICodexVersionAutoSyncEnabled 是否启用 Codex 客户端版本号自动同步(默认 true)。
SettingKeyOpenAICodexVersionAutoSyncEnabled = "openai_codex_version_auto_sync_enabled"
// SettingKeyClaudeCodeClientVersion 网关对 Anthropic 上游声明的 Claude Code CLI 客户端版本号(管理员覆写)。
// 空值表示跟随自动同步值;自动同步也没有结果时回退到 claude.CLIVersion()(环境变量覆盖 + 内置基线)。
// 版本太旧会被 Anthropic 拒绝(claude_code_version_too_old),故该值需保持跟随官方发布。
SettingKeyClaudeCodeClientVersion = "claude_code_client_version"
// SettingKeyClaudeCodeClientVersionSynced 自动同步任务写入的官方 Claude Code CLI 最新版本号。
// 由同步任务独占写入,面板只读展示;管理员覆写请用 SettingKeyClaudeCodeClientVersion。
SettingKeyClaudeCodeClientVersionSynced = "claude_code_client_version_synced"
// SettingKeyClaudeCodeVersionAutoSyncEnabled 是否启用 Claude Code 客户端版本号自动同步(默认 true)。
SettingKeyClaudeCodeVersionAutoSyncEnabled = "claude_code_version_auto_sync_enabled"
// SettingKeyOpenAIAllowClaudeCodeCodexPlugin 已废弃:历史全局开关只作为升级迁移输入读取。
// 迁移后等价规则写入 SettingKeyCodexCLIOnlyWhitelist,不再参与运行时判定。
SettingKeyOpenAIAllowClaudeCodeCodexPlugin = "openai_allow_claude_code_codex_plugin"
@@ -913,7 +913,7 @@ func TestGatewayService_AnthropicOAuthMimic_RewritesSystemWithBillingBlock(t *te
require.Truef(t, anthropicBetaTokensContains(finalBeta, beta), "missing mimic beta %s", beta)
}
require.False(t, anthropicBetaTokensContains(finalBeta, "client-only-beta"))
for key, value := range claude.DefaultHeaders {
for key, value := range claude.DefaultHeaders() {
require.Equal(t, value, getHeaderRaw(upstream.lastReq.Header, key), "mimic fingerprint header %s", key)
}
require.NotEmpty(t, getHeaderRaw(upstream.lastReq.Header, "x-client-request-id"))
@@ -5,7 +5,6 @@ import (
"regexp"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -15,10 +14,13 @@ var ccVersionInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+`)
var ccVersionWithFingerprintInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+\.[0-9a-fA-F]{3}\b`)
// OAuth mimicry forces the built-in User-Agent after applying account fingerprints.
func effectiveBillingUserAgent(tokenType string, mimicClaudeCode bool, fingerprint *Fingerprint) string {
// effectiveBillingUserAgent 选择写进 x-anthropic-billing-header 的 User-Agent。
// OAuth mimicry 强制使用调用方传入的 mimicUserAgent(与出站 User-Agent 头同源、
// 同一次请求内取一次复用,保证 cc_version 与出站头版本严格一致),
// 其余情况使用账号指纹 UA。
func effectiveBillingUserAgent(mimicUserAgent, tokenType string, mimicClaudeCode bool, fingerprint *Fingerprint) string {
if tokenType == "oauth" && mimicClaudeCode {
return claude.DefaultHeaders["User-Agent"]
return mimicUserAgent
}
if fingerprint == nil {
return ""
@@ -138,7 +138,7 @@ func TestBuildOAuthRequest_BillingMatchesWireUserAgent(t *testing.T) {
defer func() { require.NoError(t, req.Body.Close()) }()
wantUA := cachedUA
if tc.mimic {
wantUA = claude.DefaultHeaders["User-Agent"]
wantUA = claude.DefaultHeaders()["User-Agent"]
}
require.Equal(t, wantUA, getHeaderRaw(req.Header, "User-Agent"))
version := ExtractCLIVersion(wantUA)
@@ -767,14 +767,17 @@ func expandClaudeOAuthSystemPromptTextTemplate(body []byte, text string, expansi
return "", nil
}
expansionPrompt = defaultClaudeOAuthExpansionPrompt(expansionPrompt)
billingText, err := buildBillingAttributionText(body, claude.CLIVersion())
// 同一次展开内只取一次版本号,billing attribution / 指纹 / 占位符三处共用,
// 避免运行期版本翻转瞬间取到不同值。
cliVersion := claude.EffectiveCLIVersion()
billingText, err := buildBillingAttributionText(body, cliVersion)
if err != nil {
return "", err
}
fp := computeClaudeCodeFingerprint(body, claude.CLIVersion())
fp := computeClaudeCodeFingerprint(body, cliVersion)
replacer := strings.NewReplacer(
"{billing_header}", billingText,
"{cc_version}", claude.CLIVersion(),
"{cc_version}", cliVersion,
"{fp}", fp,
"{claude_code_system_prompt}", claudeCodeSystemPrompt,
"{claude_code_expansion_prompt}", expansionPrompt,
@@ -0,0 +1,120 @@
package service
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// withCLIVersionResolverForTest 注入解析器并在用例结束时还原(置 nil),避免用例间污染。
func withCLIVersionResolverForTest(t *testing.T, resolver func() string) {
t.Helper()
claude.SetCLIVersionResolver(resolver)
t.Cleanup(func() { claude.SetCLIVersionResolver(nil) })
}
// 一致性铁律(d):模拟一次 OAuth + mimic 的请求构建(messages 与 count_tokens 两条
// 出站路径),断言出站 User-Agent 头里的版本号与请求体 x-anthropic-billing-header
// 中 cc_version 的版本号相同,且都等于运行期注入的版本号。头/体不一致会被 Anthropic
// 判为非正版客户端。
func TestBuildOAuthMimicRequest_RuntimeVersionConsistentBetweenHeaderAndBilling(t *testing.T) {
gin.SetMode(gin.TestMode)
const upgraded = "9.9.9"
withCLIVersionResolverForTest(t, func() string { return upgraded })
for _, endpoint := range []string{"messages", "count_tokens"} {
t.Run(endpoint, func(t *testing.T) {
resetGatewayForwardingSettingsCacheForTest(t)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
body := []byte(`{"model":"claude-haiku-4-5","system":[{"type":"text","text":""}],"messages":[{"role":"user","content":"hello world"}]}`)
billing, err := buildBillingAttributionText(body, "2.1.81")
require.NoError(t, err)
body, err = sjson.SetBytes(body, "system.0.text", billing)
require.NoError(t, err)
svc := &GatewayService{cfg: &config.Config{}}
account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeOAuth}
var req *http.Request
var wireBody []byte
if endpoint == "messages" {
req, wireBody, err = svc.buildUpstreamRequest(context.Background(), c, account,
body, "test-token", "oauth", "claude-haiku-4-5", false, true)
} else {
req, wireBody, err = svc.buildCountTokensRequest(context.Background(), c, account,
body, "test-token", "oauth", "claude-haiku-4-5", true)
}
require.NoError(t, err)
wantUA := "claude-cli/" + upgraded + " (external, cli)"
outboundUA := getHeaderRaw(req.Header, "User-Agent")
require.Equal(t, wantUA, outboundUA)
billingText := gjson.GetBytes(wireBody, "system.0.text").String()
require.Contains(t, billingText, "x-anthropic-billing-header")
require.Contains(t, billingText, "cc_version="+upgraded+"."+computeClaudeCodeFingerprint(wireBody, upgraded)+";")
require.NotContains(t, billingText, "cc_version=2.1.81")
// 头/体版本号必须来自同一字符串。
headerVersion := ExtractCLIVersion(outboundUA)
require.Equal(t, upgraded, headerVersion)
require.Contains(t, billingText, "cc_version="+headerVersion+".")
})
}
}
// e. 注入更高版本后,floorClaudeCLIUserAgentVersion 把存量低版本指纹抬升到新版本;
//
// 等于或高于新版本的指纹保持不动(只升不降)。
func TestFloorClaudeCLIUserAgentVersion_UsesRuntimeVersion(t *testing.T) {
const upgraded = "9.9.9"
withCLIVersionResolverForTest(t, func() string { return upgraded })
floored, changed := floorClaudeCLIUserAgentVersion("claude-cli/2.1.100 (external, cli)")
require.True(t, changed)
require.Equal(t, "claude-cli/"+upgraded+" (external, cli)", floored)
// 等于运行期版本:不动。
same, changed := floorClaudeCLIUserAgentVersion("claude-cli/" + upgraded + " (external, cli)")
require.False(t, changed)
require.Equal(t, "claude-cli/"+upgraded+" (external, cli)", same)
// 高于运行期版本:不动。
greater, changed := floorClaudeCLIUserAgentVersion("claude-cli/99.0.0 (external, cli)")
require.False(t, changed)
require.Equal(t, "claude-cli/99.0.0 (external, cli)", greater)
// resolver 返回非法值时回退内置基线,floor 语义仍然成立。
withCLIVersionResolverForTest(t, func() string { return "abc" })
baselineFloored, changed := floorClaudeCLIUserAgentVersion("claude-cli/1.0.0 (external, cli)")
require.True(t, changed)
require.Equal(t, "claude-cli/"+claude.CLIVersion()+" (external, cli)", baselineFloored)
}
// 注入更高版本后,isAcceptableFingerprintUserAgent 以运行期版本为基准,允许客户端
// 上报的新版本。
func TestIsAcceptableFingerprintUserAgent_UsesRuntimeVersion(t *testing.T) {
const upgraded = "9.9.9"
withCLIVersionResolverForTest(t, func() string { return upgraded })
require.True(t, isAcceptableFingerprintUserAgent("claude-cli/"+upgraded+" (external, cli)"))
// 允许的最大主版本超前量是 +2(9.9.9 → 11.x)。
require.True(t, isAcceptableFingerprintUserAgent("claude-cli/11.0.0 (external, cli)"))
// 超前 +3 会被拒(与静态基线下的既有语义一致,只是基准换成运行期版本)。
require.False(t, isAcceptableFingerprintUserAgent("claude-cli/12.0.0 (external, cli)"))
}
// defaultFingerprint 现取运行期版本,不再在 init 固化。
func TestDefaultFingerprintUsesRuntimeVersion(t *testing.T) {
const upgraded = "9.9.9"
withCLIVersionResolverForTest(t, func() string { return upgraded })
require.Equal(t, "claude-cli/"+upgraded+" (external, cli)", defaultFingerprint().UserAgent)
}
@@ -498,7 +498,10 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
if ctEnableFP {
billingFingerprint = ctFingerprint
}
if billingUA := effectiveBillingUserAgent(tokenType, mimicClaudeCode, billingFingerprint); billingUA != "" {
// 一致性铁律:同一次请求内只取一次 mimic UA,billing cc_version 与出站
// User-Agent 头共用这一个字符串(同 buildUpstreamRequest)。
ctMimicUserAgent := claude.DefaultUserAgent()
if billingUA := effectiveBillingUserAgent(ctMimicUserAgent, tokenType, mimicClaudeCode, billingFingerprint); billingUA != "" {
body = syncBillingHeaderVersion(body, billingUA)
}
@@ -564,7 +567,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
// OAuth + mimic Claude Code:强制注入 CLI 指纹 header
if tokenType == "oauth" && mimicClaudeCode {
applyClaudeCodeMimicHeaders(req, false)
applyClaudeCodeMimicHeaders(req, false, ctMimicUserAgent)
}
// 写入最终 anthropic-beta header(Del 一次避免白名单透传值残留)
@@ -84,8 +84,13 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
}
}
// 一致性铁律:同一次请求内只取一次 mimic UA,出站 User-Agent 头与
// 请求体 x-anthropic-billing-header 的 cc_version 都源自这一个字符串,
// 避免运行期版本缓存翻转瞬间头/体版本自相矛盾(会被判非正版客户端)。
mimicUserAgent := claude.DefaultUserAgent()
// Mimicry may override the cached User-Agent later, even without a fingerprint.
if billingUA := effectiveBillingUserAgent(tokenType, mimicClaudeCode, fingerprint); billingUA != "" {
if billingUA := effectiveBillingUserAgent(mimicUserAgent, tokenType, mimicClaudeCode, fingerprint); billingUA != "" {
body = syncBillingHeaderVersion(body, billingUA)
}
@@ -172,7 +177,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
// OAuth + mimic Claude Code:强制注入 CLI 指纹相关 header
// (user-agent/x-stainless-*/x-app/Accept/x-stainless-helper-method/x-client-request-id)
if tokenType == "oauth" && mimicClaudeCode {
applyClaudeCodeMimicHeaders(req, reqStream)
applyClaudeCodeMimicHeaders(req, reqStream, mimicUserAgent)
}
// 写入最终 anthropic-beta header
@@ -417,7 +422,7 @@ func applyClaudeOAuthHeaderDefaults(req *http.Request) {
if getHeaderRaw(req.Header, "Accept") == "" {
setHeaderRaw(req.Header, "Accept", "application/json")
}
for key, value := range claude.DefaultHeaders {
for key, value := range claude.DefaultHeaders() {
if value == "" {
continue
}
@@ -854,7 +859,9 @@ var defaultDroppedBetasSet = buildBetaTokenSet(claude.DroppedBetas)
// applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers.
// This mirrors opencode-anthropic-auth behavior: do not trust downstream
// headers when using Claude Code-scoped OAuth credentials.
func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) {
// mimicUserAgent 由调用方在同一请求内取一次传入,保证出站 User-Agent 头与
// 请求体 billing attribution 的 cc_version 版本号严格一致。
func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool, mimicUserAgent string) {
if req == nil {
return
}
@@ -862,10 +869,14 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) {
applyClaudeOAuthHeaderDefaults(req)
// Then force key headers to match Claude Code fingerprint regardless of what the client sent.
// 使用 resolveWireCasing 确保 key 与真实 wire format 一致(如 "x-app" 而非 "X-App")
for key, value := range claude.DefaultHeaders {
for key, value := range claude.DefaultHeaders() {
if value == "" {
continue
}
if key == "User-Agent" {
// 版本号与 billing 路径共用同一字符串(见 mimicUserAgent 注释)。
value = mimicUserAgent
}
setHeaderRaw(req.Header, resolveWireCasing(key), value)
}
// Real Claude CLI uses Accept: application/json (even for streaming).
+35 -24
View File
@@ -72,15 +72,17 @@ func isAcceptableFingerprintUserAgent(ua string) bool {
if !ok {
return false
}
currentMajor, _, _, currentOK := parseUserAgentVersion(claudeCLIUserAgentProduct + "/" + claude.CLIVersion())
// 超前检查以运行期生效版本为基准(EffectiveCLIVersion):同步跨大版本后,
// 若仍与静态基线比较会误拒客户端上报的新版本。
currentMajor, _, _, currentOK := parseUserAgentVersion(claudeCLIUserAgentProduct + "/" + claude.EffectiveCLIVersion())
if !currentOK {
return true
}
return major <= currentMajor+maxClaudeCLIMajorVersionSkew
}
// floorClaudeCLIUserAgentVersion 把 claude-cli UA 中的版本号抬升到 claude.CLICurrentVersion
// 下限:低于下限时就地升到下限并返回 changed=true,否则原样返回。
// floorClaudeCLIUserAgentVersion 把 claude-cli UA 中的版本号抬升到运行期生效版本
// (claude.EffectiveCLIVersion())下限:低于下限时就地升到下限并返回 changed=true,否则原样返回。
//
// 为什么需要这条:账号级指纹"只升不降"、活跃账号懒续期后近乎永不过期,且系统内没有重置
// 入口,defaultFingerprint 只在首次创建指纹时使用。存量账号缓存里的 claude-cli 版本停留在
@@ -98,28 +100,36 @@ func floorClaudeCLIUserAgentVersion(ua string) (string, bool) {
if extractProduct(ua) != claudeCLIUserAgentProduct {
return ua, false
}
floorUA := claudeCLIUserAgentProduct + "/" + claude.CLICurrentVersion
// 用 EffectiveCLIVersion()(面板/同步的运行期值,经 IsSupportedCLIVersion
// 校验、恒 >= 内置基线)而非静态基线 CLICurrentVersion:
// 运行期同步到更高版本后,存量低版本指纹才能被抬到新版本;"只升不降"语义不变。
floor := claude.EffectiveCLIVersion()
floorUA := claudeCLIUserAgentProduct + "/" + floor
// isNewerVersion(floor, ua) 为 true 当且仅当下限版本严格高于 ua:
// ua 等于或高于下限、产品名不一致、或版本无法解析时都不做改动。
if !isNewerVersion(floorUA, ua) {
return ua, false
}
floored := claudeCLIUAVersionPrefixRegex.ReplaceAllString(ua, "${1}/"+claude.CLICurrentVersion)
floored := claudeCLIUAVersionPrefixRegex.ReplaceAllString(ua, "${1}/"+floor)
if floored == ua {
return ua, false
}
return floored, true
}
// 默认指纹值(当客户端未提供时使用)
var defaultFingerprint = Fingerprint{
UserAgent: "claude-cli/" + claude.CLIVersion() + " (external, cli)",
StainlessLang: "js",
StainlessPackageVersion: "0.94.0",
StainlessOS: "Linux",
StainlessArch: "arm64",
StainlessRuntime: "node",
StainlessRuntimeVersion: "v24.3.0",
// defaultFingerprint 返回默认指纹值(当客户端未提供时使用)。
// UserAgent 不能在包 init 时固化:必须在每次使用时经 EffectiveCLIVersion 现取,
// 否则运行期同步到新版本后,新建账号会写入过期版本作为持久身份。
func defaultFingerprint() Fingerprint {
return Fingerprint{
UserAgent: claude.DefaultUserAgent(),
StainlessLang: "js",
StainlessPackageVersion: "0.94.0",
StainlessOS: "Linux",
StainlessArch: "arm64",
StainlessRuntime: "node",
StainlessRuntimeVersion: "v24.3.0",
}
}
// Fingerprint represents account fingerprint data
@@ -187,7 +197,7 @@ func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID
if uaAcceptable {
mergeHeadersIntoFingerprint(cached, headers)
} else {
cached.UserAgent = defaultFingerprint.UserAgent
cached.UserAgent = defaultFingerprint().UserAgent
}
needWrite = true
logger.LegacyPrintf("service.identity",
@@ -262,19 +272,20 @@ func (s *IdentityService) createFingerprintFromHeaders(headers http.Header) *Fin
// 首次创建同样是持久化写入,必须与升级路径共用同一套校验。
if ua := strings.TrimSpace(headers.Get("User-Agent")); isAcceptableFingerprintUserAgent(ua) {
// 首次创建与缓存命中路径共用同一个版本下限:合法但过旧的 claude-cli UA
// 落库时同样不能低于 CLICurrentVersion,否则新账号一开始就带着过旧的持久身份。
// 落库时同样不能低于运行期生效版本(EffectiveCLIVersion),否则新账号一开始就带着过旧的持久身份。
fp.UserAgent, _ = floorClaudeCLIUserAgentVersion(ua)
} else {
fp.UserAgent = defaultFingerprint.UserAgent
fp.UserAgent = defaultFingerprint().UserAgent
}
// 获取x-stainless-*头,如果没有则使用默认值
fp.StainlessLang = getHeaderOrDefault(headers, "X-Stainless-Lang", defaultFingerprint.StainlessLang)
fp.StainlessPackageVersion = getHeaderOrDefault(headers, "X-Stainless-Package-Version", defaultFingerprint.StainlessPackageVersion)
fp.StainlessOS = getHeaderOrDefault(headers, "X-Stainless-OS", defaultFingerprint.StainlessOS)
fp.StainlessArch = getHeaderOrDefault(headers, "X-Stainless-Arch", defaultFingerprint.StainlessArch)
fp.StainlessRuntime = getHeaderOrDefault(headers, "X-Stainless-Runtime", defaultFingerprint.StainlessRuntime)
fp.StainlessRuntimeVersion = getHeaderOrDefault(headers, "X-Stainless-Runtime-Version", defaultFingerprint.StainlessRuntimeVersion)
df := defaultFingerprint()
fp.StainlessLang = getHeaderOrDefault(headers, "X-Stainless-Lang", df.StainlessLang)
fp.StainlessPackageVersion = getHeaderOrDefault(headers, "X-Stainless-Package-Version", df.StainlessPackageVersion)
fp.StainlessOS = getHeaderOrDefault(headers, "X-Stainless-OS", df.StainlessOS)
fp.StainlessArch = getHeaderOrDefault(headers, "X-Stainless-Arch", df.StainlessArch)
fp.StainlessRuntime = getHeaderOrDefault(headers, "X-Stainless-Runtime", df.StainlessRuntime)
fp.StainlessRuntimeVersion = getHeaderOrDefault(headers, "X-Stainless-Runtime-Version", df.StainlessRuntimeVersion)
return fp
}
@@ -324,7 +335,7 @@ func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) {
setHeaderRaw(req.Header, "User-Agent", fp.UserAgent)
}
// 设置x-stainless-*头(保持与 claude.DefaultHeaders 一致的大小写)
// 设置x-stainless-*头(保持与 claude.DefaultHeaders() 一致的大小写)
if fp.StainlessLang != "" {
setHeaderRaw(req.Header, "X-Stainless-Lang", fp.StainlessLang)
}
@@ -97,7 +97,7 @@ func TestGetOrCreateFingerprintRejectsMalformedUserAgentOnCreate(t *testing.T) {
)
require.NoError(t, err)
require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent,
require.Equal(t, defaultFingerprint().UserAgent, fp.UserAgent,
"畸形 UA 必须回退默认指纹,而不是被写成账号级持久身份")
require.NotContains(t, cache.lastSet.UserAgent, "999.0.0")
}
@@ -160,8 +160,8 @@ func TestGetOrCreateFingerprintAcceptsValidUserAgentOnCreate(t *testing.T) {
// 不变式:默认指纹自身必须能通过校验,否则自愈路径会在每次读取时反复重写。
func TestDefaultFingerprintUserAgentIsAcceptable(t *testing.T) {
require.True(t, isAcceptableFingerprintUserAgent(defaultFingerprint.UserAgent),
"defaultFingerprint.UserAgent 必须自洽,否则自愈会陷入反复重写")
require.True(t, isAcceptableFingerprintUserAgent(defaultFingerprint().UserAgent),
"defaultFingerprint().UserAgent 必须自洽,否则自愈会陷入反复重写")
}
// 存量自愈:本次加固之前写入的畸形指纹在读取时被纠正。
@@ -203,7 +203,7 @@ func TestGetOrCreateFingerprintHealsPoisonedCacheWithoutValidClientUA(t *testing
)
require.NoError(t, err)
require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent)
require.Equal(t, defaultFingerprint().UserAgent, fp.UserAgent)
require.Equal(t, 1, cache.setCalls)
}
@@ -235,5 +235,5 @@ func TestGetOrCreateFingerprintMissingUserAgentKeepsDefault(t *testing.T) {
fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, http.Header{})
require.NoError(t, err)
require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent)
require.Equal(t, defaultFingerprint().UserAgent, fp.UserAgent)
}
@@ -12,6 +12,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"golang.org/x/sync/singleflight"
)
@@ -119,6 +120,19 @@ const openAICodexClientVersionDBTimeout = 5 * time.Second
// openAICodexClientVersionSFKey singleflight 键。
const openAICodexClientVersionSFKey = "openai_codex_client_version"
// cachedClaudeCodeClientVersion 缓存出站 Claude Code 客户端版本号(进程内缓存,60s TTL)
type cachedClaudeCodeClientVersion struct {
version string
expiresAt int64 // unix nano
}
const claudeCodeClientVersionCacheTTL = 60 * time.Second
const claudeCodeClientVersionErrorTTL = 5 * time.Second
const claudeCodeClientVersionDBTimeout = 5 * time.Second
// claudeCodeClientVersionSFKey singleflight 键。
const claudeCodeClientVersionSFKey = "claude_code_client_version"
type cachedOpenAIQuotaAutoPauseSettings struct {
settings OpsOpenAIAccountQuotaAutoPauseSettings
expiresAt int64
@@ -378,6 +392,101 @@ func (s *SettingService) InvalidateOpenAICodexClientVersionCache() {
s.openAICodexVersionCache.Store((*cachedOpenAICodexClientVersion)(nil))
}
// NormalizeClaudeCodeClientVersion 校验并归一化 Claude Code 客户端版本号,非法值返回空串。
// 容忍前导 "v" 与首尾空白;合法性复用 claude.IsSupportedCLIVersion(严格三段纯数字 semver、
// 无预发布/构建后缀、且不低于内置基线)。
func NormalizeClaudeCodeClientVersion(version string) string {
normalized := strings.TrimSpace(version)
normalized = strings.TrimPrefix(normalized, "v")
if normalized == "" {
return ""
}
if !claude.IsSupportedCLIVersion(normalized) {
return ""
}
return normalized
}
// GetClaudeCodeClientVersion 返回出站声明的 Claude Code CLI 客户端版本号。
// 优先级:管理员在面板覆写的版本 → 自动同步到的官方最新版本 → claude.CLIVersion()
// (环境变量 SUB2API_CLAUDE_CLI_VERSION 覆盖 + 内置基线)。
// 版本太旧会被 Anthropic 拒绝(HTTP 400 claude_code_version_too_old),故该值需保持跟随官方发布。
//
// ⚠️ 一致性约束:同一次请求里,拼 User-Agent(claude-cli/<版本>)的版本号和用于
// billing attribution 的版本号必须是同一个值,否则 Anthropic 侧对不上、判为非正版客户端。
// 因此调用点必须在一次请求内只取一次本函数并复用;本缓存的唯一主动变更点是
// 同步任务写入后调用 InvalidateClaudeCodeClientVersionCache,60s TTL 不会在极短时间内抖动。
func (s *SettingService) GetClaudeCodeClientVersion(ctx context.Context) string {
fallback := claude.CLIVersion()
if s == nil || s.settingRepo == nil {
return fallback
}
if cached, ok := s.claudeCodeVersionCache.Load().(*cachedClaudeCodeClientVersion); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return cached.version
}
}
result, _, _ := s.claudeCodeVersionSF.Do(claudeCodeClientVersionSFKey, func() (any, error) {
if cached, ok := s.claudeCodeVersionCache.Load().(*cachedClaudeCodeClientVersion); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return cached.version, nil
}
}
if ctx == nil {
ctx = context.Background()
}
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), claudeCodeClientVersionDBTimeout)
defer cancel()
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
SettingKeyClaudeCodeClientVersion,
SettingKeyClaudeCodeClientVersionSynced,
})
if err != nil {
slog.Warn("failed to get claude code client version setting", "error", err)
s.claudeCodeVersionCache.Store(&cachedClaudeCodeClientVersion{
version: fallback,
expiresAt: time.Now().Add(claudeCodeClientVersionErrorTTL).UnixNano(),
})
return fallback, nil
}
version := NormalizeClaudeCodeClientVersion(values[SettingKeyClaudeCodeClientVersion])
if version == "" {
if raw := values[SettingKeyClaudeCodeClientVersion]; strings.TrimSpace(raw) != "" {
slog.Warn("ignoring invalid claude_code_client_version setting; falling back to the next layer",
"value", raw)
}
version = NormalizeClaudeCodeClientVersion(values[SettingKeyClaudeCodeClientVersionSynced])
if version == "" && strings.TrimSpace(values[SettingKeyClaudeCodeClientVersionSynced]) != "" {
slog.Warn("ignoring invalid claude_code_client_version_synced setting; falling back to the built-in pin",
"value", values[SettingKeyClaudeCodeClientVersionSynced])
}
}
if version == "" {
version = fallback
}
s.claudeCodeVersionCache.Store(&cachedClaudeCodeClientVersion{
version: version,
expiresAt: time.Now().Add(claudeCodeClientVersionCacheTTL).UnixNano(),
})
return version, nil
})
if version, ok := result.(string); ok && version != "" {
return version
}
return fallback
}
// InvalidateClaudeCodeClientVersionCache 丢弃版本号缓存,下次读取回源。
// 面板保存与自动同步写入后调用。
func (s *SettingService) InvalidateClaudeCodeClientVersionCache() {
if s == nil {
return
}
s.claudeCodeVersionSF.Forget(claudeCodeClientVersionSFKey)
s.claudeCodeVersionCache.Store((*cachedClaudeCodeClientVersion)(nil))
}
// GetOpenAICodexCanonicalUserAgent 返回出站规范 Codex User-Agent。
// 未填面板 UA 时按当前生效的客户端版本号拼出标准 Codex TUI UA。
//
+11
View File
@@ -246,6 +246,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyOpenAICodexClientVersion: "",
SettingKeyOpenAICodexClientVersionSynced: "",
SettingKeyOpenAICodexVersionAutoSyncEnabled: "true",
SettingKeyClaudeCodeClientVersion: "",
SettingKeyClaudeCodeClientVersionSynced: "",
SettingKeyClaudeCodeVersionAutoSyncEnabled: "true",
SettingPaymentVisibleMethodAlipaySource: "",
SettingPaymentVisibleMethodWxpaySource: "",
SettingPaymentVisibleMethodAlipayEnabled: "false",
@@ -891,6 +894,14 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
} else {
result.OpenAICodexVersionAutoSyncEnabled = true
}
result.ClaudeCodeClientVersion = NormalizeClaudeCodeClientVersion(settings[SettingKeyClaudeCodeClientVersion])
result.ClaudeCodeClientVersionSynced = NormalizeClaudeCodeClientVersion(settings[SettingKeyClaudeCodeClientVersionSynced])
// 自动同步默认开启:缺失/空值一律视为开启,与 openai_codex_version_auto_sync_enabled 同一惯例。
if v, ok := settings[SettingKeyClaudeCodeVersionAutoSyncEnabled]; ok && v != "" {
result.ClaudeCodeVersionAutoSyncEnabled = v == "true"
} else {
result.ClaudeCodeVersionAutoSyncEnabled = true
}
// codex_cli_only 加固
result.MinCodexVersion = settings[SettingKeyMinCodexVersion]
result.MaxCodexVersion = settings[SettingKeyMaxCodexVersion]
@@ -130,6 +130,8 @@ type SettingService struct {
openAICodexUASF singleflight.Group
openAICodexVersionCache atomic.Value // *cachedOpenAICodexClientVersion
openAICodexVersionSF singleflight.Group
claudeCodeVersionCache atomic.Value // *cachedClaudeCodeClientVersion
claudeCodeVersionSF singleflight.Group
codexRestrictionPolicyCache atomic.Value // *cachedCodexRestrictionPolicy
codexRestrictionPolicySF singleflight.Group
@@ -487,6 +487,10 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyOpenAICodexVersionAutoSyncEnabled] = strconv.FormatBool(settings.OpenAICodexVersionAutoSyncEnabled)
// SettingKeyOpenAICodexClientVersionSynced 由自动同步任务独占写入,此处不得覆盖,
// 否则面板保存会把同步结果清空。
updates[SettingKeyClaudeCodeClientVersion] = NormalizeClaudeCodeClientVersion(settings.ClaudeCodeClientVersion)
updates[SettingKeyClaudeCodeVersionAutoSyncEnabled] = strconv.FormatBool(settings.ClaudeCodeVersionAutoSyncEnabled)
// SettingKeyClaudeCodeClientVersionSynced 由自动同步任务独占写入,此处不得覆盖,
// 否则面板保存会把同步结果清空。
// codex_cli_only 加固
updates[SettingKeyMinCodexVersion] = strings.TrimSpace(settings.MinCodexVersion)
updates[SettingKeyMaxCodexVersion] = strings.TrimSpace(settings.MaxCodexVersion)
@@ -250,6 +250,9 @@ type SystemSettings struct {
OpenAICodexClientVersion string // 出站声明的 Codex 客户端版本号(管理员覆写);空值跟随自动同步值
OpenAICodexClientVersionSynced string // 自动同步到的官方最新稳定版版本号(只读展示)
OpenAICodexVersionAutoSyncEnabled bool // 是否启用 Codex 客户端版本号自动同步(默认 true)
ClaudeCodeClientVersion string // 出站声明的 Claude Code CLI 客户端版本号(管理员覆写);空值跟随自动同步值
ClaudeCodeClientVersionSynced string // 自动同步到的官方最新版本号(只读展示)
ClaudeCodeVersionAutoSyncEnabled bool // 是否启用 Claude Code 客户端版本号自动同步(默认 true)
MinCodexVersion string // codex_cli_only 最低 Codex 引擎版本;空=不检查
MaxCodexVersion string // codex_cli_only 最高 Codex 引擎版本;空=不检查
CodexCLIOnlyBlacklist string // codex_cli_only 全局黑名单 JSON([]AllowedClientEntry,OR deny)
+2 -2
View File
@@ -942,7 +942,7 @@ func (s *AccountTestService) buildAnthropicUpstreamModelsRequest(ctx context.Con
if err != nil {
return nil, newUpstreamModelSyncConfigError("Invalid Anthropic model list URL", err)
}
for key, value := range claude.DefaultHeaders {
for key, value := range claude.DefaultHeaders() {
req.Header.Set(key, value)
}
req.Header.Set("Accept", "application/json")
@@ -990,7 +990,7 @@ func (s *AccountTestService) buildAntigravityAPIKeyModelsRequest(ctx context.Con
if err != nil {
return nil, newUpstreamModelSyncConfigError("Invalid Antigravity model list URL", err)
}
for key, value := range claude.DefaultHeaders {
for key, value := range claude.DefaultHeaders() {
req.Header.Set(key, value)
}
req.Header.Set("Accept", "application/json")
+19
View File
@@ -10,6 +10,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/google/wire"
@@ -415,6 +416,18 @@ func ProvideOpenAICodexVersionSyncService(
return svc
}
// ProvideClaudeCodeVersionSyncService creates and starts ClaudeCodeVersionSyncService.
// 出站 Claude Code 身份的版本号靠它跟随官方发布,无需为了跟版本而发新版本;面板可关闭。
func ProvideClaudeCodeVersionSyncService(
settingRepo SettingRepository,
settingService *SettingService,
githubClient GitHubReleaseClient,
) *ClaudeCodeVersionSyncService {
svc := NewClaudeCodeVersionSyncService(settingRepo, settingService, githubClient, claudeCodeVersionSyncInterval)
svc.Start()
return svc
}
// ProvideProxyExpiryService creates and starts ProxyExpiryService.
func ProvideProxyExpiryService(proxyRepo ProxyRepository) *ProxyExpiryService {
svc := NewProxyExpiryService(proxyRepo, time.Minute)
@@ -794,6 +807,11 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit
SetCodexCanonicalUserAgentResolver(func() string {
return svc.GetOpenAICodexCanonicalUserAgent(context.Background())
})
// Claude CLI 伪装版本号同理:运行期解析(面板手动值 → 后台同步值 → 内置基线),
// 解析器内部自带 60s TTL 缓存,热路径不触库。
claude.SetCLIVersionResolver(func() string {
return svc.GetClaudeCodeClientVersion(context.Background())
})
return svc
}
@@ -920,6 +938,7 @@ var ProviderSet = wire.NewSet(
wire.Bind(new(GrokOAuthReconciler), new(*TokenRefreshService)),
ProvideAccountExpiryService,
ProvideOpenAICodexVersionSyncService,
ProvideClaudeCodeVersionSyncService,
ProvideProxyExpiryService,
ProvideSubscriptionExpiryService,
ProvideTimingWheelService,