Files
sub2api/backend/internal/pkg/claude/constants_cli_version_test.go
T
alfadb 3cb2381bdc fix(claude): 让伪装的 Claude Code 版本真正生效,修复 Fable 5.1 仍然 400
claude-fable-5-1 已登记进 DefaultModels,但 OAuth 账号实际调用仍然全部失败:

  400 claude_code_version_too_old
  "Claude Code 2.1.220 does not support this model;
   version 2.1.251 or newer is required."

两个独立的原因,缺一不可:

1. CLICurrentVersion 停留在 2.1.220,低于上游对该模型的客户端版本闸门。
   升到 2.1.258(当前官方 CLI 最新版,npm @anthropic-ai/claude-code)。

2. 只升常量对所有已有账号无效。上游请求实际发送的 User-Agent 来自账号级
   持久指纹(IdentityService,Redis fingerprint:<accountID>),不是
   DefaultHeaders;defaultFingerprint 只在首次创建指纹时使用。
   GetOrCreateFingerprint 的缓存命中路径只有两条改写分支:UA 畸形时自愈,
   或客户端送来更新版本时升级。指纹 7 天 TTL 但活跃账号每 24h 续期,实际
   近乎永不过期,系统内也没有重置入口——客户端版本比缓存旧时(例如缓存
   2.1.220、客户端 2.1.75),存量账号会永远对上游声称 2.1.220。

   生产实测:升级 CLICurrentVersion 并部署后仍报同样的 400,读 Redis 确认
   出问题账号的缓存值为 "claude-cli/2.1.220 (external, cli)"。

修复:新增 floorClaudeCLIUserAgentVersion,把 CLICurrentVersion 作为缓存
指纹里 claude-cli 版本的下限,缓存命中与首次创建两条路径共用。

  - 只对 claude-cli 产品生效,其它客户端一律不动
  - 只升不降:版本等于或高于下限(含客户端上报的更新版本)不做任何改动
  - 只替换版本号段,UA 其余部分原样保留,例如
    "(external, claude-desktop-3p, agent-sdk/0.3.100)" 不受影响
  - 与既有的"客户端更新版本"升级分支相互独立、二者取更新者
  - X-Stainless-* 维持既有 merge 语义,不被下限逻辑覆盖

cc_version 无需单独处理:billing attribution block 的版本已由
syncBillingHeaderVersion 跟随实际发送的 User-Agent 同步。

测试:identity_service_version_floor_test.go 覆盖 helper 的 8 种 UA 形态与
5 个 GetOrCreateFingerprint 场景,其中一个直接复现线上故障(缓存 2.1.220 +
客户端 2.1.75 → 写回 2.1.258,Stainless 字段保持不变)。
constants_cli_version_test.go 锁定常量与 DefaultHeaders 的 UA 同源,并断言
不低于 Fable 5.1 的闸门下限。回退实现后对应用例确认转红。

identity_service_user_agent_validation_test.go 中 3 个把版本钉死在旧字面量
的既有用例改为钉 CLICurrentVersion 或更高版本——它们断言的正是本次有意改变
的行为,各自的原始意图(哨兵拒绝、合法升级生效、健康缓存不重写)保持不变。
2026-09-07 16:34:35 +08:00

71 lines
2.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}