Merge pull request #6481 from alfadb/fix/claude-cli-version-floor

fix(claude): 让伪装的 Claude Code 版本真正生效,修复 Fable 5.1 仍然 400
This commit is contained in:
Wesley Liddick
2026-09-07 17:37:12 +08:00
committed by GitHub
6 changed files with 294 additions and 17 deletions
@@ -9,7 +9,7 @@ func TestIsSupportedCLIVersion(t *testing.T) {
want bool
}{
{"内置基线本身", CLICurrentVersion, true},
{"高于基线的补丁位", "2.1.251", true},
{"高于基线的补丁位", "2.1.259", true},
{"高于基线的次版本", "2.2.0", true},
{"高于基线的主版本", "3.0.0", true},
{"低于基线", "2.1.219", false},
@@ -41,8 +41,8 @@ func TestResolveCLIVersion(t *testing.T) {
}{
{"未配置时用内置基线", "", CLICurrentVersion},
{"只有空白等同未配置", " ", CLICurrentVersion},
{"合法覆盖生效", "2.1.251", "2.1.251"},
{"两侧空白被裁掉", " 2.1.251 ", "2.1.251"},
{"合法覆盖生效", "2.1.259", "2.1.259"},
{"两侧空白被裁掉", " 2.1.259 ", "2.1.259"},
{"非法值回落基线", "not-a-version", CLICurrentVersion},
{"向下覆盖被拒", "2.0.0", CLICurrentVersion},
{"预发布后缀被拒(会毒化账号指纹)", "2.2.0-local", CLICurrentVersion},
+1 -1
View File
@@ -80,7 +80,7 @@ const DefaultCacheControlTTL = "5m"
//
// ⚠️ 读取实际生效的版本号请用 CLIVersion(),它会叠加 SUB2API_CLAUDE_CLI_VERSION 覆盖。
// 直接引用本常量只在"表达内置基线"时才正确(例如覆盖值的下限校验)。
const CLICurrentVersion = "2.1.220"
const CLICurrentVersion = "2.1.258"
// FullClaudeCodeMimicryBetas 返回最"像"真实 Claude Code CLI 的完整 beta 列表,
// 用于 OAuth 账号伪装成 Claude Code 时使用。
@@ -0,0 +1,70 @@
package claude
import (
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// fable51MinCLIVersion 是上游对 claude-fable-5-1 的客户端版本闸门下限。
// 低于该版本时上游直接 400:
//
// claude_code_version_too_old
// "Claude Code <ver> does not support this model; version 2.1.251 or newer is required."
const fable51MinCLIVersion = "2.1.251"
// TestCLICurrentVersionMatchesDefaultUserAgent 锁定"常量与 UA 同源"这条约束。
// 两者不一致会被 Anthropic 判为第三方客户端,且只改一处是历史上的常见疏漏,
// 所以这里从 UA 里反解版本号来比对,而不是并列两个字面量。
func TestCLICurrentVersionMatchesDefaultUserAgent(t *testing.T) {
t.Parallel()
ua := DefaultHeaders["User-Agent"]
const prefix = "claude-cli/"
require.True(t, strings.HasPrefix(ua, prefix), "unexpected User-Agent: %q", ua)
rest := strings.TrimPrefix(ua, prefix)
version := rest
if idx := strings.IndexByte(rest, ' '); idx >= 0 {
version = rest[:idx]
}
require.Equal(t, CLICurrentVersion, version,
"DefaultHeaders User-Agent version must match CLICurrentVersion")
}
// TestCLICurrentVersionSatisfiesFable51Gate 锁定伪装版本不低于 claude-fable-5-1
// 的闸门下限。DefaultModels 里登记了该模型却发着更旧的版本号,等于模型清单可见
// 但一调就 400。
func TestCLICurrentVersionSatisfiesFable51Gate(t *testing.T) {
t.Parallel()
require.GreaterOrEqual(t, compareSemver(t, CLICurrentVersion, fable51MinCLIVersion), 0,
"CLICurrentVersion %s is below the claude-fable-5-1 gate %s",
CLICurrentVersion, fable51MinCLIVersion)
}
// compareSemver 比较两个三段 semver,返回 -1 / 0 / 1。
func compareSemver(t *testing.T, a, b string) int {
t.Helper()
as, bs := strings.Split(a, "."), strings.Split(b, ".")
require.Len(t, as, 3, "not a three-part semver: %q", a)
require.Len(t, bs, 3, "not a three-part semver: %q", b)
for i := range as {
ai, err := strconv.Atoi(as[i])
require.NoError(t, err, "non-numeric segment in %q", a)
bi, err := strconv.Atoi(bs[i])
require.NoError(t, err, "non-numeric segment in %q", b)
switch {
case ai > bi:
return 1
case ai < bi:
return -1
}
}
return 0
}
+61 -6
View File
@@ -28,6 +28,10 @@ var (
// <product>/<major>.<minor>.<patch> 之后必须紧跟空白或字符串结束。
// 版本号带 -local / -dev / +build 等后缀的本地构建一律不接受。
fingerprintUserAgentPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+/\d+\.\d+\.\d+(\s|$)`)
// claudeCLIUAVersionPrefixRegex 匹配 claude-cli UA 开头的 "claude-cli/x.y.z" 版本号段,
// 供版本下限抬升时就地替换版本号使用;UA 其余部分(括号内的真实客户端形态等)原样保留。
claudeCLIUAVersionPrefixRegex = regexp.MustCompile(`(?i)^(claude-cli)/\d+\.\d+\.\d+`)
)
const (
@@ -75,6 +79,38 @@ func isAcceptableFingerprintUserAgent(ua string) bool {
return major <= currentMajor+maxClaudeCLIMajorVersionSkew
}
// floorClaudeCLIUserAgentVersion 把 claude-cli UA 中的版本号抬升到 claude.CLICurrentVersion
// 下限:低于下限时就地升到下限并返回 changed=true,否则原样返回。
//
// 为什么需要这条:账号级指纹"只升不降"、活跃账号懒续期后近乎永不过期,且系统内没有重置
// 入口,defaultFingerprint 只在首次创建指纹时使用。存量账号缓存里的 claude-cli 版本停留在
// 历史值(如 claude-cli/2.1.220),客户端送来更旧的版本时 isNewerVersion 不会触发升级——
// 仅升 CLICurrentVersion 常量对所有已有账号完全无效,上游按指纹 UA 做客户端版本闸门
// (如 Fable 5.1 要求 >= 2.1.251)时旧指纹永远过不去。
//
// 约束:
// - 只对 claude-cli 产品生效,其它产品一律不动,避免误伤别的合法客户端;
// - 只升不降:版本等于或高于 CLICurrentVersion(含客户端上报的更新版本)时不做任何改动;
// - 只替换 claude-cli/ 后的版本号段,UA 其余部分(如 "(external, claude-desktop-3p,
// agent-sdk/0.3.100)")原样保留,不重建整个字符串、不退化为 defaultFingerprint.UserAgent;
// - X-Stainless-* 字段不在此处理,维持调用方的既有 merge 语义。
func floorClaudeCLIUserAgentVersion(ua string) (string, bool) {
if extractProduct(ua) != claudeCLIUserAgentProduct {
return ua, false
}
floorUA := claudeCLIUserAgentProduct + "/" + claude.CLICurrentVersion
// isNewerVersion(floor, ua) 为 true 当且仅当下限版本严格高于 ua:
// ua 等于或高于下限、产品名不一致、或版本无法解析时都不做改动。
if !isNewerVersion(floorUA, ua) {
return ua, false
}
floored := claudeCLIUAVersionPrefixRegex.ReplaceAllString(ua, "${1}/"+claude.CLICurrentVersion)
if floored == ua {
return ua, false
}
return floored, true
}
// 默认指纹值(当客户端未提供时使用)
var defaultFingerprint = Fingerprint{
UserAgent: "claude-cli/" + claude.CLIVersion() + " (external, cli)",
@@ -157,12 +193,29 @@ func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID
logger.LegacyPrintf("service.identity",
"Replaced malformed cached fingerprint for account %d: %q -> %q",
accountID, poisoned, cached.UserAgent)
} else if uaAcceptable && isNewerVersion(clientUA, cached.UserAgent) {
// 版本升级:merge 语义 — 仅更新请求中实际携带的字段,保留缓存值
// 避免缺失的头被硬编码默认值覆盖(如新 CLI 版本 + 旧 SDK 默认值的不一致)
mergeHeadersIntoFingerprint(cached, headers)
} else {
// 客户端送来更新版本时的常规升级:merge 语义 — 仅更新请求中实际携带的字段,
// 保留缓存值,避免缺失的头被硬编码默认值覆盖(如新 CLI 版本 + 旧 SDK 默认值的不一致)
if uaAcceptable && isNewerVersion(clientUA, cached.UserAgent) {
mergeHeadersIntoFingerprint(cached, headers)
needWrite = true
logger.LegacyPrintf("service.identity", "Updated fingerprint for account %d: %s (merge update)", accountID, clientUA)
}
}
// 版本下限抬升(floor):heal 与 healthy 两条缓存命中路径共同的后置条件,与
// 上面的客户端常规升级相互独立、二者取更新者。客户端送来更旧版本时
// isNewerVersion 不触发,此处仍能把低于 CLICurrentVersion 的存量指纹就地抬到
// 下限并持久化,否则升 CLICurrentVersion 对所有已有账号无效,新模型的客户端
// 版本闸门(如 Fable 5.1 要求 >= 2.1.251)永远过不去。自愈路径同样必须经过:
// 畸形缓存 + 合法但过旧的客户端 UA 首次读取就要落到下限,不能先落库一个
// 过旧版本、等下一次读取才纠正。
if flooredUA, changed := floorClaudeCLIUserAgentVersion(cached.UserAgent); changed {
cached.UserAgent = flooredUA
needWrite = true
logger.LegacyPrintf("service.identity", "Updated fingerprint for account %d: %s (merge update)", accountID, clientUA)
logger.LegacyPrintf("service.identity",
"Floored cached fingerprint claude-cli version for account %d: %s", accountID, flooredUA)
}
if !needWrite && time.Since(time.Unix(cached.UpdatedAt, 0)) > 24*time.Hour {
@@ -208,7 +261,9 @@ func (s *IdentityService) createFingerprintFromHeaders(headers http.Header) *Fin
// 获取User-Agent:只接受形态合法且版本合理的值,否则回退默认指纹。
// 首次创建同样是持久化写入,必须与升级路径共用同一套校验。
if ua := strings.TrimSpace(headers.Get("User-Agent")); isAcceptableFingerprintUserAgent(ua) {
fp.UserAgent = ua
// 首次创建与缓存命中路径共用同一个版本下限:合法但过旧的 claude-cli UA
// 落库时同样不能低于 CLICurrentVersion,否则新账号一开始就带着过旧的持久身份。
fp.UserAgent, _ = floorClaudeCLIUserAgentVersion(ua)
} else {
fp.UserAgent = defaultFingerprint.UserAgent
}
@@ -105,8 +105,9 @@ func TestGetOrCreateFingerprintRejectsMalformedUserAgentOnCreate(t *testing.T) {
// 升级路径:哨兵版本号不得覆盖已缓存的真实指纹。
// isNewerVersion 是纯数值比较,999.0.0 恒大于任何真实版本,一旦写入永远无法夺回。
func TestGetOrCreateFingerprintRejectsSentinelVersionOnUpgrade(t *testing.T) {
// 缓存版本取 CLICurrentVersion(>= 下限),使本用例只验证哨兵拒绝,不与版本下限抬升耦合。
cached := &Fingerprint{
UserAgent: "claude-cli/2.1.22 (external, cli)",
UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)",
ClientID: "cid-1",
UpdatedAt: time.Now().Unix(),
}
@@ -119,7 +120,7 @@ func TestGetOrCreateFingerprintRejectsSentinelVersionOnUpgrade(t *testing.T) {
)
require.NoError(t, err)
require.Equal(t, "claude-cli/2.1.22 (external, cli)", fp.UserAgent,
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent,
"真实指纹不得被哨兵版本覆盖")
require.Zero(t, cache.setCalls, "被拒的 UA 不应触发任何写入")
}
@@ -133,7 +134,9 @@ func TestGetOrCreateFingerprintStillUpgradesOnValidNewerVersion(t *testing.T) {
}}
svc := NewIdentityService(cache)
newUA := "claude-cli/2.1.223 (external, cli)"
// 客户端版本取高于 CLICurrentVersion 的合法值,使本用例只验证常规升级,
// 不与版本下限抬升耦合(低于下限的客户端升级由 version_floor 测试覆盖)。
newUA := "claude-cli/2.9.0 (external, cli)"
fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(newUA))
require.NoError(t, err)
@@ -176,8 +179,10 @@ func TestGetOrCreateFingerprintHealsPoisonedCacheUsingValidClientUA(t *testing.T
fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(realUA))
require.NoError(t, err)
require.Equal(t, realUA, fp.UserAgent,
"真实客户端必须能从被毒化的指纹手中夺回账号身份")
// 真实客户端从被毒化的指纹手中夺回账号身份,但版本下限在自愈的同一读取内生效:
// 合法而过旧的 UA 不能先以过旧版本落库、等下次读取才被抬升。
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent)
require.Equal(t, fp.UserAgent, cache.lastSet.UserAgent, "自愈与下限抬升应合并为同一次落库")
require.Equal(t, 1, cache.setCalls)
require.NotContains(t, cache.lastSet.UserAgent, "999.0.0")
require.Equal(t, "cid-1", fp.ClientID, "自愈不应重置 ClientID")
@@ -203,9 +208,10 @@ func TestGetOrCreateFingerprintHealsPoisonedCacheWithoutValidClientUA(t *testing
}
// 自愈只针对畸形缓存:合法缓存 + 非更新版本的合法 UA 不得触发额外写入。
// 缓存版本取 CLICurrentVersion(>= 下限),低于下限的缓存抬升由 version_floor 测试覆盖。
func TestGetOrCreateFingerprintDoesNotRewriteHealthyCache(t *testing.T) {
cache := &stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: "claude-cli/2.1.220 (external, cli)",
UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)",
ClientID: "cid-1",
UpdatedAt: time.Now().Unix(),
}}
@@ -217,7 +223,7 @@ func TestGetOrCreateFingerprintDoesNotRewriteHealthyCache(t *testing.T) {
)
require.NoError(t, err)
require.Equal(t, "claude-cli/2.1.220 (external, cli)", fp.UserAgent)
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent)
require.Zero(t, cache.setCalls)
}
@@ -0,0 +1,146 @@
package service
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
)
// floorClaudeCLIUserAgentVersion 单元测试:版本下限抬升的各种形态。
func TestFloorClaudeCLIUserAgentVersion(t *testing.T) {
floorUA := "claude-cli/" + claude.CLICurrentVersion
cases := []struct {
name string
ua string
want string
wantChanged bool
}{
// 线上故障形态:历史版本低于 CLICurrentVersion,就地抬到下限。
{"old_version_upgraded", "claude-cli/2.1.220 (external, cli)", floorUA + " (external, cli)", true},
// 只替换版本号段,括号内的真实客户端形态原样保留。
{"old_version_with_desktop_3p_suffix",
"claude-cli/2.1.100 (external, claude-desktop-3p, agent-sdk/0.3.100)",
floorUA + " (external, claude-desktop-3p, agent-sdk/0.3.100)", true},
// 等于下限:不得改动。
{"equal_to_floor", floorUA + " (external, cli)", floorUA + " (external, cli)", false},
// 高于下限:只升不降,不得把客户端上报的更新版本压回去。
{"newer_than_floor_not_downgraded", "claude-cli/2.9.0 (external, cli)", "claude-cli/2.9.0 (external, cli)", false},
// 非 claude-cli 产品:一律不动。
{"other_product_untouched", "opencode/1.2.3 (external, cli)", "opencode/1.2.3 (external, cli)", false},
// 空串 / 畸形:一律不动。
{"empty", "", "", false},
{"no_version", "claude-cli (external, cli)", "claude-cli (external, cli)", false},
{"unparseable_version", "claude-cli/abc (external, cli)", "claude-cli/abc (external, cli)", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, changed := floorClaudeCLIUserAgentVersion(tc.ua)
require.Equal(t, tc.want, got)
require.Equal(t, tc.wantChanged, changed)
})
}
}
// GetOrCreateFingerprint 集成行为,直接对应线上故障:缓存指纹停留在历史版本
// 2.1.220(生产 Redis 中账号 147 的实际值),客户端送来更旧的 2.1.75。
// 修复前:isNewerVersion 不触发、UA 形态合法不触发自愈,旧指纹被原样返回并
// 近乎永不过期——上游按指纹 UA 做客户端版本闸门(Fable 5.1 要求 >= 2.1.251),
// 仅升 CLICurrentVersion 对存量账号完全无效。
func TestGetOrCreateFingerprintFloorsStaleCachedUserAgent(t *testing.T) {
cache := &stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: "claude-cli/2.1.220 (external, cli)",
ClientID: "cid-1",
StainlessPackageVersion: "0.91.1",
UpdatedAt: time.Now().Unix(),
}}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(
context.Background(), 147,
headersWithUA("claude-cli/2.1.75 (external, cli)"),
)
require.NoError(t, err)
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent)
require.Equal(t, 1, cache.setCalls, "下限抬升必须持久化写回缓存")
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", cache.lastSet.UserAgent)
// X-Stainless-* 维持既有 merge 语义:客户端未携带时保留缓存中的真实值,不被下限逻辑覆盖。
require.Equal(t, "0.91.1", fp.StainlessPackageVersion)
}
// 缓存版本高于下限:不得被降级,也不得触发多余写入。
func TestGetOrCreateFingerprintDoesNotTouchCacheAboveFloor(t *testing.T) {
ua := "claude-cli/2.9.0 (external, cli)"
cache := &stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: ua,
ClientID: "cid-1",
StainlessPackageVersion: "0.91.1",
UpdatedAt: time.Now().Unix(),
}}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(
context.Background(), 1,
headersWithUA("claude-cli/2.1.75 (external, cli)"),
)
require.NoError(t, err)
require.Equal(t, ua, fp.UserAgent, "高于下限的缓存版本不得被降级")
require.Zero(t, cache.setCalls)
}
// 客户端送来高于下限的更新版本:常规升级照常生效,且不被下限压回。
func TestGetOrCreateFingerprintClientNewerThanFloorStillWins(t *testing.T) {
newUA := "claude-cli/2.9.0 (external, cli)"
cache := &stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: "claude-cli/2.1.220 (external, cli)",
ClientID: "cid-1",
UpdatedAt: time.Now().Unix(),
}}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(newUA))
require.NoError(t, err)
require.Equal(t, newUA, fp.UserAgent, "客户端更新版本优先于下限,不得被压回")
require.Equal(t, 1, cache.setCalls)
}
// 客户端版本高于缓存但低于下限:merge 升级后仍被抬到下限(二者取更新者)。
func TestGetOrCreateFingerprintFloorWinsOverStaleClientUpgrade(t *testing.T) {
cache := &stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: "claude-cli/2.1.22 (external, cli)",
ClientID: "cid-1",
UpdatedAt: time.Now().Unix(),
}}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(
context.Background(), 1,
headersWithUA("claude-cli/2.1.223 (external, cli)"),
)
require.NoError(t, err)
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent)
require.Equal(t, 1, cache.setCalls)
}
// 首次创建路径同样受下限约束:合法但过旧的 claude-cli UA 落库时必须抬到 CLICurrentVersion。
func TestCreateFingerprintFromHeadersFloorsOldClientUserAgent(t *testing.T) {
cache := &stubIdentityCache{}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(
context.Background(), 1,
headersWithUA("claude-cli/2.1.75 (external, claude-desktop-3p)"),
)
require.NoError(t, err)
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, claude-desktop-3p)", fp.UserAgent)
require.Equal(t, 1, cache.setCalls)
}