mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
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,避免缓存翻转 瞬间头体版本不一致被判非正版客户端 - 管理后台设置页新增配置区块,含中英文案与「当前同步到」只读展示
This commit is contained in:
@@ -99,6 +99,7 @@ func provideCleanup(
|
||||
accountExpiry *service.AccountExpiryService,
|
||||
cnProviderBalanceCheck *service.CNProviderBalanceCheckService,
|
||||
codexVersionSync *service.OpenAICodexVersionSyncService,
|
||||
claudeCodeVersionSync *service.ClaudeCodeVersionSyncService,
|
||||
proxyExpiry *service.ProxyExpiryService,
|
||||
subscriptionExpiry *service.SubscriptionExpiryService,
|
||||
usageCleanup *service.UsageCleanupService,
|
||||
@@ -272,6 +273,10 @@ func provideCleanup(
|
||||
codexVersionSync.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"ClaudeCodeVersionSyncService", func() error {
|
||||
claudeCodeVersionSync.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"ProxyExpiryService", func() error {
|
||||
proxyExpiry.Stop()
|
||||
return nil
|
||||
|
||||
@@ -341,6 +341,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
||||
cnProviderBalanceCheckService := service.ProvideCNProviderBalanceCheckService(accountRepository, cnProviderBalanceService, cnProviderQuotaService, configConfig)
|
||||
openAICodexVersionSyncService := service.ProvideOpenAICodexVersionSyncService(settingRepository, settingService, gitHubReleaseClient)
|
||||
claudeCodeVersionSyncService := service.ProvideClaudeCodeVersionSyncService(settingRepository, settingService, gitHubReleaseClient)
|
||||
proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository)
|
||||
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db)
|
||||
batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig)
|
||||
@@ -350,7 +351,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher)
|
||||
channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService)
|
||||
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager)
|
||||
application := &Application{
|
||||
Server: httpServer,
|
||||
PromptAudit: promptService,
|
||||
@@ -405,6 +406,7 @@ func provideCleanup(
|
||||
accountExpiry *service.AccountExpiryService,
|
||||
cnProviderBalanceCheck *service.CNProviderBalanceCheckService,
|
||||
codexVersionSync *service.OpenAICodexVersionSyncService,
|
||||
claudeCodeVersionSync *service.ClaudeCodeVersionSyncService,
|
||||
proxyExpiry *service.ProxyExpiryService,
|
||||
subscriptionExpiry *service.SubscriptionExpiryService,
|
||||
usageCleanup *service.UsageCleanupService,
|
||||
@@ -577,6 +579,10 @@ func provideCleanup(
|
||||
codexVersionSync.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"ClaudeCodeVersionSyncService", func() error {
|
||||
claudeCodeVersionSync.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"ProxyExpiryService", func() error {
|
||||
proxyExpiry.Stop()
|
||||
return nil
|
||||
|
||||
@@ -41,6 +41,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
)
|
||||
accountExpirySvc := service.NewAccountExpiryService(nil, time.Second)
|
||||
codexVersionSyncSvc := service.NewOpenAICodexVersionSyncService(nil, nil, nil, time.Second)
|
||||
claudeCodeVersionSyncSvc := service.NewClaudeCodeVersionSyncService(nil, nil, nil, time.Second)
|
||||
proxyExpirySvc := service.NewProxyExpiryService(nil, time.Second)
|
||||
subscriptionExpirySvc := service.NewSubscriptionExpiryService(nil, time.Second)
|
||||
pricingSvc := service.NewPricingService(cfg, nil)
|
||||
@@ -68,6 +69,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
accountExpirySvc,
|
||||
nil, // cnProviderBalanceCheck
|
||||
codexVersionSyncSvc,
|
||||
claudeCodeVersionSyncSvc,
|
||||
proxyExpirySvc,
|
||||
subscriptionExpirySvc,
|
||||
&service.UsageCleanupService{},
|
||||
|
||||
@@ -303,6 +303,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
OpenAICodexClientVersion: settings.OpenAICodexClientVersion,
|
||||
OpenAICodexClientVersionSynced: settings.OpenAICodexClientVersionSynced,
|
||||
OpenAICodexVersionAutoSyncEnabled: settings.OpenAICodexVersionAutoSyncEnabled,
|
||||
ClaudeCodeClientVersion: settings.ClaudeCodeClientVersion,
|
||||
ClaudeCodeClientVersionSynced: settings.ClaudeCodeClientVersionSynced,
|
||||
ClaudeCodeVersionAutoSyncEnabled: settings.ClaudeCodeVersionAutoSyncEnabled,
|
||||
MinCodexVersion: settings.MinCodexVersion,
|
||||
MaxCodexVersion: settings.MaxCodexVersion,
|
||||
CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist,
|
||||
|
||||
@@ -491,6 +491,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.OpenAICodexVersionAutoSyncEnabled != after.OpenAICodexVersionAutoSyncEnabled {
|
||||
changed = append(changed, "openai_codex_version_auto_sync_enabled")
|
||||
}
|
||||
if before.ClaudeCodeClientVersion != after.ClaudeCodeClientVersion {
|
||||
changed = append(changed, "claude_code_client_version")
|
||||
}
|
||||
if before.ClaudeCodeVersionAutoSyncEnabled != after.ClaudeCodeVersionAutoSyncEnabled {
|
||||
changed = append(changed, "claude_code_version_auto_sync_enabled")
|
||||
}
|
||||
if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource {
|
||||
changed = append(changed, "payment_visible_method_alipay_source")
|
||||
}
|
||||
|
||||
@@ -257,6 +257,8 @@ type UpdateSettingsRequest struct {
|
||||
OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
|
||||
OpenAICodexClientVersion *string `json:"openai_codex_client_version"`
|
||||
OpenAICodexVersionAutoSyncEnabled *bool `json:"openai_codex_version_auto_sync_enabled"`
|
||||
ClaudeCodeClientVersion *string `json:"claude_code_client_version"`
|
||||
ClaudeCodeVersionAutoSyncEnabled *bool `json:"claude_code_version_auto_sync_enabled"`
|
||||
|
||||
// codex_cli_only 加固(global-only)
|
||||
MinCodexVersion string `json:"min_codex_version"`
|
||||
@@ -1455,6 +1457,15 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
req.OpenAICodexClientVersion = &normalized
|
||||
}
|
||||
if req.ClaudeCodeClientVersion != nil {
|
||||
// 该值会被拼进出站 User-Agent 与 billing attribution,必须是合法版本号;空串表示跟随自动同步。
|
||||
normalized := strings.TrimSpace(*req.ClaudeCodeClientVersion)
|
||||
if normalized != "" && service.NormalizeClaudeCodeClientVersion(normalized) == "" {
|
||||
response.Error(c, http.StatusBadRequest, "claude_code_client_version must be empty or a valid version (e.g. 2.1.258)")
|
||||
return
|
||||
}
|
||||
req.ClaudeCodeClientVersion = &normalized
|
||||
}
|
||||
|
||||
// codex_cli_only 加固:最低/最高 Codex 版本(空=禁用,或合法 semver;max>=min)
|
||||
if req.MinCodexVersion != "" && !semverPattern.MatchString(req.MinCodexVersion) {
|
||||
@@ -1768,6 +1779,20 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.OpenAICodexVersionAutoSyncEnabled
|
||||
}(),
|
||||
ClaudeCodeClientVersion: func() string {
|
||||
if req.ClaudeCodeClientVersion != nil {
|
||||
return *req.ClaudeCodeClientVersion
|
||||
}
|
||||
return previousSettings.ClaudeCodeClientVersion
|
||||
}(),
|
||||
// 同步值由自动同步任务独占写入,面板保存时原样带回,避免被清空。
|
||||
ClaudeCodeClientVersionSynced: previousSettings.ClaudeCodeClientVersionSynced,
|
||||
ClaudeCodeVersionAutoSyncEnabled: func() bool {
|
||||
if req.ClaudeCodeVersionAutoSyncEnabled != nil {
|
||||
return *req.ClaudeCodeVersionAutoSyncEnabled
|
||||
}
|
||||
return previousSettings.ClaudeCodeVersionAutoSyncEnabled
|
||||
}(),
|
||||
MinCodexVersion: strings.TrimSpace(req.MinCodexVersion),
|
||||
MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion),
|
||||
CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist),
|
||||
@@ -2311,6 +2336,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
OpenAICodexClientVersion: updatedSettings.OpenAICodexClientVersion,
|
||||
OpenAICodexClientVersionSynced: updatedSettings.OpenAICodexClientVersionSynced,
|
||||
OpenAICodexVersionAutoSyncEnabled: updatedSettings.OpenAICodexVersionAutoSyncEnabled,
|
||||
ClaudeCodeClientVersion: updatedSettings.ClaudeCodeClientVersion,
|
||||
ClaudeCodeClientVersionSynced: updatedSettings.ClaudeCodeClientVersionSynced,
|
||||
ClaudeCodeVersionAutoSyncEnabled: updatedSettings.ClaudeCodeVersionAutoSyncEnabled,
|
||||
MinCodexVersion: updatedSettings.MinCodexVersion,
|
||||
MaxCodexVersion: updatedSettings.MaxCodexVersion,
|
||||
CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist,
|
||||
|
||||
@@ -217,6 +217,9 @@ type SystemSettings struct {
|
||||
OpenAICodexClientVersion string `json:"openai_codex_client_version"`
|
||||
OpenAICodexClientVersionSynced string `json:"openai_codex_client_version_synced"`
|
||||
OpenAICodexVersionAutoSyncEnabled bool `json:"openai_codex_version_auto_sync_enabled"`
|
||||
ClaudeCodeClientVersion string `json:"claude_code_client_version"`
|
||||
ClaudeCodeClientVersionSynced string `json:"claude_code_client_version_synced"`
|
||||
ClaudeCodeVersionAutoSyncEnabled bool `json:"claude_code_version_auto_sync_enabled"`
|
||||
|
||||
// codex_cli_only 加固
|
||||
MinCodexVersion string `json:"min_codex_version"`
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package claude
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
// cliVersionResolver 返回运行期生效的 Claude CLI 版本号(管理员面板手动值 →
|
||||
// 后台同步值)。由 SettingService 在装配时注入(wire.go),解析器内部自带
|
||||
// TTL 缓存,热路径不触库。
|
||||
//
|
||||
// 未注入时(所有不经过 wire 的单元测试)EffectiveCLIVersion() 与 CLIVersion()
|
||||
// 完全等价,行为不变。
|
||||
type cliVersionResolverFunc func() string
|
||||
|
||||
var cliVersionResolver atomic.Pointer[cliVersionResolverFunc]
|
||||
|
||||
// SetCLIVersionResolver 注入运行期版本解析器;传 nil 可撤销注入。
|
||||
func SetCLIVersionResolver(resolver func() string) {
|
||||
if resolver == nil {
|
||||
cliVersionResolver.Store(nil)
|
||||
return
|
||||
}
|
||||
r := cliVersionResolverFunc(resolver)
|
||||
cliVersionResolver.Store(&r)
|
||||
}
|
||||
|
||||
// EffectiveCLIVersion 返回当前生效的 Claude CLI 版本号。
|
||||
// resolver 返回值必须通过 IsSupportedCLIVersion 校验(严格三段 semver 且不低于
|
||||
// 内置基线),否则回退 CLIVersion()(环境变量覆盖 → 内置基线)。
|
||||
// 运行期值恒 >= 内置基线,identity_service 的"只升不降"语义不受影响。
|
||||
func EffectiveCLIVersion() string {
|
||||
if r := cliVersionResolver.Load(); r != nil {
|
||||
if v := (*r)(); IsSupportedCLIVersion(v) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return CLIVersion()
|
||||
}
|
||||
|
||||
// DefaultUserAgent 返回 Claude Code CLI 伪装 User-Agent。
|
||||
// 所有出站路径都必须经本函数取 UA,保证 User-Agent 头与请求体 billing
|
||||
// attribution 块中的 cc_version 始终源自同一个版本号。
|
||||
func DefaultUserAgent() string {
|
||||
return "claude-cli/" + EffectiveCLIVersion() + " (external, cli)"
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withCLIVersionResolver 注入测试解析器并在用例结束时还原,避免用例间污染。
|
||||
func withCLIVersionResolver(t *testing.T, resolver func() string) {
|
||||
t.Helper()
|
||||
SetCLIVersionResolver(resolver)
|
||||
t.Cleanup(func() { SetCLIVersionResolver(nil) })
|
||||
}
|
||||
|
||||
// a. resolver 未注入时,EffectiveCLIVersion() 必须等于 CLIVersion(),
|
||||
// 且 DefaultHeaders()["User-Agent"] 与改造前的 init 固化值完全一致。
|
||||
func TestEffectiveCLIVersionDefaultsToCLIVersion(t *testing.T) {
|
||||
SetCLIVersionResolver(nil)
|
||||
if got := EffectiveCLIVersion(); got != CLIVersion() {
|
||||
t.Fatalf("EffectiveCLIVersion() = %q, want CLIVersion() = %q", got, CLIVersion())
|
||||
}
|
||||
wantUA := "claude-cli/" + CLIVersion() + " (external, cli)"
|
||||
if got := DefaultHeaders()["User-Agent"]; got != wantUA {
|
||||
t.Fatalf("DefaultHeaders()[User-Agent] = %q, want %q", got, wantUA)
|
||||
}
|
||||
}
|
||||
|
||||
// b. 注入返回更高版本的 resolver 后,DefaultHeaders()["User-Agent"] 跟随变化。
|
||||
func TestEffectiveCLIVersionFollowsResolver(t *testing.T) {
|
||||
const upgraded = "9.9.9"
|
||||
withCLIVersionResolver(t, func() string { return upgraded })
|
||||
|
||||
if got := EffectiveCLIVersion(); got != upgraded {
|
||||
t.Fatalf("EffectiveCLIVersion() = %q, want %q", got, upgraded)
|
||||
}
|
||||
wantUA := "claude-cli/" + upgraded + " (external, cli)"
|
||||
if got := DefaultHeaders()["User-Agent"]; got != wantUA {
|
||||
t.Fatalf("DefaultHeaders()[User-Agent] = %q, want %q", got, wantUA)
|
||||
}
|
||||
if got := DefaultHeaders()["X-App"]; got != "cli" {
|
||||
t.Fatalf("DefaultHeaders()[X-App] = %q, want cli", got)
|
||||
}
|
||||
}
|
||||
|
||||
// c. resolver 返回非法值(空串、非 semver、低于内置基线)时回退 CLIVersion()。
|
||||
func TestEffectiveCLIVersionFallsBackOnInvalidResolverValues(t *testing.T) {
|
||||
for name, invalid := range map[string]string{
|
||||
"empty": "",
|
||||
"not semver": "abc",
|
||||
"below baseline": "1.0.0",
|
||||
"whitespace": " ",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
withCLIVersionResolver(t, func() string { return invalid })
|
||||
if got := EffectiveCLIVersion(); got != CLIVersion() {
|
||||
t.Fatalf("resolver %q: EffectiveCLIVersion() = %q, want fallback %q", invalid, got, CLIVersion())
|
||||
}
|
||||
ua := DefaultHeaders()["User-Agent"]
|
||||
if !strings.HasPrefix(ua, "claude-cli/"+CLIVersion()+" ") {
|
||||
t.Fatalf("UA %q does not carry fallback version %q", ua, CLIVersion())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 撤销注入(传 nil)后回到 CLIVersion()。
|
||||
func TestSetCLIVersionResolverNilRestoresFallback(t *testing.T) {
|
||||
withCLIVersionResolver(t, func() string { return "9.9.9" })
|
||||
SetCLIVersionResolver(nil)
|
||||
if got := EffectiveCLIVersion(); got != CLIVersion() {
|
||||
t.Fatalf("after nil resolver: EffectiveCLIVersion() = %q, want %q", got, CLIVersion())
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func TestResolveCLIVersion(t *testing.T) {
|
||||
// 两者由不同代码路径写入同一个请求,不一致会被上游判为非正版客户端。
|
||||
func TestDefaultHeadersUserAgentMatchesCLIVersion(t *testing.T) {
|
||||
want := "claude-cli/" + CLIVersion() + " (external, cli)"
|
||||
if got := DefaultHeaders["User-Agent"]; got != want {
|
||||
if got := DefaultHeaders()["User-Agent"]; got != want {
|
||||
t.Fatalf("DefaultHeaders[User-Agent] = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,21 +108,26 @@ func FullClaudeCodeMimicryBetas() []string {
|
||||
}
|
||||
|
||||
// DefaultHeaders 是 Claude Code 客户端默认请求头。
|
||||
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/" + CLIVersion() + " (external, cli)",
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Package-Version": "0.94.0",
|
||||
"X-Stainless-OS": "Linux",
|
||||
"X-Stainless-Arch": "arm64",
|
||||
"X-Stainless-Runtime": "node",
|
||||
"X-Stainless-Runtime-Version": "v24.3.0",
|
||||
"X-Stainless-Retry-Count": "0",
|
||||
"X-Stainless-Timeout": "600",
|
||||
"X-App": "cli",
|
||||
"Anthropic-Dangerous-Direct-Browser-Access": "true",
|
||||
// 每次调用现构造:User-Agent 走 DefaultUserAgent()(运行期可变版本号),
|
||||
// 不再在包 init 时固化。同一次请求内应只取一次 UA 字符串并在出站头与
|
||||
// billing 两条路径间复用,避免版本缓存翻转瞬间头/体不一致。
|
||||
func DefaultHeaders() map[string]string {
|
||||
return 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": DefaultUserAgent(),
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Package-Version": "0.94.0",
|
||||
"X-Stainless-OS": "Linux",
|
||||
"X-Stainless-Arch": "arm64",
|
||||
"X-Stainless-Runtime": "node",
|
||||
"X-Stainless-Runtime-Version": "v24.3.0",
|
||||
"X-Stainless-Retry-Count": "0",
|
||||
"X-Stainless-Timeout": "600",
|
||||
"X-App": "cli",
|
||||
"Anthropic-Dangerous-Direct-Browser-Access": "true",
|
||||
}
|
||||
}
|
||||
|
||||
// Model 表示一个 Claude 模型
|
||||
|
||||
@@ -21,7 +21,7 @@ const fable51MinCLIVersion = "2.1.251"
|
||||
func TestCLICurrentVersionMatchesDefaultUserAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ua := DefaultHeaders["User-Agent"]
|
||||
ua := DefaultHeaders()["User-Agent"]
|
||||
const prefix = "claude-cli/"
|
||||
require.True(t, strings.HasPrefix(ua, prefix), "unexpected User-Agent: %q", ua)
|
||||
|
||||
|
||||
@@ -299,7 +299,7 @@ func (s *AccountTestService) validateUpstreamBaseURL(raw string) (string, error)
|
||||
}
|
||||
|
||||
// generateSessionString generates a Claude Code style session string.
|
||||
// The output format is determined by the UA version in claude.DefaultHeaders,
|
||||
// The output format is determined by the UA version in claude.DefaultHeaders(),
|
||||
// ensuring consistency between the user_id format and the UA sent to upstream.
|
||||
func generateSessionString() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
@@ -308,7 +308,7 @@ func generateSessionString() (string, error) {
|
||||
}
|
||||
hex64 := hex.EncodeToString(b)
|
||||
sessionUUID := uuid.New().String()
|
||||
uaVersion := ExtractCLIVersion(claude.DefaultHeaders["User-Agent"])
|
||||
uaVersion := ExtractCLIVersion(claude.DefaultHeaders()["User-Agent"])
|
||||
return FormatMetadataUserID(hex64, "", sessionUUID, uaVersion), nil
|
||||
}
|
||||
|
||||
@@ -562,7 +562,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
// Apply Claude Code client headers
|
||||
for key, value := range claude.DefaultHeaders {
|
||||
for key, value := range claude.DefaultHeaders() {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Co
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
for key, value := range claude.DefaultHeaders {
|
||||
for key, value := range claude.DefaultHeaders() {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader)
|
||||
@@ -263,7 +263,7 @@ func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, a
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
for key, value := range claude.DefaultHeaders {
|
||||
for key, value := range claude.DefaultHeaders() {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
// Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer,其余保持
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// claudeCodeVersionSyncInterval 自动同步间隔。Claude Code CLI 发版频率是小时级,
|
||||
// 1 小时足够跟上官方节奏,同时把对 GitHub API 的调用压到每天 24 次。
|
||||
claudeCodeVersionSyncInterval = time.Hour
|
||||
// claudeCodeVersionSyncTimeout 单次同步的整体超时。
|
||||
claudeCodeVersionSyncTimeout = 30 * time.Second
|
||||
// claudeCodeVersionSyncRepo 官方 Claude Code CLI 仓库。
|
||||
claudeCodeVersionSyncRepo = "anthropics/claude-code"
|
||||
// claudeCodeVersionSyncPerPage 回退路径单次拉取的 release 数量(主路径见
|
||||
// fetchLatestStableVersion)。
|
||||
claudeCodeVersionSyncPerPage = 30
|
||||
// claudeCodeVersionTagPrefix 客户端 release 的 tag 前缀(如 v2.1.280)。
|
||||
// 同仓库的 tag 家族单一,但显式按前缀过滤仍能挡住上游改版或混入的其他 tag。
|
||||
claudeCodeVersionTagPrefix = "v"
|
||||
)
|
||||
|
||||
// ClaudeCodeVersionSyncService 周期性把官方 Claude Code CLI 的最新稳定版版本号同步到设置,
|
||||
// 供出站规范身份使用,避免为了跟上游版本而发新版本。
|
||||
//
|
||||
// 同步值写入 SettingKeyClaudeCodeClientVersionSynced(本服务独占写入);管理员在面板填写的
|
||||
// SettingKeyClaudeCodeClientVersion 优先级更高,因此手工固定版本不会被同步覆盖。
|
||||
type ClaudeCodeVersionSyncService struct {
|
||||
settingRepo SettingRepository
|
||||
settingService *SettingService
|
||||
githubClient GitHubReleaseClient
|
||||
interval time.Duration
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewClaudeCodeVersionSyncService(
|
||||
settingRepo SettingRepository,
|
||||
settingService *SettingService,
|
||||
githubClient GitHubReleaseClient,
|
||||
interval time.Duration,
|
||||
) *ClaudeCodeVersionSyncService {
|
||||
return &ClaudeCodeVersionSyncService{
|
||||
settingRepo: settingRepo,
|
||||
settingService: settingService,
|
||||
githubClient: githubClient,
|
||||
interval: interval,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ClaudeCodeVersionSyncService) Start() {
|
||||
if s == nil || s.settingRepo == nil || s.githubClient == nil || s.interval <= 0 {
|
||||
return
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
ticker := time.NewTicker(s.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
s.runInitial()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.runOnce()
|
||||
case <-s.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *ClaudeCodeVersionSyncService) Stop() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.stopOnce.Do(func() {
|
||||
close(s.stopCh)
|
||||
})
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
// runInitial 执行启动时的首次同步。若同步值在一个同步周期内已被刷新过则跳过:
|
||||
// 频繁重启、滚动发布或崩溃重启会让「启动即同步」放大成对 GitHub 的连续请求,
|
||||
// 而同步间隔只有 1 小时,重启后没有立刻重新拉取的必要。
|
||||
func (s *ClaudeCodeVersionSyncService) runInitial() {
|
||||
if s.syncedWithinInterval() {
|
||||
return
|
||||
}
|
||||
s.runOnce()
|
||||
}
|
||||
|
||||
// syncedWithinInterval 判断已同步值是否仍在一个同步周期内。
|
||||
// 借设置行自身的 UpdatedAt 判断,无需额外记录时间戳的设置项。
|
||||
// 读取失败或尚无有效同步值时返回 false,让启动同步照常执行。
|
||||
func (s *ClaudeCodeVersionSyncService) syncedWithinInterval() bool {
|
||||
if s.interval <= 0 {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), claudeCodeVersionSyncTimeout)
|
||||
defer cancel()
|
||||
|
||||
setting, err := s.settingRepo.Get(ctx, SettingKeyClaudeCodeClientVersionSynced)
|
||||
if err != nil || setting == nil || setting.UpdatedAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
if NormalizeClaudeCodeClientVersion(setting.Value) == "" {
|
||||
return false
|
||||
}
|
||||
return time.Since(setting.UpdatedAt) < s.interval
|
||||
}
|
||||
|
||||
func (s *ClaudeCodeVersionSyncService) runOnce() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), claudeCodeVersionSyncTimeout)
|
||||
defer cancel()
|
||||
|
||||
if !s.autoSyncEnabled(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
latest := s.fetchLatestStableVersion(ctx)
|
||||
if latest == "" {
|
||||
return
|
||||
}
|
||||
|
||||
current := NormalizeClaudeCodeClientVersion(s.currentSyncedVersion(ctx))
|
||||
// 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步的版本号降级。
|
||||
if current != "" && CompareVersions(latest, current) <= 0 {
|
||||
return
|
||||
}
|
||||
if err := s.settingRepo.Set(ctx, SettingKeyClaudeCodeClientVersionSynced, latest); err != nil {
|
||||
slog.Warn("claude_code_version_sync_persist_failed", "version", latest, "error", err)
|
||||
return
|
||||
}
|
||||
s.settingService.InvalidateClaudeCodeClientVersionCache()
|
||||
slog.Info("claude_code_version_synced", "previous", current, "version", latest)
|
||||
}
|
||||
|
||||
// fetchLatestStableVersion 取官方最新稳定版 CLI 版本号;取不到时返回空串,
|
||||
// 由调用方保持既有值(不清空、不降级),各失败分支自行落日志。
|
||||
//
|
||||
// 主路径 /releases/latest:该端点本身就排除 draft 与 prerelease,直接给出最新正式发布。
|
||||
//
|
||||
// 回退列表扫描:latest 抓取失败或返回值未通过过滤时,扫一页 release 继续跟随官方版本,
|
||||
// 否则版本号会静默停更。两条路径共用同一套过滤(前缀 / draft / prerelease / 版本号形态),
|
||||
// 语义不会分叉。
|
||||
func (s *ClaudeCodeVersionSyncService) fetchLatestStableVersion(ctx context.Context) string {
|
||||
release, err := s.githubClient.FetchLatestRelease(ctx, claudeCodeVersionSyncRepo)
|
||||
if err != nil {
|
||||
slog.Warn("claude_code_version_sync_latest_fetch_failed", "error", err)
|
||||
} else if version := latestClaudeCodeStableReleaseVersion([]*GitHubRelease{release}); version != "" {
|
||||
return version
|
||||
}
|
||||
|
||||
// 主路径没拿到可用版本(抓取失败,或 latest 未通过过滤)。
|
||||
releases, err := s.githubClient.FetchRecentReleases(ctx, claudeCodeVersionSyncRepo, claudeCodeVersionSyncPerPage)
|
||||
if err != nil {
|
||||
slog.Warn("claude_code_version_sync_fetch_failed", "error", err)
|
||||
return ""
|
||||
}
|
||||
version := latestClaudeCodeStableReleaseVersion(releases)
|
||||
if version == "" {
|
||||
slog.Warn("claude_code_version_sync_no_stable_release", "repo", claudeCodeVersionSyncRepo)
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// autoSyncEnabled 读取面板开关。缺失或空值视为开启,与设置默认值一致;
|
||||
// 读取失败时保持开启,避免一次数据库抖动就静默停掉版本跟随。
|
||||
func (s *ClaudeCodeVersionSyncService) autoSyncEnabled(ctx context.Context) bool {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyClaudeCodeVersionAutoSyncEnabled)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(value) == "true"
|
||||
}
|
||||
|
||||
func (s *ClaudeCodeVersionSyncService) currentSyncedVersion(ctx context.Context) string {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyClaudeCodeClientVersionSynced)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// latestClaudeCodeStableReleaseVersion 从 release 列表里挑出最大的稳定版 CLI 版本号。
|
||||
// 过滤条件:tag 前缀为 v、非草稿、非预发布、版本号须通过 NormalizeClaudeCodeClientVersion
|
||||
// (严格三段纯数字 semver,且不低于内置基线)。取最大值而非最新发布,
|
||||
// 避免重新发布历史 tag 造成回退。
|
||||
// 主路径的单条 /releases/latest 结果也走本函数(单元素切片),保证两条取数路径的过滤语义一致。
|
||||
func latestClaudeCodeStableReleaseVersion(releases []*GitHubRelease) string {
|
||||
best := ""
|
||||
for _, release := range releases {
|
||||
if release == nil || release.Draft || release.Prerelease {
|
||||
continue
|
||||
}
|
||||
tag := strings.TrimSpace(release.TagName)
|
||||
if !strings.HasPrefix(tag, claudeCodeVersionTagPrefix) {
|
||||
continue
|
||||
}
|
||||
version := NormalizeClaudeCodeClientVersion(strings.TrimPrefix(tag, claudeCodeVersionTagPrefix))
|
||||
if version == "" || strings.Contains(version, "-") {
|
||||
continue
|
||||
}
|
||||
if best == "" || CompareVersions(version, best) > 0 {
|
||||
best = version
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- mock: 只实现同步任务用到的读写,其余方法不应被调用 ---
|
||||
|
||||
type claudeCodeVersionSyncSettingRepoStub struct {
|
||||
SettingRepository // 嵌入接口,未实现的方法会 panic(不应被调用)
|
||||
|
||||
mu sync.Mutex
|
||||
values map[string]string
|
||||
getErr error
|
||||
setErr error
|
||||
updatedAt time.Time
|
||||
writes []string
|
||||
}
|
||||
|
||||
func newClaudeCodeVersionSyncSettingRepoStub(values map[string]string) *claudeCodeVersionSyncSettingRepoStub {
|
||||
if values == nil {
|
||||
values = map[string]string{}
|
||||
}
|
||||
return &claudeCodeVersionSyncSettingRepoStub{values: values}
|
||||
}
|
||||
|
||||
func (r *claudeCodeVersionSyncSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.getErr != nil {
|
||||
return "", r.getErr
|
||||
}
|
||||
return r.values[key], nil
|
||||
}
|
||||
|
||||
func (r *claudeCodeVersionSyncSettingRepoStub) Set(_ context.Context, key, value string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.setErr != nil {
|
||||
return r.setErr
|
||||
}
|
||||
r.values[key] = value
|
||||
r.writes = append(r.writes, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *claudeCodeVersionSyncSettingRepoStub) syncedWrites() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.writes...)
|
||||
}
|
||||
|
||||
func (r *claudeCodeVersionSyncSettingRepoStub) Get(_ context.Context, key string) (*Setting, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.getErr != nil {
|
||||
return nil, r.getErr
|
||||
}
|
||||
value, ok := r.values[key]
|
||||
if !ok {
|
||||
return nil, ErrSettingNotFound
|
||||
}
|
||||
return &Setting{Key: key, Value: value, UpdatedAt: r.updatedAt}, nil
|
||||
}
|
||||
|
||||
type claudeCodeVersionSyncGitHubStub struct {
|
||||
GitHubReleaseClient // 嵌入接口,未实现的方法会 panic(不应被调用)
|
||||
|
||||
// 主路径 /releases/latest;latest 为 nil 且 latestErr 为 nil 时模拟「拿不到可用值」,
|
||||
// 使调用落到回退的列表扫描上。
|
||||
latest *GitHubRelease
|
||||
latestErr error
|
||||
latestCalls int
|
||||
|
||||
releases []*GitHubRelease
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (c *claudeCodeVersionSyncGitHubStub) FetchLatestRelease(_ context.Context, _ string) (*GitHubRelease, error) {
|
||||
c.latestCalls++
|
||||
if c.latestErr != nil {
|
||||
return nil, c.latestErr
|
||||
}
|
||||
return c.latest, nil
|
||||
}
|
||||
|
||||
func (c *claudeCodeVersionSyncGitHubStub) FetchRecentReleases(_ context.Context, _ string, _ int) ([]*GitHubRelease, error) {
|
||||
c.calls++
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
return c.releases, nil
|
||||
}
|
||||
|
||||
func newClaudeCodeVersionSyncService(
|
||||
repo SettingRepository,
|
||||
github GitHubReleaseClient,
|
||||
) *ClaudeCodeVersionSyncService {
|
||||
return NewClaudeCodeVersionSyncService(repo, &SettingService{}, github, claudeCodeVersionSyncInterval)
|
||||
}
|
||||
|
||||
// 同仓库可能混入非 v 前缀 tag 与预发布 / 草稿,必须只认 v 前缀的稳定版,
|
||||
// 否则会把无关 tag 的版本号当成客户端版本同步出去。
|
||||
func TestLatestClaudeCodeStableReleaseVersion(t *testing.T) {
|
||||
releases := []*GitHubRelease{
|
||||
{TagName: "v2.1.281-beta.1", Prerelease: true},
|
||||
{TagName: "v2.1.280"},
|
||||
{TagName: "v2.1.279"},
|
||||
{TagName: "v2.999.0", Draft: true},
|
||||
{TagName: "not-a-tag"},
|
||||
nil,
|
||||
}
|
||||
|
||||
require.Equal(t, "2.1.280", latestClaudeCodeStableReleaseVersion(releases))
|
||||
require.Empty(t, latestClaudeCodeStableReleaseVersion(nil))
|
||||
require.Empty(t, latestClaudeCodeStableReleaseVersion([]*GitHubRelease{{TagName: "not-a-tag"}}))
|
||||
// 预发布 tag 即使漏标 Prerelease 也要被版本号后缀挡住。
|
||||
require.Empty(t, latestClaudeCodeStableReleaseVersion([]*GitHubRelease{{TagName: "v2.1.281-beta.1"}}))
|
||||
// 低于内置基线的版本号即使形态合法也不得采信。
|
||||
require.Empty(t, latestClaudeCodeStableReleaseVersion([]*GitHubRelease{{TagName: "v2.1.9"}}))
|
||||
}
|
||||
|
||||
func TestClaudeCodeVersionSyncWritesLatestStableVersion(t *testing.T) {
|
||||
repo := newClaudeCodeVersionSyncSettingRepoStub(nil)
|
||||
github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{
|
||||
{TagName: "v2.1.279"},
|
||||
{TagName: "v2.1.280"},
|
||||
}}
|
||||
|
||||
newClaudeCodeVersionSyncService(repo, github).runOnce()
|
||||
|
||||
require.Equal(t, []string{"2.1.280"}, repo.syncedWrites())
|
||||
}
|
||||
|
||||
// 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步版本降级。
|
||||
func TestClaudeCodeVersionSyncNeverMovesBackwards(t *testing.T) {
|
||||
repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{
|
||||
SettingKeyClaudeCodeClientVersionSynced: "2.1.280",
|
||||
})
|
||||
github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.279"}}}
|
||||
|
||||
newClaudeCodeVersionSyncService(repo, github).runOnce()
|
||||
|
||||
require.Empty(t, repo.syncedWrites())
|
||||
}
|
||||
|
||||
func TestClaudeCodeVersionSyncSkippedWhenDisabled(t *testing.T) {
|
||||
repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{
|
||||
SettingKeyClaudeCodeVersionAutoSyncEnabled: "false",
|
||||
})
|
||||
github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.280"}}}
|
||||
|
||||
newClaudeCodeVersionSyncService(repo, github).runOnce()
|
||||
|
||||
require.Zero(t, github.latestCalls, "关闭自动同步后不应请求上游")
|
||||
require.Zero(t, github.calls, "关闭自动同步后不应请求上游")
|
||||
require.Empty(t, repo.syncedWrites())
|
||||
}
|
||||
|
||||
// 面板开关缺失或为空一律视为开启,与设置默认值一致;读取失败同样按开启处理,
|
||||
// 避免一次数据库抖动就静默停掉版本跟随。
|
||||
func TestClaudeCodeVersionSyncEnabledByDefaultOrOnError(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
getErr error
|
||||
value string
|
||||
}{
|
||||
{name: "缺失"},
|
||||
{name: "空值", value: ""},
|
||||
{name: "显式开启", value: "true"},
|
||||
{name: "读取失败", getErr: errors.New("db down")},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{
|
||||
SettingKeyClaudeCodeVersionAutoSyncEnabled: tt.value,
|
||||
})
|
||||
repo.getErr = tt.getErr
|
||||
github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.280"}}}
|
||||
|
||||
newClaudeCodeVersionSyncService(repo, github).runOnce()
|
||||
|
||||
require.Equal(t, []string{"2.1.280"}, repo.syncedWrites())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 抓取失败保持既有值,不清空、不降级。两条取数路径都失败才算真正拿不到。
|
||||
func TestClaudeCodeVersionSyncKeepsValueOnFetchError(t *testing.T) {
|
||||
repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{
|
||||
SettingKeyClaudeCodeClientVersionSynced: "2.1.280",
|
||||
})
|
||||
github := &claudeCodeVersionSyncGitHubStub{
|
||||
latestErr: errors.New("network down"),
|
||||
err: errors.New("network down"),
|
||||
}
|
||||
|
||||
newClaudeCodeVersionSyncService(repo, github).runOnce()
|
||||
|
||||
require.Equal(t, 1, github.latestCalls)
|
||||
require.Equal(t, 1, github.calls)
|
||||
require.Empty(t, repo.syncedWrites())
|
||||
value, err := repo.GetValue(context.Background(), SettingKeyClaudeCodeClientVersionSynced)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "2.1.280", value)
|
||||
}
|
||||
|
||||
// 主路径 /releases/latest:该端点已排除 draft / prerelease,直接给出最新正式发布。
|
||||
func TestClaudeCodeVersionSyncUsesLatestReleaseEndpoint(t *testing.T) {
|
||||
repo := newClaudeCodeVersionSyncSettingRepoStub(nil)
|
||||
github := &claudeCodeVersionSyncGitHubStub{
|
||||
latest: &GitHubRelease{TagName: "v2.1.280"},
|
||||
// 列表若被调用会给出不同答案,用于证明取值确实来自主路径。
|
||||
releases: []*GitHubRelease{{TagName: "v2.1.279"}},
|
||||
}
|
||||
|
||||
newClaudeCodeVersionSyncService(repo, github).runOnce()
|
||||
|
||||
require.Equal(t, 1, github.latestCalls)
|
||||
require.Zero(t, github.calls, "主路径可用时不应再拉列表页")
|
||||
require.Equal(t, []string{"2.1.280"}, repo.syncedWrites())
|
||||
}
|
||||
|
||||
// 回退列表扫描:主路径拿不到可用稳定版时必须继续扫一页 release,
|
||||
// 否则版本号会静默停更。各种拿不到的形态都要回退。
|
||||
func TestClaudeCodeVersionSyncFallsBackToReleaseList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
latest *GitHubRelease
|
||||
latestErr error
|
||||
}{
|
||||
{name: "latest 前缀不符", latest: &GitHubRelease{TagName: "cli-2.1.280"}},
|
||||
{name: "latest 是预发布", latest: &GitHubRelease{TagName: "v2.1.281-beta.1", Prerelease: true}},
|
||||
{name: "latest 是草稿", latest: &GitHubRelease{TagName: "v2.1.281", Draft: true}},
|
||||
{name: "latest 抓取失败", latestErr: errors.New("network down")},
|
||||
// 上游返回空对象:不得因此 panic,按拿不到处理。
|
||||
{name: "latest 为空", latest: nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo := newClaudeCodeVersionSyncSettingRepoStub(nil)
|
||||
github := &claudeCodeVersionSyncGitHubStub{
|
||||
latest: tt.latest,
|
||||
latestErr: tt.latestErr,
|
||||
releases: []*GitHubRelease{
|
||||
{TagName: "v2.1.281-beta.1", Prerelease: true},
|
||||
{TagName: "v2.1.280"},
|
||||
{TagName: "v2.1.279"},
|
||||
},
|
||||
}
|
||||
|
||||
newClaudeCodeVersionSyncService(repo, github).runOnce()
|
||||
|
||||
require.Equal(t, 1, github.latestCalls)
|
||||
require.Equal(t, 1, github.calls)
|
||||
require.Equal(t, []string{"2.1.280"}, repo.syncedWrites())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 两条路径共用同一套过滤:主路径的单条 latest 同样要过前缀 / 版本号形态校验,
|
||||
// 不能因为「端点保证是正式发布」就直接采信 tag。
|
||||
func TestClaudeCodeVersionSyncLatestSharesFiltering(t *testing.T) {
|
||||
repo := newClaudeCodeVersionSyncSettingRepoStub(nil)
|
||||
github := &claudeCodeVersionSyncGitHubStub{
|
||||
// 剥掉 v 前缀后不满足基线校验(2.1.9 低于内置基线 2.1.258)的 latest
|
||||
// 必须被拒绝而不是直接采信。
|
||||
latest: &GitHubRelease{TagName: "v2.1.9"},
|
||||
releases: []*GitHubRelease{{TagName: "v2.1.280"}},
|
||||
}
|
||||
|
||||
newClaudeCodeVersionSyncService(repo, github).runOnce()
|
||||
|
||||
require.Equal(t, []string{"2.1.280"}, repo.syncedWrites())
|
||||
}
|
||||
|
||||
// 依赖缺失时 Start 必须直接返回,不能起一个空转的 goroutine。
|
||||
func TestClaudeCodeVersionSyncStartRequiresDependencies(t *testing.T) {
|
||||
require.NotPanics(t, func() {
|
||||
svc := NewClaudeCodeVersionSyncService(nil, nil, nil, claudeCodeVersionSyncInterval)
|
||||
svc.Start()
|
||||
svc.Stop()
|
||||
})
|
||||
}
|
||||
|
||||
// 启动同步防抖:同步值仍在一个周期内时跳过,避免频繁重启/滚动发布把启动同步
|
||||
// 放大成对 GitHub 的连续请求。
|
||||
func TestClaudeCodeVersionSyncInitialSkipsWhenRecentlySynced(t *testing.T) {
|
||||
repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{
|
||||
SettingKeyClaudeCodeClientVersionSynced: "2.1.280",
|
||||
})
|
||||
repo.updatedAt = time.Now().Add(-time.Minute)
|
||||
github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.281"}}}
|
||||
|
||||
newClaudeCodeVersionSyncService(repo, github).runInitial()
|
||||
|
||||
require.Zero(t, github.calls, "同步值仍在周期内时不应请求上游")
|
||||
require.Empty(t, repo.syncedWrites())
|
||||
}
|
||||
|
||||
func TestClaudeCodeVersionSyncInitialRunsWhenStaleOrMissing(t *testing.T) {
|
||||
t.Run("同步值已过期", func(t *testing.T) {
|
||||
repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{
|
||||
SettingKeyClaudeCodeClientVersionSynced: "2.1.280",
|
||||
})
|
||||
repo.updatedAt = time.Now().Add(-2 * time.Hour)
|
||||
github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.281"}}}
|
||||
|
||||
newClaudeCodeVersionSyncService(repo, github).runInitial()
|
||||
|
||||
require.Equal(t, 1, github.calls)
|
||||
require.Equal(t, []string{"2.1.281"}, repo.syncedWrites())
|
||||
})
|
||||
|
||||
// 首次部署尚无同步值:必须立刻同步,不能被防抖挡住。
|
||||
t.Run("尚无同步值", func(t *testing.T) {
|
||||
repo := newClaudeCodeVersionSyncSettingRepoStub(nil)
|
||||
repo.updatedAt = time.Now()
|
||||
github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.280"}}}
|
||||
|
||||
newClaudeCodeVersionSyncService(repo, github).runInitial()
|
||||
|
||||
require.Equal(t, 1, github.calls)
|
||||
require.Equal(t, []string{"2.1.280"}, repo.syncedWrites())
|
||||
})
|
||||
}
|
||||
|
||||
// 版本比较必须按段取数字:字典序会把 2.1.9 判为大于 2.1.280,
|
||||
// 从而让「取最大值」和「只向前推进」两处逻辑同时判错。
|
||||
func TestClaudeCodeVersionComparisonIsNumericNotLexical(t *testing.T) {
|
||||
require.Greater(t, CompareVersions("2.1.280", "2.1.9"), 0)
|
||||
|
||||
require.Equal(t, "2.1.280", latestClaudeCodeStableReleaseVersion([]*GitHubRelease{
|
||||
{TagName: "v2.1.9"},
|
||||
{TagName: "v2.1.280"},
|
||||
}))
|
||||
|
||||
repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{
|
||||
SettingKeyClaudeCodeClientVersionSynced: "2.1.280",
|
||||
})
|
||||
github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.258"}}}
|
||||
|
||||
newClaudeCodeVersionSyncService(repo, github).runOnce()
|
||||
|
||||
require.Empty(t, repo.syncedWrites(), "更低的版本号不得写入")
|
||||
}
|
||||
@@ -707,6 +707,15 @@ const (
|
||||
SettingKeyOpenAICodexClientVersionSynced = "openai_codex_client_version_synced"
|
||||
// SettingKeyOpenAICodexVersionAutoSyncEnabled 是否启用 Codex 客户端版本号自动同步(默认 true)。
|
||||
SettingKeyOpenAICodexVersionAutoSyncEnabled = "openai_codex_version_auto_sync_enabled"
|
||||
// SettingKeyClaudeCodeClientVersion 网关对 Anthropic 上游声明的 Claude Code CLI 客户端版本号(管理员覆写)。
|
||||
// 空值表示跟随自动同步值;自动同步也没有结果时回退到 claude.CLIVersion()(环境变量覆盖 + 内置基线)。
|
||||
// 版本太旧会被 Anthropic 拒绝(claude_code_version_too_old),故该值需保持跟随官方发布。
|
||||
SettingKeyClaudeCodeClientVersion = "claude_code_client_version"
|
||||
// SettingKeyClaudeCodeClientVersionSynced 自动同步任务写入的官方 Claude Code CLI 最新版本号。
|
||||
// 由同步任务独占写入,面板只读展示;管理员覆写请用 SettingKeyClaudeCodeClientVersion。
|
||||
SettingKeyClaudeCodeClientVersionSynced = "claude_code_client_version_synced"
|
||||
// SettingKeyClaudeCodeVersionAutoSyncEnabled 是否启用 Claude Code 客户端版本号自动同步(默认 true)。
|
||||
SettingKeyClaudeCodeVersionAutoSyncEnabled = "claude_code_version_auto_sync_enabled"
|
||||
// SettingKeyOpenAIAllowClaudeCodeCodexPlugin 已废弃:历史全局开关只作为升级迁移输入读取。
|
||||
// 迁移后等价规则写入 SettingKeyCodexCLIOnlyWhitelist,不再参与运行时判定。
|
||||
SettingKeyOpenAIAllowClaudeCodeCodexPlugin = "openai_allow_claude_code_codex_plugin"
|
||||
|
||||
@@ -913,7 +913,7 @@ func TestGatewayService_AnthropicOAuthMimic_RewritesSystemWithBillingBlock(t *te
|
||||
require.Truef(t, anthropicBetaTokensContains(finalBeta, beta), "missing mimic beta %s", beta)
|
||||
}
|
||||
require.False(t, anthropicBetaTokensContains(finalBeta, "client-only-beta"))
|
||||
for key, value := range claude.DefaultHeaders {
|
||||
for key, value := range claude.DefaultHeaders() {
|
||||
require.Equal(t, value, getHeaderRaw(upstream.lastReq.Header, key), "mimic fingerprint header %s", key)
|
||||
}
|
||||
require.NotEmpty(t, getHeaderRaw(upstream.lastReq.Header, "x-client-request-id"))
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
@@ -15,10 +14,13 @@ var ccVersionInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+`)
|
||||
|
||||
var ccVersionWithFingerprintInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+\.[0-9a-fA-F]{3}\b`)
|
||||
|
||||
// OAuth mimicry forces the built-in User-Agent after applying account fingerprints.
|
||||
func effectiveBillingUserAgent(tokenType string, mimicClaudeCode bool, fingerprint *Fingerprint) string {
|
||||
// effectiveBillingUserAgent 选择写进 x-anthropic-billing-header 的 User-Agent。
|
||||
// OAuth mimicry 强制使用调用方传入的 mimicUserAgent(与出站 User-Agent 头同源、
|
||||
// 同一次请求内取一次复用,保证 cc_version 与出站头版本严格一致),
|
||||
// 其余情况使用账号指纹 UA。
|
||||
func effectiveBillingUserAgent(mimicUserAgent, tokenType string, mimicClaudeCode bool, fingerprint *Fingerprint) string {
|
||||
if tokenType == "oauth" && mimicClaudeCode {
|
||||
return claude.DefaultHeaders["User-Agent"]
|
||||
return mimicUserAgent
|
||||
}
|
||||
if fingerprint == nil {
|
||||
return ""
|
||||
|
||||
@@ -138,7 +138,7 @@ func TestBuildOAuthRequest_BillingMatchesWireUserAgent(t *testing.T) {
|
||||
defer func() { require.NoError(t, req.Body.Close()) }()
|
||||
wantUA := cachedUA
|
||||
if tc.mimic {
|
||||
wantUA = claude.DefaultHeaders["User-Agent"]
|
||||
wantUA = claude.DefaultHeaders()["User-Agent"]
|
||||
}
|
||||
require.Equal(t, wantUA, getHeaderRaw(req.Header, "User-Agent"))
|
||||
version := ExtractCLIVersion(wantUA)
|
||||
|
||||
@@ -767,14 +767,17 @@ func expandClaudeOAuthSystemPromptTextTemplate(body []byte, text string, expansi
|
||||
return "", nil
|
||||
}
|
||||
expansionPrompt = defaultClaudeOAuthExpansionPrompt(expansionPrompt)
|
||||
billingText, err := buildBillingAttributionText(body, claude.CLIVersion())
|
||||
// 同一次展开内只取一次版本号,billing attribution / 指纹 / 占位符三处共用,
|
||||
// 避免运行期版本翻转瞬间取到不同值。
|
||||
cliVersion := claude.EffectiveCLIVersion()
|
||||
billingText, err := buildBillingAttributionText(body, cliVersion)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fp := computeClaudeCodeFingerprint(body, claude.CLIVersion())
|
||||
fp := computeClaudeCodeFingerprint(body, cliVersion)
|
||||
replacer := strings.NewReplacer(
|
||||
"{billing_header}", billingText,
|
||||
"{cc_version}", claude.CLIVersion(),
|
||||
"{cc_version}", cliVersion,
|
||||
"{fp}", fp,
|
||||
"{claude_code_system_prompt}", claudeCodeSystemPrompt,
|
||||
"{claude_code_expansion_prompt}", expansionPrompt,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
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)
|
||||
}
|
||||
@@ -498,7 +498,10 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
|
||||
if ctEnableFP {
|
||||
billingFingerprint = ctFingerprint
|
||||
}
|
||||
if billingUA := effectiveBillingUserAgent(tokenType, mimicClaudeCode, billingFingerprint); billingUA != "" {
|
||||
// 一致性铁律:同一次请求内只取一次 mimic UA,billing cc_version 与出站
|
||||
// User-Agent 头共用这一个字符串(同 buildUpstreamRequest)。
|
||||
ctMimicUserAgent := claude.DefaultUserAgent()
|
||||
if billingUA := effectiveBillingUserAgent(ctMimicUserAgent, tokenType, mimicClaudeCode, billingFingerprint); billingUA != "" {
|
||||
body = syncBillingHeaderVersion(body, billingUA)
|
||||
}
|
||||
|
||||
@@ -564,7 +567,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
|
||||
|
||||
// OAuth + mimic Claude Code:强制注入 CLI 指纹 header
|
||||
if tokenType == "oauth" && mimicClaudeCode {
|
||||
applyClaudeCodeMimicHeaders(req, false)
|
||||
applyClaudeCodeMimicHeaders(req, false, ctMimicUserAgent)
|
||||
}
|
||||
|
||||
// 写入最终 anthropic-beta header(Del 一次避免白名单透传值残留)
|
||||
|
||||
@@ -84,8 +84,13 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
||||
}
|
||||
}
|
||||
|
||||
// 一致性铁律:同一次请求内只取一次 mimic UA,出站 User-Agent 头与
|
||||
// 请求体 x-anthropic-billing-header 的 cc_version 都源自这一个字符串,
|
||||
// 避免运行期版本缓存翻转瞬间头/体版本自相矛盾(会被判非正版客户端)。
|
||||
mimicUserAgent := claude.DefaultUserAgent()
|
||||
|
||||
// Mimicry may override the cached User-Agent later, even without a fingerprint.
|
||||
if billingUA := effectiveBillingUserAgent(tokenType, mimicClaudeCode, fingerprint); billingUA != "" {
|
||||
if billingUA := effectiveBillingUserAgent(mimicUserAgent, tokenType, mimicClaudeCode, fingerprint); billingUA != "" {
|
||||
body = syncBillingHeaderVersion(body, billingUA)
|
||||
}
|
||||
|
||||
@@ -172,7 +177,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
||||
// OAuth + mimic Claude Code:强制注入 CLI 指纹相关 header
|
||||
// (user-agent/x-stainless-*/x-app/Accept/x-stainless-helper-method/x-client-request-id)
|
||||
if tokenType == "oauth" && mimicClaudeCode {
|
||||
applyClaudeCodeMimicHeaders(req, reqStream)
|
||||
applyClaudeCodeMimicHeaders(req, reqStream, mimicUserAgent)
|
||||
}
|
||||
|
||||
// 写入最终 anthropic-beta header
|
||||
@@ -417,7 +422,7 @@ func applyClaudeOAuthHeaderDefaults(req *http.Request) {
|
||||
if getHeaderRaw(req.Header, "Accept") == "" {
|
||||
setHeaderRaw(req.Header, "Accept", "application/json")
|
||||
}
|
||||
for key, value := range claude.DefaultHeaders {
|
||||
for key, value := range claude.DefaultHeaders() {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
@@ -854,7 +859,9 @@ var defaultDroppedBetasSet = buildBetaTokenSet(claude.DroppedBetas)
|
||||
// applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers.
|
||||
// This mirrors opencode-anthropic-auth behavior: do not trust downstream
|
||||
// headers when using Claude Code-scoped OAuth credentials.
|
||||
func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) {
|
||||
// mimicUserAgent 由调用方在同一请求内取一次传入,保证出站 User-Agent 头与
|
||||
// 请求体 billing attribution 的 cc_version 版本号严格一致。
|
||||
func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool, mimicUserAgent string) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
@@ -862,10 +869,14 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) {
|
||||
applyClaudeOAuthHeaderDefaults(req)
|
||||
// Then force key headers to match Claude Code fingerprint regardless of what the client sent.
|
||||
// 使用 resolveWireCasing 确保 key 与真实 wire format 一致(如 "x-app" 而非 "X-App")
|
||||
for key, value := range claude.DefaultHeaders {
|
||||
for key, value := range claude.DefaultHeaders() {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if key == "User-Agent" {
|
||||
// 版本号与 billing 路径共用同一字符串(见 mimicUserAgent 注释)。
|
||||
value = mimicUserAgent
|
||||
}
|
||||
setHeaderRaw(req.Header, resolveWireCasing(key), value)
|
||||
}
|
||||
// Real Claude CLI uses Accept: application/json (even for streaming).
|
||||
|
||||
@@ -72,15 +72,17 @@ func isAcceptableFingerprintUserAgent(ua string) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
currentMajor, _, _, currentOK := parseUserAgentVersion(claudeCLIUserAgentProduct + "/" + claude.CLIVersion())
|
||||
// 超前检查以运行期生效版本为基准(EffectiveCLIVersion):同步跨大版本后,
|
||||
// 若仍与静态基线比较会误拒客户端上报的新版本。
|
||||
currentMajor, _, _, currentOK := parseUserAgentVersion(claudeCLIUserAgentProduct + "/" + claude.EffectiveCLIVersion())
|
||||
if !currentOK {
|
||||
return true
|
||||
}
|
||||
return major <= currentMajor+maxClaudeCLIMajorVersionSkew
|
||||
}
|
||||
|
||||
// floorClaudeCLIUserAgentVersion 把 claude-cli UA 中的版本号抬升到 claude.CLICurrentVersion
|
||||
// 下限:低于下限时就地升到下限并返回 changed=true,否则原样返回。
|
||||
// floorClaudeCLIUserAgentVersion 把 claude-cli UA 中的版本号抬升到运行期生效版本
|
||||
// (claude.EffectiveCLIVersion())下限:低于下限时就地升到下限并返回 changed=true,否则原样返回。
|
||||
//
|
||||
// 为什么需要这条:账号级指纹"只升不降"、活跃账号懒续期后近乎永不过期,且系统内没有重置
|
||||
// 入口,defaultFingerprint 只在首次创建指纹时使用。存量账号缓存里的 claude-cli 版本停留在
|
||||
@@ -98,28 +100,36 @@ func floorClaudeCLIUserAgentVersion(ua string) (string, bool) {
|
||||
if extractProduct(ua) != claudeCLIUserAgentProduct {
|
||||
return ua, false
|
||||
}
|
||||
floorUA := claudeCLIUserAgentProduct + "/" + claude.CLICurrentVersion
|
||||
// 用 EffectiveCLIVersion()(面板/同步的运行期值,经 IsSupportedCLIVersion
|
||||
// 校验、恒 >= 内置基线)而非静态基线 CLICurrentVersion:
|
||||
// 运行期同步到更高版本后,存量低版本指纹才能被抬到新版本;"只升不降"语义不变。
|
||||
floor := claude.EffectiveCLIVersion()
|
||||
floorUA := claudeCLIUserAgentProduct + "/" + floor
|
||||
// isNewerVersion(floor, ua) 为 true 当且仅当下限版本严格高于 ua:
|
||||
// ua 等于或高于下限、产品名不一致、或版本无法解析时都不做改动。
|
||||
if !isNewerVersion(floorUA, ua) {
|
||||
return ua, false
|
||||
}
|
||||
floored := claudeCLIUAVersionPrefixRegex.ReplaceAllString(ua, "${1}/"+claude.CLICurrentVersion)
|
||||
floored := claudeCLIUAVersionPrefixRegex.ReplaceAllString(ua, "${1}/"+floor)
|
||||
if floored == ua {
|
||||
return ua, false
|
||||
}
|
||||
return floored, true
|
||||
}
|
||||
|
||||
// 默认指纹值(当客户端未提供时使用)
|
||||
var defaultFingerprint = Fingerprint{
|
||||
UserAgent: "claude-cli/" + claude.CLIVersion() + " (external, cli)",
|
||||
StainlessLang: "js",
|
||||
StainlessPackageVersion: "0.94.0",
|
||||
StainlessOS: "Linux",
|
||||
StainlessArch: "arm64",
|
||||
StainlessRuntime: "node",
|
||||
StainlessRuntimeVersion: "v24.3.0",
|
||||
// defaultFingerprint 返回默认指纹值(当客户端未提供时使用)。
|
||||
// UserAgent 不能在包 init 时固化:必须在每次使用时经 EffectiveCLIVersion 现取,
|
||||
// 否则运行期同步到新版本后,新建账号会写入过期版本作为持久身份。
|
||||
func defaultFingerprint() Fingerprint {
|
||||
return Fingerprint{
|
||||
UserAgent: claude.DefaultUserAgent(),
|
||||
StainlessLang: "js",
|
||||
StainlessPackageVersion: "0.94.0",
|
||||
StainlessOS: "Linux",
|
||||
StainlessArch: "arm64",
|
||||
StainlessRuntime: "node",
|
||||
StainlessRuntimeVersion: "v24.3.0",
|
||||
}
|
||||
}
|
||||
|
||||
// Fingerprint represents account fingerprint data
|
||||
@@ -187,7 +197,7 @@ func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID
|
||||
if uaAcceptable {
|
||||
mergeHeadersIntoFingerprint(cached, headers)
|
||||
} else {
|
||||
cached.UserAgent = defaultFingerprint.UserAgent
|
||||
cached.UserAgent = defaultFingerprint().UserAgent
|
||||
}
|
||||
needWrite = true
|
||||
logger.LegacyPrintf("service.identity",
|
||||
@@ -262,19 +272,20 @@ func (s *IdentityService) createFingerprintFromHeaders(headers http.Header) *Fin
|
||||
// 首次创建同样是持久化写入,必须与升级路径共用同一套校验。
|
||||
if ua := strings.TrimSpace(headers.Get("User-Agent")); isAcceptableFingerprintUserAgent(ua) {
|
||||
// 首次创建与缓存命中路径共用同一个版本下限:合法但过旧的 claude-cli UA
|
||||
// 落库时同样不能低于 CLICurrentVersion,否则新账号一开始就带着过旧的持久身份。
|
||||
// 落库时同样不能低于运行期生效版本(EffectiveCLIVersion),否则新账号一开始就带着过旧的持久身份。
|
||||
fp.UserAgent, _ = floorClaudeCLIUserAgentVersion(ua)
|
||||
} else {
|
||||
fp.UserAgent = defaultFingerprint.UserAgent
|
||||
fp.UserAgent = defaultFingerprint().UserAgent
|
||||
}
|
||||
|
||||
// 获取x-stainless-*头,如果没有则使用默认值
|
||||
fp.StainlessLang = getHeaderOrDefault(headers, "X-Stainless-Lang", defaultFingerprint.StainlessLang)
|
||||
fp.StainlessPackageVersion = getHeaderOrDefault(headers, "X-Stainless-Package-Version", defaultFingerprint.StainlessPackageVersion)
|
||||
fp.StainlessOS = getHeaderOrDefault(headers, "X-Stainless-OS", defaultFingerprint.StainlessOS)
|
||||
fp.StainlessArch = getHeaderOrDefault(headers, "X-Stainless-Arch", defaultFingerprint.StainlessArch)
|
||||
fp.StainlessRuntime = getHeaderOrDefault(headers, "X-Stainless-Runtime", defaultFingerprint.StainlessRuntime)
|
||||
fp.StainlessRuntimeVersion = getHeaderOrDefault(headers, "X-Stainless-Runtime-Version", defaultFingerprint.StainlessRuntimeVersion)
|
||||
df := defaultFingerprint()
|
||||
fp.StainlessLang = getHeaderOrDefault(headers, "X-Stainless-Lang", df.StainlessLang)
|
||||
fp.StainlessPackageVersion = getHeaderOrDefault(headers, "X-Stainless-Package-Version", df.StainlessPackageVersion)
|
||||
fp.StainlessOS = getHeaderOrDefault(headers, "X-Stainless-OS", df.StainlessOS)
|
||||
fp.StainlessArch = getHeaderOrDefault(headers, "X-Stainless-Arch", df.StainlessArch)
|
||||
fp.StainlessRuntime = getHeaderOrDefault(headers, "X-Stainless-Runtime", df.StainlessRuntime)
|
||||
fp.StainlessRuntimeVersion = getHeaderOrDefault(headers, "X-Stainless-Runtime-Version", df.StainlessRuntimeVersion)
|
||||
|
||||
return fp
|
||||
}
|
||||
@@ -324,7 +335,7 @@ func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) {
|
||||
setHeaderRaw(req.Header, "User-Agent", fp.UserAgent)
|
||||
}
|
||||
|
||||
// 设置x-stainless-*头(保持与 claude.DefaultHeaders 一致的大小写)
|
||||
// 设置x-stainless-*头(保持与 claude.DefaultHeaders() 一致的大小写)
|
||||
if fp.StainlessLang != "" {
|
||||
setHeaderRaw(req.Header, "X-Stainless-Lang", fp.StainlessLang)
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestGetOrCreateFingerprintRejectsMalformedUserAgentOnCreate(t *testing.T) {
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent,
|
||||
require.Equal(t, defaultFingerprint().UserAgent, fp.UserAgent,
|
||||
"畸形 UA 必须回退默认指纹,而不是被写成账号级持久身份")
|
||||
require.NotContains(t, cache.lastSet.UserAgent, "999.0.0")
|
||||
}
|
||||
@@ -160,8 +160,8 @@ func TestGetOrCreateFingerprintAcceptsValidUserAgentOnCreate(t *testing.T) {
|
||||
|
||||
// 不变式:默认指纹自身必须能通过校验,否则自愈路径会在每次读取时反复重写。
|
||||
func TestDefaultFingerprintUserAgentIsAcceptable(t *testing.T) {
|
||||
require.True(t, isAcceptableFingerprintUserAgent(defaultFingerprint.UserAgent),
|
||||
"defaultFingerprint.UserAgent 必须自洽,否则自愈会陷入反复重写")
|
||||
require.True(t, isAcceptableFingerprintUserAgent(defaultFingerprint().UserAgent),
|
||||
"defaultFingerprint().UserAgent 必须自洽,否则自愈会陷入反复重写")
|
||||
}
|
||||
|
||||
// 存量自愈:本次加固之前写入的畸形指纹在读取时被纠正。
|
||||
@@ -203,7 +203,7 @@ func TestGetOrCreateFingerprintHealsPoisonedCacheWithoutValidClientUA(t *testing
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent)
|
||||
require.Equal(t, defaultFingerprint().UserAgent, fp.UserAgent)
|
||||
require.Equal(t, 1, cache.setCalls)
|
||||
}
|
||||
|
||||
@@ -235,5 +235,5 @@ func TestGetOrCreateFingerprintMissingUserAgentKeepsDefault(t *testing.T) {
|
||||
fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, http.Header{})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent)
|
||||
require.Equal(t, defaultFingerprint().UserAgent, fp.UserAgent)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
@@ -119,6 +120,19 @@ const openAICodexClientVersionDBTimeout = 5 * time.Second
|
||||
// openAICodexClientVersionSFKey singleflight 键。
|
||||
const openAICodexClientVersionSFKey = "openai_codex_client_version"
|
||||
|
||||
// cachedClaudeCodeClientVersion 缓存出站 Claude Code 客户端版本号(进程内缓存,60s TTL)
|
||||
type cachedClaudeCodeClientVersion struct {
|
||||
version string
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
const claudeCodeClientVersionCacheTTL = 60 * time.Second
|
||||
const claudeCodeClientVersionErrorTTL = 5 * time.Second
|
||||
const claudeCodeClientVersionDBTimeout = 5 * time.Second
|
||||
|
||||
// claudeCodeClientVersionSFKey singleflight 键。
|
||||
const claudeCodeClientVersionSFKey = "claude_code_client_version"
|
||||
|
||||
type cachedOpenAIQuotaAutoPauseSettings struct {
|
||||
settings OpsOpenAIAccountQuotaAutoPauseSettings
|
||||
expiresAt int64
|
||||
@@ -378,6 +392,101 @@ func (s *SettingService) InvalidateOpenAICodexClientVersionCache() {
|
||||
s.openAICodexVersionCache.Store((*cachedOpenAICodexClientVersion)(nil))
|
||||
}
|
||||
|
||||
// NormalizeClaudeCodeClientVersion 校验并归一化 Claude Code 客户端版本号,非法值返回空串。
|
||||
// 容忍前导 "v" 与首尾空白;合法性复用 claude.IsSupportedCLIVersion(严格三段纯数字 semver、
|
||||
// 无预发布/构建后缀、且不低于内置基线)。
|
||||
func NormalizeClaudeCodeClientVersion(version string) string {
|
||||
normalized := strings.TrimSpace(version)
|
||||
normalized = strings.TrimPrefix(normalized, "v")
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
if !claude.IsSupportedCLIVersion(normalized) {
|
||||
return ""
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// GetClaudeCodeClientVersion 返回出站声明的 Claude Code CLI 客户端版本号。
|
||||
// 优先级:管理员在面板覆写的版本 → 自动同步到的官方最新版本 → claude.CLIVersion()
|
||||
// (环境变量 SUB2API_CLAUDE_CLI_VERSION 覆盖 + 内置基线)。
|
||||
// 版本太旧会被 Anthropic 拒绝(HTTP 400 claude_code_version_too_old),故该值需保持跟随官方发布。
|
||||
//
|
||||
// ⚠️ 一致性约束:同一次请求里,拼 User-Agent(claude-cli/<版本>)的版本号和用于
|
||||
// billing attribution 的版本号必须是同一个值,否则 Anthropic 侧对不上、判为非正版客户端。
|
||||
// 因此调用点必须在一次请求内只取一次本函数并复用;本缓存的唯一主动变更点是
|
||||
// 同步任务写入后调用 InvalidateClaudeCodeClientVersionCache,60s TTL 不会在极短时间内抖动。
|
||||
func (s *SettingService) GetClaudeCodeClientVersion(ctx context.Context) string {
|
||||
fallback := claude.CLIVersion()
|
||||
if s == nil || s.settingRepo == nil {
|
||||
return fallback
|
||||
}
|
||||
if cached, ok := s.claudeCodeVersionCache.Load().(*cachedClaudeCodeClientVersion); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.version
|
||||
}
|
||||
}
|
||||
|
||||
result, _, _ := s.claudeCodeVersionSF.Do(claudeCodeClientVersionSFKey, func() (any, error) {
|
||||
if cached, ok := s.claudeCodeVersionCache.Load().(*cachedClaudeCodeClientVersion); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.version, nil
|
||||
}
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), claudeCodeClientVersionDBTimeout)
|
||||
defer cancel()
|
||||
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
|
||||
SettingKeyClaudeCodeClientVersion,
|
||||
SettingKeyClaudeCodeClientVersionSynced,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("failed to get claude code client version setting", "error", err)
|
||||
s.claudeCodeVersionCache.Store(&cachedClaudeCodeClientVersion{
|
||||
version: fallback,
|
||||
expiresAt: time.Now().Add(claudeCodeClientVersionErrorTTL).UnixNano(),
|
||||
})
|
||||
return fallback, nil
|
||||
}
|
||||
version := NormalizeClaudeCodeClientVersion(values[SettingKeyClaudeCodeClientVersion])
|
||||
if version == "" {
|
||||
if raw := values[SettingKeyClaudeCodeClientVersion]; strings.TrimSpace(raw) != "" {
|
||||
slog.Warn("ignoring invalid claude_code_client_version setting; falling back to the next layer",
|
||||
"value", raw)
|
||||
}
|
||||
version = NormalizeClaudeCodeClientVersion(values[SettingKeyClaudeCodeClientVersionSynced])
|
||||
if version == "" && strings.TrimSpace(values[SettingKeyClaudeCodeClientVersionSynced]) != "" {
|
||||
slog.Warn("ignoring invalid claude_code_client_version_synced setting; falling back to the built-in pin",
|
||||
"value", values[SettingKeyClaudeCodeClientVersionSynced])
|
||||
}
|
||||
}
|
||||
if version == "" {
|
||||
version = fallback
|
||||
}
|
||||
s.claudeCodeVersionCache.Store(&cachedClaudeCodeClientVersion{
|
||||
version: version,
|
||||
expiresAt: time.Now().Add(claudeCodeClientVersionCacheTTL).UnixNano(),
|
||||
})
|
||||
return version, nil
|
||||
})
|
||||
if version, ok := result.(string); ok && version != "" {
|
||||
return version
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// InvalidateClaudeCodeClientVersionCache 丢弃版本号缓存,下次读取回源。
|
||||
// 面板保存与自动同步写入后调用。
|
||||
func (s *SettingService) InvalidateClaudeCodeClientVersionCache() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.claudeCodeVersionSF.Forget(claudeCodeClientVersionSFKey)
|
||||
s.claudeCodeVersionCache.Store((*cachedClaudeCodeClientVersion)(nil))
|
||||
}
|
||||
|
||||
// GetOpenAICodexCanonicalUserAgent 返回出站规范 Codex User-Agent。
|
||||
// 未填面板 UA 时按当前生效的客户端版本号拼出标准 Codex TUI UA。
|
||||
//
|
||||
|
||||
@@ -246,6 +246,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
||||
SettingKeyOpenAICodexClientVersion: "",
|
||||
SettingKeyOpenAICodexClientVersionSynced: "",
|
||||
SettingKeyOpenAICodexVersionAutoSyncEnabled: "true",
|
||||
SettingKeyClaudeCodeClientVersion: "",
|
||||
SettingKeyClaudeCodeClientVersionSynced: "",
|
||||
SettingKeyClaudeCodeVersionAutoSyncEnabled: "true",
|
||||
SettingPaymentVisibleMethodAlipaySource: "",
|
||||
SettingPaymentVisibleMethodWxpaySource: "",
|
||||
SettingPaymentVisibleMethodAlipayEnabled: "false",
|
||||
@@ -891,6 +894,14 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
} else {
|
||||
result.OpenAICodexVersionAutoSyncEnabled = true
|
||||
}
|
||||
result.ClaudeCodeClientVersion = NormalizeClaudeCodeClientVersion(settings[SettingKeyClaudeCodeClientVersion])
|
||||
result.ClaudeCodeClientVersionSynced = NormalizeClaudeCodeClientVersion(settings[SettingKeyClaudeCodeClientVersionSynced])
|
||||
// 自动同步默认开启:缺失/空值一律视为开启,与 openai_codex_version_auto_sync_enabled 同一惯例。
|
||||
if v, ok := settings[SettingKeyClaudeCodeVersionAutoSyncEnabled]; ok && v != "" {
|
||||
result.ClaudeCodeVersionAutoSyncEnabled = v == "true"
|
||||
} else {
|
||||
result.ClaudeCodeVersionAutoSyncEnabled = true
|
||||
}
|
||||
// codex_cli_only 加固
|
||||
result.MinCodexVersion = settings[SettingKeyMinCodexVersion]
|
||||
result.MaxCodexVersion = settings[SettingKeyMaxCodexVersion]
|
||||
|
||||
@@ -130,6 +130,8 @@ type SettingService struct {
|
||||
openAICodexUASF singleflight.Group
|
||||
openAICodexVersionCache atomic.Value // *cachedOpenAICodexClientVersion
|
||||
openAICodexVersionSF singleflight.Group
|
||||
claudeCodeVersionCache atomic.Value // *cachedClaudeCodeClientVersion
|
||||
claudeCodeVersionSF singleflight.Group
|
||||
codexRestrictionPolicyCache atomic.Value // *cachedCodexRestrictionPolicy
|
||||
codexRestrictionPolicySF singleflight.Group
|
||||
|
||||
|
||||
@@ -487,6 +487,10 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
||||
updates[SettingKeyOpenAICodexVersionAutoSyncEnabled] = strconv.FormatBool(settings.OpenAICodexVersionAutoSyncEnabled)
|
||||
// SettingKeyOpenAICodexClientVersionSynced 由自动同步任务独占写入,此处不得覆盖,
|
||||
// 否则面板保存会把同步结果清空。
|
||||
updates[SettingKeyClaudeCodeClientVersion] = NormalizeClaudeCodeClientVersion(settings.ClaudeCodeClientVersion)
|
||||
updates[SettingKeyClaudeCodeVersionAutoSyncEnabled] = strconv.FormatBool(settings.ClaudeCodeVersionAutoSyncEnabled)
|
||||
// SettingKeyClaudeCodeClientVersionSynced 由自动同步任务独占写入,此处不得覆盖,
|
||||
// 否则面板保存会把同步结果清空。
|
||||
// codex_cli_only 加固
|
||||
updates[SettingKeyMinCodexVersion] = strings.TrimSpace(settings.MinCodexVersion)
|
||||
updates[SettingKeyMaxCodexVersion] = strings.TrimSpace(settings.MaxCodexVersion)
|
||||
|
||||
@@ -250,6 +250,9 @@ type SystemSettings struct {
|
||||
OpenAICodexClientVersion string // 出站声明的 Codex 客户端版本号(管理员覆写);空值跟随自动同步值
|
||||
OpenAICodexClientVersionSynced string // 自动同步到的官方最新稳定版版本号(只读展示)
|
||||
OpenAICodexVersionAutoSyncEnabled bool // 是否启用 Codex 客户端版本号自动同步(默认 true)
|
||||
ClaudeCodeClientVersion string // 出站声明的 Claude Code CLI 客户端版本号(管理员覆写);空值跟随自动同步值
|
||||
ClaudeCodeClientVersionSynced string // 自动同步到的官方最新版本号(只读展示)
|
||||
ClaudeCodeVersionAutoSyncEnabled bool // 是否启用 Claude Code 客户端版本号自动同步(默认 true)
|
||||
MinCodexVersion string // codex_cli_only 最低 Codex 引擎版本;空=不检查
|
||||
MaxCodexVersion string // codex_cli_only 最高 Codex 引擎版本;空=不检查
|
||||
CodexCLIOnlyBlacklist string // codex_cli_only 全局黑名单 JSON([]AllowedClientEntry,OR deny)
|
||||
|
||||
@@ -942,7 +942,7 @@ func (s *AccountTestService) buildAnthropicUpstreamModelsRequest(ctx context.Con
|
||||
if err != nil {
|
||||
return nil, newUpstreamModelSyncConfigError("Invalid Anthropic model list URL", err)
|
||||
}
|
||||
for key, value := range claude.DefaultHeaders {
|
||||
for key, value := range claude.DefaultHeaders() {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
@@ -990,7 +990,7 @@ func (s *AccountTestService) buildAntigravityAPIKeyModelsRequest(ctx context.Con
|
||||
if err != nil {
|
||||
return nil, newUpstreamModelSyncConfigError("Invalid Antigravity model list URL", err)
|
||||
}
|
||||
for key, value := range claude.DefaultHeaders {
|
||||
for key, value := range claude.DefaultHeaders() {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"github.com/google/wire"
|
||||
@@ -415,6 +416,18 @@ func ProvideOpenAICodexVersionSyncService(
|
||||
return svc
|
||||
}
|
||||
|
||||
// ProvideClaudeCodeVersionSyncService creates and starts ClaudeCodeVersionSyncService.
|
||||
// 出站 Claude Code 身份的版本号靠它跟随官方发布,无需为了跟版本而发新版本;面板可关闭。
|
||||
func ProvideClaudeCodeVersionSyncService(
|
||||
settingRepo SettingRepository,
|
||||
settingService *SettingService,
|
||||
githubClient GitHubReleaseClient,
|
||||
) *ClaudeCodeVersionSyncService {
|
||||
svc := NewClaudeCodeVersionSyncService(settingRepo, settingService, githubClient, claudeCodeVersionSyncInterval)
|
||||
svc.Start()
|
||||
return svc
|
||||
}
|
||||
|
||||
// ProvideProxyExpiryService creates and starts ProxyExpiryService.
|
||||
func ProvideProxyExpiryService(proxyRepo ProxyRepository) *ProxyExpiryService {
|
||||
svc := NewProxyExpiryService(proxyRepo, time.Minute)
|
||||
@@ -794,6 +807,11 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit
|
||||
SetCodexCanonicalUserAgentResolver(func() string {
|
||||
return svc.GetOpenAICodexCanonicalUserAgent(context.Background())
|
||||
})
|
||||
// Claude CLI 伪装版本号同理:运行期解析(面板手动值 → 后台同步值 → 内置基线),
|
||||
// 解析器内部自带 60s TTL 缓存,热路径不触库。
|
||||
claude.SetCLIVersionResolver(func() string {
|
||||
return svc.GetClaudeCodeClientVersion(context.Background())
|
||||
})
|
||||
return svc
|
||||
}
|
||||
|
||||
@@ -920,6 +938,7 @@ var ProviderSet = wire.NewSet(
|
||||
wire.Bind(new(GrokOAuthReconciler), new(*TokenRefreshService)),
|
||||
ProvideAccountExpiryService,
|
||||
ProvideOpenAICodexVersionSyncService,
|
||||
ProvideClaudeCodeVersionSyncService,
|
||||
ProvideProxyExpiryService,
|
||||
ProvideSubscriptionExpiryService,
|
||||
ProvideTimingWheelService,
|
||||
|
||||
@@ -639,6 +639,9 @@ export interface SystemSettings {
|
||||
openai_codex_client_version: string;
|
||||
openai_codex_client_version_synced: string;
|
||||
openai_codex_version_auto_sync_enabled: boolean;
|
||||
claude_code_client_version: string;
|
||||
claude_code_client_version_synced: string;
|
||||
claude_code_version_auto_sync_enabled: boolean;
|
||||
// codex_cli_only 加固
|
||||
min_codex_version: string;
|
||||
max_codex_version: string;
|
||||
@@ -959,6 +962,8 @@ export interface UpdateSettingsRequest {
|
||||
openai_codex_user_agent?: string;
|
||||
openai_codex_client_version?: string;
|
||||
openai_codex_version_auto_sync_enabled?: boolean;
|
||||
claude_code_client_version?: string;
|
||||
claude_code_version_auto_sync_enabled?: boolean;
|
||||
// codex_cli_only 加固
|
||||
min_codex_version?: string;
|
||||
max_codex_version?: string;
|
||||
|
||||
@@ -542,6 +542,11 @@ export default {
|
||||
openaiCodexVersionAutoSync: 'Auto-sync Codex version',
|
||||
openaiCodexVersionAutoSyncHint: 'Fetches the latest stable client version from the official repository every 6 hours, so you never need to upgrade this service just to keep the version current. When disabled, only the version above or the built-in default is used.',
|
||||
openaiCodexVersionSyncedValue: 'Currently synced: {version}',
|
||||
claudeCodeClientVersion: 'Claude Code client version',
|
||||
claudeCodeClientVersionHint: "The client version this gateway declares upstream when impersonating the official Claude Code CLI. Leave empty to use the auto-synced latest official release; setting a value pins it and stops following auto-sync. The SUB2API_CLAUDE_CLI_VERSION environment variable still acts as a fallback and takes priority over this setting.",
|
||||
claudeCodeVersionAutoSync: 'Auto-sync Claude Code version',
|
||||
claudeCodeVersionAutoSyncHint: 'Fetches the latest Claude Code client version from the official release channel every hour, so you never need to upgrade this service just to keep the version current. When disabled, only the version above or the environment-variable fallback is used.',
|
||||
claudeCodeVersionSyncedValue: 'Currently synced: {version}',
|
||||
codexHardeningTitle: "Codex Settings",
|
||||
codexClientRestrictionTitle: "Codex client restriction",
|
||||
codexHardeningDesc:
|
||||
|
||||
@@ -535,6 +535,11 @@ export default {
|
||||
openaiCodexVersionAutoSync: '自动同步 Codex 版本号',
|
||||
openaiCodexVersionAutoSyncHint: '每 6 小时从官方仓库获取最新稳定版客户端版本号,无需为了跟版本而升级本服务。关闭后仅使用上方手填版本或内置版本。',
|
||||
openaiCodexVersionSyncedValue: '当前同步到:{version}',
|
||||
claudeCodeClientVersion: 'Claude Code 客户端版本号',
|
||||
claudeCodeClientVersionHint: '网关伪装为官方 Claude Code CLI 时对上游声明的客户端版本号。留空表示使用自动同步到的官方最新版本;填写后固定为该版本,不再跟随同步。环境变量 SUB2API_CLAUDE_CLI_VERSION 仍作为兜底,优先级高于此处配置。',
|
||||
claudeCodeVersionAutoSync: '自动同步 Claude Code 版本号',
|
||||
claudeCodeVersionAutoSyncHint: '每小时从官方发布渠道获取最新版本的 Claude Code 客户端版本号,无需为了跟版本而升级本服务。关闭后仅使用上方手填版本或环境变量兜底。',
|
||||
claudeCodeVersionSyncedValue: '当前同步到:{version}',
|
||||
codexHardeningTitle: 'Codex 设置',
|
||||
codexClientRestrictionTitle: 'Codex 客户端限制',
|
||||
codexHardeningDesc:
|
||||
|
||||
@@ -5775,6 +5775,61 @@
|
||||
<Toggle v-model="form.openai_codex_version_auto_sync_enabled" />
|
||||
</div>
|
||||
|
||||
<!-- Claude Code 客户端版本号 -->
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"admin.settings.gatewayForwarding.claudeCodeClientVersion",
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.claude_code_client_version"
|
||||
type="text"
|
||||
class="input w-full font-mono text-sm"
|
||||
placeholder="2.1.280"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.gatewayForwarding.claudeCodeClientVersionHint",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Claude Code 版本号自动同步 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label
|
||||
class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"admin.settings.gatewayForwarding.claudeCodeVersionAutoSync",
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.gatewayForwarding.claudeCodeVersionAutoSyncHint",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="claudeSyncedVersionLabel"
|
||||
class="mt-0.5 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ claudeSyncedVersionLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.claude_code_version_auto_sync_enabled" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9840,6 +9895,10 @@ const form = reactive<SettingsForm>({
|
||||
// 只读展示:自动同步任务写入的官方最新稳定版,不参与提交(提交载荷按字段显式构造)
|
||||
openai_codex_client_version_synced: "",
|
||||
openai_codex_version_auto_sync_enabled: true,
|
||||
claude_code_client_version: "",
|
||||
// 只读展示:自动同步任务写入的官方最新稳定版,不参与提交(提交载荷按字段显式构造)
|
||||
claude_code_client_version_synced: "",
|
||||
claude_code_version_auto_sync_enabled: true,
|
||||
// codex_cli_only 加固
|
||||
min_codex_version: "",
|
||||
max_codex_version: "",
|
||||
@@ -10827,6 +10886,14 @@ const codexSyncedVersionLabel = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const claudeSyncedVersionLabel = computed(() => {
|
||||
const synced = form.claude_code_client_version_synced?.trim();
|
||||
if (!synced) return "";
|
||||
return t("admin.settings.gatewayForwarding.claudeCodeVersionSyncedValue", {
|
||||
version: synced,
|
||||
});
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true;
|
||||
loadFailed.value = false;
|
||||
@@ -11460,6 +11527,9 @@ async function saveSettings() {
|
||||
form.openai_codex_client_version?.trim() || "",
|
||||
openai_codex_version_auto_sync_enabled:
|
||||
form.openai_codex_version_auto_sync_enabled,
|
||||
claude_code_client_version: form.claude_code_client_version?.trim() || "",
|
||||
claude_code_version_auto_sync_enabled:
|
||||
form.claude_code_version_auto_sync_enabled,
|
||||
min_codex_version: form.min_codex_version?.trim() || "",
|
||||
max_codex_version: form.max_codex_version?.trim() || "",
|
||||
codex_cli_only_allow_app_server_clients:
|
||||
|
||||
Reference in New Issue
Block a user