mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-21 06:56:58 +00: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,
|
||||
|
||||
Reference in New Issue
Block a user