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 committed 2026-09-23 13:35:44 +08:00
1 parent 5e244e7382
commit ca08825935
37 files changed
+1208 -70

No files matched your search

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