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

+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)
}