Files
sub2api/backend/internal/service/gateway_cli_version_runtime_test.go
T
alfadb ca08825935 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,避免缓存翻转
  瞬间头体版本不一致被判非正版客户端
- 管理后台设置页新增配置区块,含中英文案与「当前同步到」只读展示
2026-09-23 13:35:44 +08:00

121 lines
5.3 KiB
Go

package service
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// withCLIVersionResolverForTest 注入解析器并在用例结束时还原(置 nil),避免用例间污染。
func withCLIVersionResolverForTest(t *testing.T, resolver func() string) {
t.Helper()
claude.SetCLIVersionResolver(resolver)
t.Cleanup(func() { claude.SetCLIVersionResolver(nil) })
}
// 一致性铁律(d):模拟一次 OAuth + mimic 的请求构建(messages 与 count_tokens 两条
// 出站路径),断言出站 User-Agent 头里的版本号与请求体 x-anthropic-billing-header
// 中 cc_version 的版本号相同,且都等于运行期注入的版本号。头/体不一致会被 Anthropic
// 判为非正版客户端。
func TestBuildOAuthMimicRequest_RuntimeVersionConsistentBetweenHeaderAndBilling(t *testing.T) {
gin.SetMode(gin.TestMode)
const upgraded = "9.9.9"
withCLIVersionResolverForTest(t, func() string { return upgraded })
for _, endpoint := range []string{"messages", "count_tokens"} {
t.Run(endpoint, func(t *testing.T) {
resetGatewayForwardingSettingsCacheForTest(t)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
body := []byte(`{"model":"claude-haiku-4-5","system":[{"type":"text","text":""}],"messages":[{"role":"user","content":"hello world"}]}`)
billing, err := buildBillingAttributionText(body, "2.1.81")
require.NoError(t, err)
body, err = sjson.SetBytes(body, "system.0.text", billing)
require.NoError(t, err)
svc := &GatewayService{cfg: &config.Config{}}
account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeOAuth}
var req *http.Request
var wireBody []byte
if endpoint == "messages" {
req, wireBody, err = svc.buildUpstreamRequest(context.Background(), c, account,
body, "test-token", "oauth", "claude-haiku-4-5", false, true)
} else {
req, wireBody, err = svc.buildCountTokensRequest(context.Background(), c, account,
body, "test-token", "oauth", "claude-haiku-4-5", true)
}
require.NoError(t, err)
wantUA := "claude-cli/" + upgraded + " (external, cli)"
outboundUA := getHeaderRaw(req.Header, "User-Agent")
require.Equal(t, wantUA, outboundUA)
billingText := gjson.GetBytes(wireBody, "system.0.text").String()
require.Contains(t, billingText, "x-anthropic-billing-header")
require.Contains(t, billingText, "cc_version="+upgraded+"."+computeClaudeCodeFingerprint(wireBody, upgraded)+";")
require.NotContains(t, billingText, "cc_version=2.1.81")
// 头/体版本号必须来自同一字符串。
headerVersion := ExtractCLIVersion(outboundUA)
require.Equal(t, upgraded, headerVersion)
require.Contains(t, billingText, "cc_version="+headerVersion+".")
})
}
}
// e. 注入更高版本后,floorClaudeCLIUserAgentVersion 把存量低版本指纹抬升到新版本;
//
// 等于或高于新版本的指纹保持不动(只升不降)。
func TestFloorClaudeCLIUserAgentVersion_UsesRuntimeVersion(t *testing.T) {
const upgraded = "9.9.9"
withCLIVersionResolverForTest(t, func() string { return upgraded })
floored, changed := floorClaudeCLIUserAgentVersion("claude-cli/2.1.100 (external, cli)")
require.True(t, changed)
require.Equal(t, "claude-cli/"+upgraded+" (external, cli)", floored)
// 等于运行期版本:不动。
same, changed := floorClaudeCLIUserAgentVersion("claude-cli/" + upgraded + " (external, cli)")
require.False(t, changed)
require.Equal(t, "claude-cli/"+upgraded+" (external, cli)", same)
// 高于运行期版本:不动。
greater, changed := floorClaudeCLIUserAgentVersion("claude-cli/99.0.0 (external, cli)")
require.False(t, changed)
require.Equal(t, "claude-cli/99.0.0 (external, cli)", greater)
// resolver 返回非法值时回退内置基线,floor 语义仍然成立。
withCLIVersionResolverForTest(t, func() string { return "abc" })
baselineFloored, changed := floorClaudeCLIUserAgentVersion("claude-cli/1.0.0 (external, cli)")
require.True(t, changed)
require.Equal(t, "claude-cli/"+claude.CLIVersion()+" (external, cli)", baselineFloored)
}
// 注入更高版本后,isAcceptableFingerprintUserAgent 以运行期版本为基准,允许客户端
// 上报的新版本。
func TestIsAcceptableFingerprintUserAgent_UsesRuntimeVersion(t *testing.T) {
const upgraded = "9.9.9"
withCLIVersionResolverForTest(t, func() string { return upgraded })
require.True(t, isAcceptableFingerprintUserAgent("claude-cli/"+upgraded+" (external, cli)"))
// 允许的最大主版本超前量是 +2(9.9.9 → 11.x)。
require.True(t, isAcceptableFingerprintUserAgent("claude-cli/11.0.0 (external, cli)"))
// 超前 +3 会被拒(与静态基线下的既有语义一致,只是基准换成运行期版本)。
require.False(t, isAcceptableFingerprintUserAgent("claude-cli/12.0.0 (external, cli)"))
}
// defaultFingerprint 现取运行期版本,不再在 init 固化。
func TestDefaultFingerprintUsesRuntimeVersion(t *testing.T) {
const upgraded = "9.9.9"
withCLIVersionResolverForTest(t, func() string { return upgraded })
require.Equal(t, "claude-cli/"+upgraded+" (external, cli)", defaultFingerprint().UserAgent)
}