mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-29 00:10:31 +00:00
Merge pull request #6606 from luckydududu/feat/claude-cli-version-env-override
feat(claude): allow operators to override the advertised CLI version
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// CLIVersionEnv 是 CLICurrentVersion 的可选运维覆盖。
|
||||
//
|
||||
// 存在的理由:Anthropic 会对新模型设客户端版本下限(例如 claude-fable-5-1 要求
|
||||
// claude-cli >= 2.1.251),命中时上游直接返回
|
||||
// `Claude Code X.Y.Z does not support this model; version A.B.C or newer is required`。
|
||||
// 在没有本开关之前,这类模型必须等 sub2api 发一个新版本才能使用,
|
||||
// 而改动本身只是一个常量。xai 包的 XAI_GROK_CLI_VERSION 已经是同样的做法。
|
||||
const CLIVersionEnv = "SUB2API_CLAUDE_CLI_VERSION"
|
||||
|
||||
// resolvedCLIVersion 在包初始化时解析一次。
|
||||
//
|
||||
// ⚠️ 故意不做成"每次调用读一次环境变量":伪装身份必须在一个进程的生命周期内保持恒定。
|
||||
// User-Agent 头与请求体 billing attribution 块里的 cc_version 由不同代码路径写入,
|
||||
// 若两次读到不同的值(例如进程运行中有人改了环境变量),同一个请求就会自相矛盾,
|
||||
// 被上游判为非正版客户端。
|
||||
var resolvedCLIVersion = resolveCLIVersion(os.Getenv(CLIVersionEnv))
|
||||
|
||||
// CLIVersion 返回对外伪装的 Claude Code CLI 版本号(三段 semver)。
|
||||
//
|
||||
// 所有需要该版本号的位置都必须走本函数,不要直接引用 CLICurrentVersion——
|
||||
// 后者只是"没有覆盖时的内置基线"。
|
||||
func CLIVersion() string {
|
||||
return resolvedCLIVersion
|
||||
}
|
||||
|
||||
// IsSupportedCLIVersion 判断运维给的覆盖值是否可用。
|
||||
//
|
||||
// 判据有两条,缺一不可:
|
||||
// 1. 严格三段纯数字("2.1.251")。带 -local / -dev / +build 等后缀的版本号会被
|
||||
// identity_service 的 fingerprintUserAgentPattern 拒绝,一旦漏进去,该账号的
|
||||
// 持久指纹会被写成一个不存在的客户端版本,此后所有上游请求都声称这个版本,
|
||||
// 被判非正版并持续 429——而系统内没有指纹重置入口。
|
||||
// 2. 不低于内置基线 CLICurrentVersion。向下覆盖没有任何使用场景,
|
||||
// 却会让 identity_service 的主版本超前检查基准跟着一起降。
|
||||
func IsSupportedCLIVersion(version string) bool {
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" {
|
||||
return false
|
||||
}
|
||||
// semver 允许 "v1.2" 与预发布/构建元数据,这里都不接受:
|
||||
// Canonical 相等可排除省略段,再显式排除预发布与构建元数据。
|
||||
canonical := "v" + version
|
||||
if !semver.IsValid(canonical) || semver.Canonical(canonical) != canonical {
|
||||
return false
|
||||
}
|
||||
if semver.Prerelease(canonical) != "" || semver.Build(canonical) != "" {
|
||||
return false
|
||||
}
|
||||
return semver.Compare(canonical, "v"+CLICurrentVersion) >= 0
|
||||
}
|
||||
|
||||
// resolveCLIVersion 把环境变量的原始值解析成可用的版本号。
|
||||
// 空值静默回落(未配置是正常状态);非空但不合法则回落并告警——
|
||||
// 静默忽略一个显式配置会让运维以为已经生效。
|
||||
func resolveCLIVersion(raw string) string {
|
||||
version := strings.TrimSpace(raw)
|
||||
if version == "" {
|
||||
return CLICurrentVersion
|
||||
}
|
||||
if !IsSupportedCLIVersion(version) {
|
||||
slog.Warn("ignoring invalid Claude CLI version override; falling back to the built-in pin",
|
||||
"env", CLIVersionEnv,
|
||||
"value", version,
|
||||
"builtin", CLICurrentVersion,
|
||||
"requirement", "strict three-part semver, not older than the built-in pin")
|
||||
return CLICurrentVersion
|
||||
}
|
||||
return version
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package claude
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsSupportedCLIVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
version string
|
||||
want bool
|
||||
}{
|
||||
{"内置基线本身", CLICurrentVersion, true},
|
||||
{"高于基线的补丁位", "2.1.251", true},
|
||||
{"高于基线的次版本", "2.2.0", true},
|
||||
{"高于基线的主版本", "3.0.0", true},
|
||||
{"低于基线", "2.1.219", false},
|
||||
{"远低于基线", "1.0.0", false},
|
||||
{"空值", "", false},
|
||||
{"只有两段", "2.2", false},
|
||||
{"带 v 前缀", "v2.2.0", false},
|
||||
{"预发布后缀", "2.2.0-local", false},
|
||||
{"构建元数据", "2.2.0+build1", false},
|
||||
{"哨兵版本带后缀", "999.0.0-local", false},
|
||||
{"纯数字哨兵仍然合法(形态没问题,运维自负)", "999.0.0", true},
|
||||
{"非数字", "abc", false},
|
||||
{"多余的段", "2.2.0.1", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := IsSupportedCLIVersion(tc.version); got != tc.want {
|
||||
t.Fatalf("IsSupportedCLIVersion(%q) = %v, want %v", tc.version, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCLIVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{"未配置时用内置基线", "", CLICurrentVersion},
|
||||
{"只有空白等同未配置", " ", CLICurrentVersion},
|
||||
{"合法覆盖生效", "2.1.251", "2.1.251"},
|
||||
{"两侧空白被裁掉", " 2.1.251 ", "2.1.251"},
|
||||
{"非法值回落基线", "not-a-version", CLICurrentVersion},
|
||||
{"向下覆盖被拒", "2.0.0", CLICurrentVersion},
|
||||
{"预发布后缀被拒(会毒化账号指纹)", "2.2.0-local", CLICurrentVersion},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := resolveCLIVersion(tc.raw); got != tc.want {
|
||||
t.Fatalf("resolveCLIVersion(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 伪装身份的自洽性:User-Agent 头里的版本号必须与 CLIVersion() 一致。
|
||||
// 两者由不同代码路径写入同一个请求,不一致会被上游判为非正版客户端。
|
||||
func TestDefaultHeadersUserAgentMatchesCLIVersion(t *testing.T) {
|
||||
want := "claude-cli/" + CLIVersion() + " (external, cli)"
|
||||
if got := DefaultHeaders["User-Agent"]; got != want {
|
||||
t.Fatalf("DefaultHeaders[User-Agent] = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 没有配置覆盖时,CLIVersion() 必须等于内置基线——保证本 PR 对既有部署零行为变化。
|
||||
func TestCLIVersionDefaultsToBuiltinPin(t *testing.T) {
|
||||
if got := CLIVersion(); got != CLICurrentVersion {
|
||||
t.Fatalf("CLIVersion() = %q, want built-in pin %q (测试进程未设置 %s)", got, CLICurrentVersion, CLIVersionEnv)
|
||||
}
|
||||
}
|
||||
@@ -74,9 +74,12 @@ const APIKeyHaikuBetaHeader = BetaInterleavedThinking
|
||||
// 客户端缺省时统一使用 5m",这样既不浪费 1h 缓存额度,也保留客户端自定义能力。
|
||||
const DefaultCacheControlTTL = "5m"
|
||||
|
||||
// CLICurrentVersion 是 sub2api 当前对外伪装的 Claude Code CLI 版本号(三段 semver)。
|
||||
// CLICurrentVersion 是内置的 Claude Code CLI 伪装版本号基线(三段 semver)。
|
||||
// 用于 billing attribution block 中的 cc_version=X.Y.Z.{fp} 前缀以及 fingerprint 计算。
|
||||
// 必须与 DefaultHeaders["User-Agent"] 中的版本号严格一致;不一致会被 Anthropic 判第三方。
|
||||
//
|
||||
// ⚠️ 读取实际生效的版本号请用 CLIVersion(),它会叠加 SUB2API_CLAUDE_CLI_VERSION 覆盖。
|
||||
// 直接引用本常量只在"表达内置基线"时才正确(例如覆盖值的下限校验)。
|
||||
const CLICurrentVersion = "2.1.220"
|
||||
|
||||
// FullClaudeCodeMimicryBetas 返回最"像"真实 Claude Code CLI 的完整 beta 列表,
|
||||
@@ -105,7 +108,7 @@ 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/" + CLICurrentVersion + " (external, cli)",
|
||||
"User-Agent": "claude-cli/" + CLIVersion() + " (external, cli)",
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Package-Version": "0.94.0",
|
||||
"X-Stainless-OS": "Linux",
|
||||
|
||||
@@ -770,14 +770,14 @@ func expandClaudeOAuthSystemPromptTextTemplate(body []byte, text string, expansi
|
||||
return "", nil
|
||||
}
|
||||
expansionPrompt = defaultClaudeOAuthExpansionPrompt(expansionPrompt)
|
||||
billingText, err := buildBillingAttributionText(body, claude.CLICurrentVersion)
|
||||
billingText, err := buildBillingAttributionText(body, claude.CLIVersion())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fp := computeClaudeCodeFingerprint(body, claude.CLICurrentVersion)
|
||||
fp := computeClaudeCodeFingerprint(body, claude.CLIVersion())
|
||||
replacer := strings.NewReplacer(
|
||||
"{billing_header}", billingText,
|
||||
"{cc_version}", claude.CLICurrentVersion,
|
||||
"{cc_version}", claude.CLIVersion(),
|
||||
"{fp}", fp,
|
||||
"{claude_code_system_prompt}", claudeCodeSystemPrompt,
|
||||
"{claude_code_expansion_prompt}", expansionPrompt,
|
||||
|
||||
@@ -36,7 +36,7 @@ const (
|
||||
// maxFingerprintUserAgentLength 限制写入缓存的 User-Agent 长度。
|
||||
maxFingerprintUserAgentLength = 256
|
||||
// maxClaudeCLIMajorVersionSkew 是 claude-cli 主版本号相对 sub2api 自身伪装
|
||||
// 版本(claude.CLICurrentVersion)允许的最大超前量。给足两个大版本的升级
|
||||
// 版本(claude.CLIVersion())允许的最大超前量。给足两个大版本的升级
|
||||
// 窗口,同时挡掉 999 这类哨兵版本号。
|
||||
maxClaudeCLIMajorVersionSkew = 2
|
||||
)
|
||||
@@ -68,7 +68,7 @@ func isAcceptableFingerprintUserAgent(ua string) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
currentMajor, _, _, currentOK := parseUserAgentVersion(claudeCLIUserAgentProduct + "/" + claude.CLICurrentVersion)
|
||||
currentMajor, _, _, currentOK := parseUserAgentVersion(claudeCLIUserAgentProduct + "/" + claude.CLIVersion())
|
||||
if !currentOK {
|
||||
return true
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func isAcceptableFingerprintUserAgent(ua string) bool {
|
||||
|
||||
// 默认指纹值(当客户端未提供时使用)
|
||||
var defaultFingerprint = Fingerprint{
|
||||
UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)",
|
||||
UserAgent: "claude-cli/" + claude.CLIVersion() + " (external, cli)",
|
||||
StainlessLang: "js",
|
||||
StainlessPackageVersion: "0.94.0",
|
||||
StainlessOS: "Linux",
|
||||
|
||||
Reference in New Issue
Block a user