mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
出站伪装用的 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,避免缓存翻转 瞬间头体版本不一致被判非正版客户端 - 管理后台设置页新增配置区块,含中英文案与「当前同步到」只读展示
154 lines
5.8 KiB
Go
154 lines
5.8 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
func TestSyncBillingHeaderVersion(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
userAgent string
|
|
wantSub string // substring expected in result
|
|
unchanged bool // expect body to remain the same
|
|
}{
|
|
{
|
|
name: "replaces cc_version and recomputes message-derived suffix",
|
|
body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81.df2; cc_entrypoint=cli; cch=00000;"},{"type":"text","text":"You are Claude Code.","cache_control":{"type":"ephemeral"}}],"messages":[]}`,
|
|
userAgent: "claude-cli/2.1.22 (external, cli)",
|
|
wantSub: "cc_version=2.1.22." + computeClaudeCodeFingerprint([]byte(`{"messages":[]}`), "2.1.22"),
|
|
},
|
|
{
|
|
name: "no billing header in system",
|
|
body: `{"system":[{"type":"text","text":"You are Claude Code."}],"messages":[]}`,
|
|
userAgent: "claude-cli/2.1.22",
|
|
unchanged: true,
|
|
},
|
|
{
|
|
name: "no system field",
|
|
body: `{"messages":[]}`,
|
|
userAgent: "claude-cli/2.1.22",
|
|
unchanged: true,
|
|
},
|
|
{
|
|
name: "user-agent without version",
|
|
body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`,
|
|
userAgent: "Mozilla/5.0",
|
|
unchanged: true,
|
|
},
|
|
{
|
|
name: "empty user-agent",
|
|
body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`,
|
|
userAgent: "",
|
|
unchanged: true,
|
|
},
|
|
{
|
|
name: "version already matches",
|
|
body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.22; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`,
|
|
userAgent: "claude-cli/2.1.22",
|
|
unchanged: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := syncBillingHeaderVersion([]byte(tt.body), tt.userAgent)
|
|
if tt.unchanged {
|
|
assert.Equal(t, tt.body, string(result), "body should remain unchanged")
|
|
} else {
|
|
assert.Contains(t, string(result), tt.wantSub)
|
|
// Ensure old semver is gone
|
|
assert.NotContains(t, string(result), "cc_version=2.1.81")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSyncBillingHeaderVersion_RecomputesSuffixAndIsIdempotent(t *testing.T) {
|
|
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81.df2; cc_entrypoint=cli;"}],"messages":[{"role":"user","content":"hello world"}]}`)
|
|
version := "2.1.22"
|
|
result := syncBillingHeaderVersion(body, "claude-cli/"+version)
|
|
require.Contains(t, gjson.GetBytes(result, "system.0.text").String(),
|
|
"cc_version="+version+"."+computeClaudeCodeFingerprint(body, version)+";")
|
|
require.Equal(t, string(result), string(syncBillingHeaderVersion(result, "claude-cli/"+version)))
|
|
require.JSONEq(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(result, "messages").Raw)
|
|
}
|
|
|
|
func TestBuildOAuthRequest_BillingMatchesWireUserAgent(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
for _, endpoint := range []string{"messages", "count_tokens"} {
|
|
for _, tc := range []struct {
|
|
name string
|
|
mimic bool
|
|
identity bool
|
|
disableFP bool
|
|
}{
|
|
{name: "mimic_overrides_cached_version", mimic: true, identity: true},
|
|
{name: "mimic_without_identity", mimic: true},
|
|
{name: "mimic_with_fingerprint_disabled", mimic: true, identity: true, disableFP: true},
|
|
{name: "passthrough_uses_cached_version", identity: true},
|
|
} {
|
|
t.Run(endpoint+"/"+tc.name, 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)
|
|
|
|
cfg := &config.Config{}
|
|
svc := &GatewayService{cfg: cfg}
|
|
cachedUA := "claude-cli/2.9.0 (external, cli)"
|
|
if tc.identity {
|
|
svc.identityService = NewIdentityService(&stubIdentityCache{fingerprint: &Fingerprint{
|
|
UserAgent: cachedUA, ClientID: "test-client", UpdatedAt: time.Now().Unix(),
|
|
}})
|
|
}
|
|
if tc.disableFP {
|
|
svc.settingService = NewSettingService(&gatewayTTLSettingRepo{data: map[string]string{
|
|
SettingKeyEnableFingerprintUnification: "false",
|
|
}}, cfg)
|
|
}
|
|
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, tc.mimic)
|
|
} else {
|
|
req, wireBody, err = svc.buildCountTokensRequest(context.Background(), c, account,
|
|
body, "test-token", "oauth", "claude-haiku-4-5", tc.mimic)
|
|
}
|
|
require.NoError(t, err)
|
|
defer func() { require.NoError(t, req.Body.Close()) }()
|
|
wantUA := cachedUA
|
|
if tc.mimic {
|
|
wantUA = claude.DefaultHeaders()["User-Agent"]
|
|
}
|
|
require.Equal(t, wantUA, getHeaderRaw(req.Header, "User-Agent"))
|
|
version := ExtractCLIVersion(wantUA)
|
|
require.Contains(t, gjson.GetBytes(wireBody, "system.0.text").String(),
|
|
"cc_version="+version+"."+computeClaudeCodeFingerprint(wireBody, version)+";")
|
|
actualBody, err := io.ReadAll(req.Body)
|
|
require.NoError(t, err)
|
|
require.Equal(t, wireBody, actualBody)
|
|
})
|
|
}
|
|
}
|
|
}
|