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
@@ -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)