diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 47d3b9ec33..2e4b0d902c 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -88,6 +88,7 @@ func provideCleanup( schedulerSnapshot *service.SchedulerSnapshotService, tokenRefresh *service.TokenRefreshService, accountExpiry *service.AccountExpiryService, + codexVersionSync *service.OpenAICodexVersionSyncService, proxyExpiry *service.ProxyExpiryService, subscriptionExpiry *service.SubscriptionExpiryService, usageCleanup *service.UsageCleanupService, @@ -236,6 +237,10 @@ func provideCleanup( accountExpiry.Stop() return nil }}, + {"OpenAICodexVersionSyncService", func() error { + codexVersionSync.Stop() + return nil + }}, {"ProxyExpiryService", func() error { proxyExpiry.Stop() return nil diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index ec2db0e11c..83f7c8c686 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -320,6 +320,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig) opsIngressRejectAggregator := service.ProvideOpsIngressRejectAggregator(opsRepository, opsService) accountExpiryService := service.ProvideAccountExpiryService(accountRepository) + openAICodexVersionSyncService := service.ProvideOpenAICodexVersionSyncService(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) @@ -327,7 +328,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db) channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, 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, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService) application := &Application{ Server: httpServer, PromptAudit: promptService, @@ -371,6 +372,7 @@ func provideCleanup( schedulerSnapshot *service.SchedulerSnapshotService, tokenRefresh *service.TokenRefreshService, accountExpiry *service.AccountExpiryService, + codexVersionSync *service.OpenAICodexVersionSyncService, proxyExpiry *service.ProxyExpiryService, subscriptionExpiry *service.SubscriptionExpiryService, usageCleanup *service.UsageCleanupService, @@ -518,6 +520,10 @@ func provideCleanup( accountExpiry.Stop() return nil }}, + {"OpenAICodexVersionSyncService", func() error { + codexVersionSync.Stop() + return nil + }}, {"ProxyExpiryService", func() error { proxyExpiry.Stop() return nil diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index 592177be07..805ba5353a 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -40,6 +40,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { nil, ) accountExpirySvc := service.NewAccountExpiryService(nil, time.Second) + codexVersionSyncSvc := service.NewOpenAICodexVersionSyncService(nil, nil, nil, time.Second) proxyExpirySvc := service.NewProxyExpiryService(nil, time.Second) subscriptionExpirySvc := service.NewSubscriptionExpiryService(nil, time.Second) pricingSvc := service.NewPricingService(cfg, nil) @@ -65,6 +66,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { schedulerSnapshotSvc, tokenRefreshSvc, accountExpirySvc, + codexVersionSyncSvc, proxyExpirySvc, subscriptionExpirySvc, &service.UsageCleanupService{}, diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index ce99a302b8..6735179253 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -907,13 +907,17 @@ type GatewayConfig struct { // ForceCodexCLI: 强制将 OpenAI `/v1/responses` 请求按 Codex CLI 处理。 // 用于网关未透传/改写 User-Agent 时的兼容兜底(默认关闭,避免影响其他客户端)。 ForceCodexCLI bool `mapstructure:"force_codex_cli"` - // DisableCodexOriginatorNormalization: 关闭「把落在上游降载桶的 Codex originator 改写为 - // 官方 CLI 身份」。上游 /backend-api/codex 按 originator 分桶调度容量,命中降载桶的请求会被回 - // server_is_overloaded,网关据此冷却账号,表现为账号频繁过载不可用。 + // DisableCodexIdentityEnforcement: 关闭「强制统一 Codex 出站身份」。上游 /backend-api/codex + // 在容量紧张时按客户端身份分优先级降载,被降载的请求会拿到 HTTP 200 + 流内 + // server_is_overloaded,该次请求失败。默认强制统一出口:所有 OAuth 出站的 + // User-Agent / originator / version 都改写为网关规范身份,确保没有请求带着第三方或陈旧身份 + // 出站。置 true 后退回「仅按最终 User-Agent 配对 originator」的收口语义,供上游策略变动时回滚。 // // 取反义命名是为了让零值安全:该开关会发布为进程级快照,未经 viper 加载而手工构造的 - // Config(测试、工具)其零值必须落在「归一化开启」这一侧,否则会静默丢掉这层保护。 - // 仅当上游调整分桶、使归一化反而落入降载桶时才置 true。 + // Config(测试、工具)其零值必须落在「强制统一开启」这一侧,否则会静默丢掉这层保护。 + DisableCodexIdentityEnforcement bool `mapstructure:"disable_codex_identity_enforcement"` + // DisableCodexOriginatorNormalization: 已废弃,等价于 DisableCodexIdentityEnforcement。 + // 保留以兼容既有配置文件;加载时会折叠进新键,不要在新代码里直接读取。 DisableCodexOriginatorNormalization bool `mapstructure:"disable_codex_originator_normalization"` // CodexImageGenerationBridgeEnabled: 是否为 Codex `/v1/responses` 自动注入 image_generation 工具和桥接指令。 // 默认关闭,避免纯文本 Codex 请求被意外改写;显式携带 image_generation 工具的请求仍按分组能力转发。 @@ -1774,6 +1778,13 @@ func load(allowMissingJWTSecret bool) (*Config, error) { cfg.Gateway.ForcedCodexInstructionsTemplate = string(content) } + // 兼容旧键 gateway.disable_codex_originator_normalization:语义已被 + // disable_codex_identity_enforcement 取代(身份改写升级为强制统一出口), + // 任一为 true 即关闭强制统一。 + if cfg.Gateway.DisableCodexOriginatorNormalization { + cfg.Gateway.DisableCodexIdentityEnforcement = true + } + // 兼容旧键 gateway.openai_ws.sticky_previous_response_ttl_seconds。 // 新键未配置(<=0)时回退旧键;新键优先。 if cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds <= 0 && cfg.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds > 0 { @@ -2225,6 +2236,7 @@ func setDefaults() { viper.SetDefault("gateway.max_account_switches", 10) viper.SetDefault("gateway.max_account_switches_gemini", 3) viper.SetDefault("gateway.force_codex_cli", false) + viper.SetDefault("gateway.disable_codex_identity_enforcement", false) viper.SetDefault("gateway.disable_codex_originator_normalization", false) viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false) viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 8ad27e122a..cd2ac22372 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -279,6 +279,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { EnableClientDatelineNormalization: settings.EnableClientDatelineNormalization, AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion, OpenAICodexUserAgent: settings.OpenAICodexUserAgent, + OpenAICodexClientVersion: settings.OpenAICodexClientVersion, + OpenAICodexClientVersionSynced: settings.OpenAICodexClientVersionSynced, + OpenAICodexVersionAutoSyncEnabled: settings.OpenAICodexVersionAutoSyncEnabled, MinCodexVersion: settings.MinCodexVersion, MaxCodexVersion: settings.MaxCodexVersion, CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist, diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index ab91ac9f52..962c6f7735 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -443,6 +443,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.OpenAICodexUserAgent != after.OpenAICodexUserAgent { changed = append(changed, "openai_codex_user_agent") } + if before.OpenAICodexClientVersion != after.OpenAICodexClientVersion { + changed = append(changed, "openai_codex_client_version") + } + if before.OpenAICodexVersionAutoSyncEnabled != after.OpenAICodexVersionAutoSyncEnabled { + changed = append(changed, "openai_codex_version_auto_sync_enabled") + } if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource { changed = append(changed, "payment_visible_method_alipay_source") } diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 54569bc47a..e11b867e87 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -236,6 +236,8 @@ type UpdateSettingsRequest struct { EnableClientDatelineNormalization *bool `json:"enable_client_dateline_normalization"` AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"` OpenAICodexUserAgent *string `json:"openai_codex_user_agent"` + OpenAICodexClientVersion *string `json:"openai_codex_client_version"` + OpenAICodexVersionAutoSyncEnabled *bool `json:"openai_codex_version_auto_sync_enabled"` // codex_cli_only 加固(global-only) MinCodexVersion string `json:"min_codex_version"` @@ -1276,6 +1278,15 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { return } } + if req.OpenAICodexClientVersion != nil { + // 该值会被拼进出站 User-Agent 与 version 头,必须是合法版本号;空串表示跟随自动同步。 + normalized := strings.TrimSpace(*req.OpenAICodexClientVersion) + if normalized != "" && service.NormalizeCodexClientVersion(normalized) == "" { + response.Error(c, http.StatusBadRequest, "openai_codex_client_version must be empty or a valid version (e.g. 0.146.0)") + return + } + req.OpenAICodexClientVersion = &normalized + } // codex_cli_only 加固:最低/最高 Codex 版本(空=禁用,或合法 semver;max>=min) if req.MinCodexVersion != "" && !semverPattern.MatchString(req.MinCodexVersion) { @@ -1555,6 +1566,20 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.OpenAICodexUserAgent }(), + OpenAICodexClientVersion: func() string { + if req.OpenAICodexClientVersion != nil { + return *req.OpenAICodexClientVersion + } + return previousSettings.OpenAICodexClientVersion + }(), + // 同步值由自动同步任务独占写入,面板保存时原样带回,避免被清空。 + OpenAICodexClientVersionSynced: previousSettings.OpenAICodexClientVersionSynced, + OpenAICodexVersionAutoSyncEnabled: func() bool { + if req.OpenAICodexVersionAutoSyncEnabled != nil { + return *req.OpenAICodexVersionAutoSyncEnabled + } + return previousSettings.OpenAICodexVersionAutoSyncEnabled + }(), MinCodexVersion: strings.TrimSpace(req.MinCodexVersion), MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion), CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist), @@ -2027,6 +2052,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { EnableClientDatelineNormalization: updatedSettings.EnableClientDatelineNormalization, AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion, OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent, + OpenAICodexClientVersion: updatedSettings.OpenAICodexClientVersion, + OpenAICodexClientVersionSynced: updatedSettings.OpenAICodexClientVersionSynced, + OpenAICodexVersionAutoSyncEnabled: updatedSettings.OpenAICodexVersionAutoSyncEnabled, MinCodexVersion: updatedSettings.MinCodexVersion, MaxCodexVersion: updatedSettings.MaxCodexVersion, CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index da4c6abc10..1d215c6498 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -199,6 +199,9 @@ type SystemSettings struct { EnableClientDatelineNormalization bool `json:"enable_client_dateline_normalization"` AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"` OpenAICodexUserAgent string `json:"openai_codex_user_agent"` + OpenAICodexClientVersion string `json:"openai_codex_client_version"` + OpenAICodexClientVersionSynced string `json:"openai_codex_client_version_synced"` + OpenAICodexVersionAutoSyncEnabled bool `json:"openai_codex_version_auto_sync_enabled"` // codex_cli_only 加固 MinCodexVersion string `json:"min_codex_version"` diff --git a/backend/internal/pkg/openai/request.go b/backend/internal/pkg/openai/request.go index 8387b017c1..1165804f1c 100644 --- a/backend/internal/pkg/openai/request.go +++ b/backend/internal/pkg/openai/request.go @@ -57,17 +57,6 @@ var codexOfficialClientOriginators = map[string]bool{ "codex_sdk_ts": true, // TypeScript SDK } -// IsBrowserUserAgent 判断 User-Agent 是否来自浏览器(Chrome/Firefox/Safari/Edge/Opera 等)。 -// 所有现代浏览器的 UA 均以 "Mozilla/" 作为前缀,CLI 工具(codex/claude/curl/postman/python-requests 等)不会。 -// 该判定用于避免 Cloudflare 对浏览器型 UA 在 OpenAI 上游接口上触发 JS 质询。 -func IsBrowserUserAgent(userAgent string) bool { - ua := strings.TrimSpace(userAgent) - if ua == "" { - return false - } - return strings.HasPrefix(strings.ToLower(ua), "mozilla/") -} - // IsCodexCLIRequest checks if the User-Agent indicates a Codex CLI request func IsCodexCLIRequest(userAgent string) bool { ua := normalizeCodexClientHeader(userAgent) @@ -259,46 +248,21 @@ func canonicalizeCodexOriginator(name string) string { // 也是身份归一化的目标身份。 const CodexCLIOriginator = "codex_cli_rs" -// codexLoadShedOriginators:上游 /backend-api/codex 按 originator 分桶调度容量,命中降载桶的 -// 请求即使 HTTP 200 也会立刻推 SSE `event: error`(code=server_is_overloaded)并以 -// response.failed 收尾。2026-07-29 起 codex-tui 被观测到落入降载桶:同账号、同请求体、同 UA, -// 仅把 originator 换成 codex_cli_rs 即恢复正常(换言之 UA 不是判定因子,originator 才是)。 -// 网关会把该错误判定为瞬时上游故障并让账号进入冷却,对外表现为「账号过载不可用」, -// 因此出站前需要把命中的身份改写为 CLI 身份。 -// -// 该集合是上游容量策略的快照而非协议常量,上游调整分桶后需同步修订。 -var codexLoadShedOriginators = map[string]bool{ - "codex-tui": true, -} - -// IsCodexLoadShedOriginator 判断 originator 是否落在上游降载桶。 -func IsCodexLoadShedOriginator(originator string) bool { - return codexLoadShedOriginators[normalizeCodexClientHeader(originator)] -} - -// NormalizeCodexClientIdentityToCLI 把落在降载桶的官方身份改写为 Codex CLI 身份: -// UA 首段替换为 codex_cli_rs,并裁掉尾部 `(name; version)` 客户端标识组(真实 CLI UA 无该组), -// 版本 / OS / 架构 / 终端指纹原样保留。返回配套的 originator 与 UA,未命中降载桶时 changed=false。 -// -// 入参应为 PairCodexClientIdentity 输出的已配对身份;改写后 UA 首段与 originator 仍然配套, -// 不破坏上游的配对校验。 -func NormalizeCodexClientIdentityToCLI(originator, userAgent string) (string, string, bool) { - if !IsCodexLoadShedOriginator(originator) { - return originator, userAgent, false - } +// CodexUserAgentVersion 提取 Codex UA 的完整版本段,即 `{client}/{version} (...` 中的 version。 +// 与 ParseCodexEngineVersion 的区别:后者只取三段数字用于引擎版本比较(会丢掉 -alpha.4 +// 之类的预发布后缀),本函数保留原样,因为出站 version 头必须与 UA 版本段逐字一致。 +// 取不到(非 Codex 形态 UA)时返回空串。 +func CodexUserAgentVersion(userAgent string) string { ua := strings.TrimSpace(userAgent) slash := strings.IndexByte(ua, '/') if slash <= 0 { - return CodexCLIOriginator, ua, true + return "" } - rest := ua[slash:] - // 仅当尾部括号组确为官方客户端标识时才裁剪,避免误截合法 UA 尾巴(如 `(Ubuntu 22.4.0; x86_64)`)。 - if trailer := codexUATrailerName(ua); trailer != "" && IsCodexOfficialClientOriginator(trailer) { - if open := strings.LastIndex(rest, "("); open > 0 { - rest = strings.TrimRight(rest[:open], " ") - } + rest := ua[slash+1:] + if space := strings.IndexByte(rest, ' '); space >= 0 { + rest = rest[:space] } - return CodexCLIOriginator, CodexCLIOriginator + rest, true + return strings.TrimSpace(rest) } // codexEngineVersionPattern 提取版本段开头的三段数字 X.Y.Z(忽略 -alpha 等后缀)。 diff --git a/backend/internal/pkg/openai/request_load_shed_test.go b/backend/internal/pkg/openai/request_load_shed_test.go deleted file mode 100644 index e00e83615c..0000000000 --- a/backend/internal/pkg/openai/request_load_shed_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package openai - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestIsCodexLoadShedOriginator(t *testing.T) { - require.True(t, IsCodexLoadShedOriginator("codex-tui")) - require.True(t, IsCodexLoadShedOriginator(" CODEX-TUI ")) - require.False(t, IsCodexLoadShedOriginator("codex_cli_rs")) - require.False(t, IsCodexLoadShedOriginator("codex_vscode")) - require.False(t, IsCodexLoadShedOriginator("Codex Desktop")) - require.False(t, IsCodexLoadShedOriginator("")) -} - -func TestNormalizeCodexClientIdentityToCLI(t *testing.T) { - tests := []struct { - name string - originator string - ua string - wantOriginator string - wantUA string - wantChanged bool - }{ - { - name: "tui 完整 UA 改写首段并裁掉客户端标识组", - originator: "codex-tui", - ua: "codex-tui/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.144.1)", - wantOriginator: "codex_cli_rs", - wantUA: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color", - wantChanged: true, - }, - { - name: "无客户端标识组时仅改写首段", - originator: "codex-tui", - ua: "codex-tui/0.144.1 (Mac OS X 14.0; arm64)", - wantOriginator: "codex_cli_rs", - wantUA: "codex_cli_rs/0.144.1 (Mac OS X 14.0; arm64)", - wantChanged: true, - }, - { - name: "OS 括号组不是客户端标识不得被裁剪", - originator: "codex-tui", - ua: "codex-tui/0.144.1 (Ubuntu 22.4.0; x86_64)", - wantOriginator: "codex_cli_rs", - wantUA: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64)", - wantChanged: true, - }, - { - name: "缺少版本段时只替换 originator", - originator: "codex-tui", - ua: "codex-tui", - wantOriginator: "codex_cli_rs", - wantUA: "codex-tui", - wantChanged: true, - }, - { - name: "健康身份原样返回", - originator: "codex_cli_rs", - ua: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color", - wantOriginator: "codex_cli_rs", - wantUA: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color", - wantChanged: false, - }, - { - name: "其他官方身份不受影响", - originator: "codex_vscode", - ua: "codex_vscode/1.0.0 (Ubuntu 22.4.0; x86_64) vscode (codex_vscode; 1.0.0)", - wantOriginator: "codex_vscode", - wantUA: "codex_vscode/1.0.0 (Ubuntu 22.4.0; x86_64) vscode (codex_vscode; 1.0.0)", - wantChanged: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotOriginator, gotUA, changed := NormalizeCodexClientIdentityToCLI(tt.originator, tt.ua) - - require.Equal(t, tt.wantOriginator, gotOriginator) - require.Equal(t, tt.wantUA, gotUA) - require.Equal(t, tt.wantChanged, changed) - }) - } -} - -// 归一化后的身份必须仍然通过上游的 originator ↔ UA 首段配对校验(issue #3901), -// 且再次归一化保持幂等。 -func TestNormalizeCodexClientIdentityToCLIStaysPaired(t *testing.T) { - originator, ua, changed := NormalizeCodexClientIdentityToCLI( - "codex-tui", - "codex-tui/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.144.1)", - ) - require.True(t, changed) - - pairedOriginator, pairedUA, ok := PairCodexClientIdentity(ua) - require.True(t, ok) - require.Equal(t, originator, pairedOriginator) - require.Equal(t, ua, pairedUA) - - againOriginator, againUA, againChanged := NormalizeCodexClientIdentityToCLI(originator, ua) - require.False(t, againChanged) - require.Equal(t, originator, againOriginator) - require.Equal(t, ua, againUA) -} diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index d00f82dcc9..a0b82890b7 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -113,7 +113,7 @@ const ( openAIProbeCacheTTL = 10 * time.Minute grokProbeRetryTTL = 1 * time.Minute grokFreeQuotaWindow = 24 * time.Hour - openAICodexProbeVersion = "0.144.1" + openAICodexProbeVersion = codexCLIVersion // 与网关出站身份同源,避免两处硬编码版本各自漂移 ) // UsageCache 封装账户使用量相关的缓存 @@ -768,7 +768,7 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco } } // 与真实转发一致:originator 与最终 User-Agent(可能来自指纹缓存)首段配套,否则探针被上游 - // 404(issue #3901);缓存里的降载桶身份同样在此归一化,避免探针被回 server_is_overloaded。 + // 404(issue #3901);缓存里被优先降载的身份同样在此归一化,避免探针被回 server_is_overloaded。 enforceCodexIdentityHeaders(req.Header) setOpenAIChatGPTAccountHeaders(req.Header, account) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index aa3153d0e8..ab1e2f6a3a 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -526,6 +526,16 @@ const ( // 当客户端 UA 被识别为浏览器(Chrome/Firefox/Safari/Edge 等)时,转发给 OpenAI 上游前会替换为此值, // 用于避免 Cloudflare 对浏览器型 UA 的质询拦截。 SettingKeyOpenAICodexUserAgent = "openai_codex_user_agent" + // SettingKeyOpenAICodexClientVersion 网关对 ChatGPT 上游声明的 Codex 客户端版本号(管理员覆写)。 + // 空值表示跟随自动同步值;自动同步也没有结果时回退到内置常量。 + // 上游在容量紧张时按客户端身份分优先级降载,陈旧版本会被优先丢弃,故该值需保持跟随官方发布。 + SettingKeyOpenAICodexClientVersion = "openai_codex_client_version" + // SettingKeyOpenAICodexClientVersionSynced 自动同步任务写入的官方 Codex 最新稳定版版本号。 + // 由 OpenAICodexVersionSyncService 独占写入,面板只读展示;管理员覆写请用 + // SettingKeyOpenAICodexClientVersion。 + SettingKeyOpenAICodexClientVersionSynced = "openai_codex_client_version_synced" + // SettingKeyOpenAICodexVersionAutoSyncEnabled 是否启用 Codex 客户端版本号自动同步(默认 true)。 + SettingKeyOpenAICodexVersionAutoSyncEnabled = "openai_codex_version_auto_sync_enabled" // SettingKeyOpenAIAllowClaudeCodeCodexPlugin 已废弃:历史全局开关只作为升级迁移输入读取。 // 迁移后等价规则写入 SettingKeyCodexCLIOnlyWhitelist,不再参与运行时判定。 SettingKeyOpenAIAllowClaudeCodeCodexPlugin = "openai_allow_claude_code_codex_plugin" diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 9795425346..bb4ce85476 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -633,6 +633,7 @@ type UpstreamFailoverError struct { ResponseHeaders http.Header // 上游响应头,用于透传 cf-ray/cf-mitigated/content-type 等诊断信息 ForceCacheBilling bool // Antigravity 粘性会话切换时设为 true RetryableOnSameAccount bool // 临时性错误(如 Google 间歇性 400、空响应),应在同一账号上重试 N 次再切换 + RequestScopedTransient bool // 故障因素与账号无关(如上游按客户端身份/模型容量降载):可同账号重试,但不得据此对账号做临时封禁 SafeToFailoverAfterWrite bool // 仅写出 SSE 注释等非语义字节时,仍可在同一客户端流中切换账号 Stage GatewayFailureStage Scope GatewayFailureScope @@ -684,6 +685,11 @@ func (s *GatewayService) TempUnscheduleRetryableError(ctx context.Context, accou if failoverErr == nil || !failoverErr.RetryableOnSameAccount { return } + // 请求级瞬时故障与账号健康无关:封禁只会把与故障无关的账号一并摘掉, + // 而故障因素(客户端身份、模型容量)在下一个账号上完全相同。 + if failoverErr.RequestScopedTransient { + return + } // 根据状态码选择封禁策略 switch failoverErr.StatusCode { case http.StatusBadRequest: diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index 488db038e0..9d2a70fdaf 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -264,8 +264,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c req.Header.Set("Session_ID", isolated) req.Header.Set("Conversation_ID", isolated) } - s.overrideBrowserUserAgent(ctx, account, req) - enforceCodexIdentityHeaders(req.Header) + enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) account.ApplyHeaderOverrides(req.Header) return req, nil } @@ -402,8 +401,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { req.Header.Set("User-Agent", codexCLIUserAgent) } - s.overrideBrowserUserAgent(ctx, account, req) - enforceCodexIdentityHeaders(req.Header) + enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) } account.ApplyHeaderOverrides(req.Header) diff --git a/backend/internal/service/openai_alpha_search_test.go b/backend/internal/service/openai_alpha_search_test.go index e31fd9e01d..f4ad2506c7 100644 --- a/backend/internal/service/openai_alpha_search_test.go +++ b/backend/internal/service/openai_alpha_search_test.go @@ -88,7 +88,7 @@ func TestForwardAlphaSearchOAuthPreservesWire(t *testing.T) { require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "chatgpt-account", upstream.lastReq.Header.Get("chatgpt-account-id")) require.Equal(t, "application/json", upstream.lastReq.Header.Get("Accept")) - require.Equal(t, "0.144.1", upstream.lastReq.Header.Get("Version")) + require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version")) require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta")) require.JSONEq(t, string(body), string(upstream.lastBody)) } @@ -153,7 +153,7 @@ func TestForwardAlphaSearchPATUsesResponsesWebSearchFallback(t *testing.T) { require.Equal(t, "application/json", upstream.lastReq.Header.Get("Content-Type")) require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept")) require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta")) - require.Equal(t, "0.144.1", upstream.lastReq.Header.Get("Version")) + require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version")) require.Equal(t, `{"turn_id":"turn-1"}`, upstream.lastReq.Header.Get("X-Codex-Turn-Metadata")) require.Equal(t, "codex_cli_rs", upstream.lastReq.Header.Get("Originator")) require.Empty(t, upstream.lastReq.Header.Get("X-Codex-Beta-Features")) diff --git a/backend/internal/service/openai_capacity_shed_test.go b/backend/internal/service/openai_capacity_shed_test.go new file mode 100644 index 0000000000..2376149a4f --- /dev/null +++ b/backend/internal/service/openai_capacity_shed_test.go @@ -0,0 +1,85 @@ +package service + +import ( + "context" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// --- mock: 只记录临时不可调度写入,其余方法不应被调用 --- + +type capacityShedAccountRepoStub struct { + AccountRepository // 嵌入接口,未实现的方法会 panic(不应被调用) + + tempUnschedCalls int +} + +func (r *capacityShedAccountRepoStub) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, _ string) error { + r.tempUnschedCalls++ + return nil +} + +// 上游容量降载是请求级信号:故障因素(客户端身份、模型容量)与账号无关, +// 同账号重试用尽后不得把账号临时摘掉——否则一个被降载的请求会顺着 failover +// 把整池账号逐个封禁,而每个账号都会以同一个错误失败。 +func TestTempUnscheduleRetryableErrorSkipsRequestScopedTransient(t *testing.T) { + t.Run("请求级瞬时故障不写账号状态", func(t *testing.T) { + repo := &capacityShedAccountRepoStub{} + svc := &GatewayService{accountRepo: repo} + + svc.TempUnscheduleRetryableError(context.Background(), 1, &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + RetryableOnSameAccount: true, + RequestScopedTransient: true, + }) + + require.Zero(t, repo.tempUnschedCalls) + }) + + // 对照组:同样的 502 在未标记请求级瞬时故障时仍按原有语义临时摘号, + // 确认上面的断言来自新增守卫而非其他前置条件。 + t.Run("未标记时保持原有临时摘号语义", func(t *testing.T) { + repo := &capacityShedAccountRepoStub{} + svc := &GatewayService{accountRepo: repo} + + svc.TempUnscheduleRetryableError(context.Background(), 1, &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + RetryableOnSameAccount: true, + }) + + require.Equal(t, 1, repo.tempUnschedCalls) + }) +} + +// 非池模式账号同样要先在同账号重试:换号不改变降载因素。 +func TestStreamFailedEventCapacityShedRetriesOnSameAccount(t *testing.T) { + nonPool := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + + for _, code := range []string{"server_is_overloaded", "slow_down"} { + payload := []byte(`{"type":"response.failed","response":{"error":{"code":"` + code + `"}}}`) + require.True(t, isOpenAIUpstreamCapacityShedEvent(payload), code) + require.True(t, openAIStreamFailedEventRetryableOnSameAccount(nonPool, payload, "overloaded"), code) + } + + // 非降载的 failed 事件在非池模式下仍不做同账号重试,避免放大改动面。 + other := []byte(`{"type":"response.failed","response":{"error":{"code":"server_error"}}}`) + require.False(t, isOpenAIUpstreamCapacityShedEvent(other)) + require.False(t, openAIStreamFailedEventRetryableOnSameAccount(nonPool, other, "boom")) +} + +// 出站身份的版本声明只能有一个来源:UA 的版本段、version 头、探针版本三处必须同源, +// 各自硬编码会漂移成互相矛盾的身份,而自相矛盾或陈旧的身份会被上游优先降载。 +func TestCodexOutboundVersionHasSingleSource(t *testing.T) { + require.True(t, + strings.HasPrefix(codexCLIUserAgent, "codex_cli_rs/"+codexCLIVersion+" "), + "codexCLIUserAgent=%q 必须以 codexCLIVersion=%q 作为版本段", codexCLIUserAgent, codexCLIVersion, + ) + require.Equal(t, codexCLIVersion, openAICodexProbeVersion) + require.GreaterOrEqual(t, CompareVersions(codexCLIVersion, codexUpstreamMinVersion), 0, + "codexCLIVersion=%q 不得低于上游最低门槛 %q", codexCLIVersion, codexUpstreamMinVersion, + ) +} diff --git a/backend/internal/service/openai_codex_identity.go b/backend/internal/service/openai_codex_identity.go index 2b51244c23..1ef39799d3 100644 --- a/backend/internal/service/openai_codex_identity.go +++ b/backend/internal/service/openai_codex_identity.go @@ -2,7 +2,9 @@ package service import ( "net/http" + "regexp" "strings" + "sync" "sync/atomic" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" @@ -13,37 +15,125 @@ import ( // 若请求携带 version 且低于该值,上游直接 404(issue #3901,2026-07 实测)。 const codexUpstreamMinVersion = "0.144.0" -// codexOriginatorNormalization 控制 enforceCodexIdentityHeaders 是否把落在上游降载桶的 -// Codex 身份改写为 CLI 身份,由 gateway.disable_codex_originator_normalization 在服务构造时取反发布。 -// 默认开启:降载桶命中会让上游回 server_is_overloaded,网关据此判定瞬时故障并冷却账号。 -var codexOriginatorNormalization = func() *atomic.Bool { +// codexClientVersionMaxLen 官方版本号均为短 ASCII 串,远低于此上限。 +const codexClientVersionMaxLen = 64 + +// codexClientVersionPattern 允许 0.146.0 与 0.147.0-alpha.4 两类官方形态。 +var codexClientVersionPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+){1,3}(-[0-9A-Za-z.]+)?$`) + +// NormalizeCodexClientVersion 校验并归一化 Codex 客户端版本号,非法值返回空串。 +// 该值会被拼进出站 User-Agent 与 version 头,必须拒绝任意字节,避免管理员误填或 +// 自动同步拿到异常值时把不可控内容透给上游。 +func NormalizeCodexClientVersion(version string) string { + version = strings.TrimSpace(version) + if version == "" || len(version) > codexClientVersionMaxLen || !codexClientVersionPattern.MatchString(version) { + return "" + } + return version +} + +// buildCodexCLIUserAgent 按版本号拼出规范 Codex CLI User-Agent。 +// UA 形态只在 codexCLIUserAgentSuffix 一处定义,避免多处拼装漂移。 +func buildCodexCLIUserAgent(version string) string { + if version = NormalizeCodexClientVersion(version); version == "" { + return codexCLIUserAgent + } + return openai.CodexCLIOriginator + "/" + version + codexCLIUserAgentSuffix +} + +// codexIdentityEnforcement 控制 enforceCodexIdentityHeaders 是否强制统一出站身份, +// 由 gateway.disable_codex_identity_enforcement 在服务构造时取反发布。 +// 默认开启:上游在容量紧张时按客户端身份分优先级降载,被降载的请求会拿到 +// HTTP 200 + 流内 server_is_overloaded,本次请求即失败;强制统一出口可确保没有 +// 请求带着第三方或陈旧身份出站。关闭后退回「仅按最终 UA 配对 originator」的收口语义。 +var codexIdentityEnforcement = func() *atomic.Bool { v := &atomic.Bool{} v.Store(true) return v }() -// SetCodexOriginatorNormalizationEnabled 发布 Codex 降载身份归一化开关。 +// SetCodexIdentityEnforcementEnabled 发布 Codex 出站身份强制统一开关。 // enforceCodexIdentityHeaders 是所有出站路径共用的纯函数收口点,无法在热路径注入配置, // 故由持有配置的服务在构造时发布进程级快照。 -func SetCodexOriginatorNormalizationEnabled(enabled bool) { - codexOriginatorNormalization.Store(enabled) +func SetCodexIdentityEnforcementEnabled(enabled bool) { + codexIdentityEnforcement.Store(enabled) +} + +// codexCanonicalUserAgentResolver 返回当前生效的规范 Codex User-Agent(后台设置 / 自动同步版本号)。 +// 由 SettingService 在装配时注入;解析器内部自带 TTL 缓存,热路径不触库。 +type codexCanonicalUserAgentResolver func() string + +var ( + codexCanonicalUAMu sync.RWMutex + codexCanonicalUAResolver codexCanonicalUserAgentResolver +) + +// SetCodexCanonicalUserAgentResolver 注入规范 User-Agent 解析器。 +// 未注入或解析结果非法时回退到编译期常量 codexCLIUserAgent。 +func SetCodexCanonicalUserAgentResolver(resolver func() string) { + codexCanonicalUAMu.Lock() + defer codexCanonicalUAMu.Unlock() + codexCanonicalUAResolver = resolver +} + +// codexCanonicalUserAgent 返回出站规范 User-Agent。 +func codexCanonicalUserAgent() string { + codexCanonicalUAMu.RLock() + resolver := codexCanonicalUAResolver + codexCanonicalUAMu.RUnlock() + if resolver != nil { + if ua := strings.TrimSpace(resolver()); ua != "" { + return ua + } + } + return codexCLIUserAgent +} + +// codexOutboundIdentity 出站身份三元组,三者必须同源自洽: +// originator 与 User-Agent 首段配套(否则上游 404,issue #3901), +// version 等于 User-Agent 的版本段且不低于上游门槛。 +type codexOutboundIdentity struct { + userAgent string + originator string + version string +} + +// resolveCodexOutboundIdentity 由候选 User-Agent 推导自洽的出站身份。 +// candidateUA 为空时使用规范 User-Agent;推导不出官方身份时整体回退为规范 CLI 身份。 +func resolveCodexOutboundIdentity(candidateUA string) codexOutboundIdentity { + ua := strings.TrimSpace(candidateUA) + if ua == "" { + ua = codexCanonicalUserAgent() + } + originator, pairedUA, ok := openai.PairCodexClientIdentity(ua) + if !ok { + canonical := codexCanonicalUserAgent() + if originator, pairedUA, ok = openai.PairCodexClientIdentity(canonical); !ok { + originator, pairedUA = openai.CodexCLIOriginator, codexCLIUserAgent + } + } + version := NormalizeCodexClientVersion(openai.CodexUserAgentVersion(pairedUA)) + if version == "" || CompareVersions(version, codexUpstreamMinVersion) < 0 { + version = codexCLIVersion + } + return codexOutboundIdentity{userAgent: pairedUA, originator: originator, version: version} } // ensureCodexIdentityHeaders 补齐 OAuth(ChatGPT 内部接口)出站请求所需的 Codex 身份头。 -// 已有 User-Agent 与 version 保持不变,交给紧随其后的 enforceCodexIdentityHeaders -// 做官方身份配对与最低版本校正。 +// 已有 User-Agent 与 version 保持不变,交给紧随其后的 enforceCodexIdentityHeaders 收口。 func ensureCodexIdentityHeaders(h http.Header) { if h == nil { return } + identity := resolveCodexOutboundIdentity("") if strings.TrimSpace(h.Get("user-agent")) == "" { - h.Set("user-agent", codexCLIUserAgent) + h.Set("user-agent", identity.userAgent) } if strings.TrimSpace(h.Get("originator")) == "" { - h.Set("originator", openai.CodexCLIOriginator) + h.Set("originator", identity.originator) } if strings.TrimSpace(h.Get("version")) == "" { - h.Set("version", codexCLIVersion) + h.Set("version", identity.version) } h.Set("OpenAI-Beta", "responses=experimental") } @@ -58,27 +148,47 @@ func applyOpenAICodexProbeHeaders(h http.Header) { } // enforceCodexIdentityHeaders 收口 OAuth(ChatGPT 内部接口)出站请求的客户端身份头。 -// 上游要求 originator 与 User-Agent 首段配套且为官方客户端标识,version 头(若携带) -// 不低于 0.144.0,任一不满足即 404(issue #3901)。以最终 User-Agent 为准推导配套 -// originator;推导不出官方身份(第三方 UA / UA 缺失)时整体回退为默认 Codex CLI 身份。 -// -// 配对之后再做降载身份归一化:上游按 originator 分桶调度容量,命中降载桶的请求会被回 -// server_is_overloaded,网关据此判定瞬时上游故障并冷却账号(对外表现为账号过载不可用), -// 故这类身份统一改写为 CLI 身份——只替换身份段,保留版本 / OS / 架构 / 终端指纹。 -// -// 仅对携带 originator 的请求生效;需要从缺失身份头恢复的调用方应先调用 -// ensureCodexIdentityHeaders。 -// 必须在所有 User-Agent 改写(自定义 UA / ForceCodexCLI / 浏览器 UA 兜底)之后调用。 +// 见 enforceCodexIdentityHeadersWithUA;无账号级自定义 User-Agent 时使用本函数。 func enforceCodexIdentityHeaders(h http.Header) { + enforceCodexIdentityHeadersWithUA(h, "") +} + +// enforceCodexIdentityHeadersWithUA 强制统一 OAuth 出站身份:User-Agent / originator / version +// 一律改写为网关的规范身份,客户端自报身份不参与构造。上游在容量紧张时按客户端身份分优先级 +// 降载,被降载的请求会拿到 HTTP 200 + 流内 server_is_overloaded;统一出口可确保没有请求带着 +// 第三方或陈旧身份出站,也天然满足 originator 与 UA 首段配套的上游校验(issue #3901)。 +// +// overrideUA 是账号级自定义 User-Agent:管理员的显式配置仍然生效,但会被重新配对出配套的 +// originator 与同源 version,不允许出现自相矛盾的身份。 +// +// 强制统一被 gateway.disable_codex_identity_enforcement 关闭时,退回「按最终 User-Agent 配对 +// originator + version 门槛校正」的收口语义,供上游策略变动时回滚。 +// +// 仅对携带 originator 的请求生效:compat 桥接等非 ChatGPT 内部接口路径会显式删除 originator, +// 不应被补回。需要从缺失身份头恢复的调用方应先调用 ensureCodexIdentityHeaders。 +// 必须在所有 User-Agent 改写之后调用。 +func enforceCodexIdentityHeadersWithUA(h http.Header, overrideUA string) { if h == nil || h.Get("originator") == "" { return } + if !codexIdentityEnforcement.Load() { + pairCodexIdentityHeaders(h) + return + } + identity := resolveCodexOutboundIdentity(overrideUA) + h.Set("user-agent", identity.userAgent) + h.Set("originator", identity.originator) + h.Set("version", identity.version) +} + +// pairCodexIdentityHeaders 是关闭强制统一后的兜底收口:保留客户端真实身份, +// 仅保证 originator 与最终 User-Agent 首段配套、version 不低于上游门槛(issue #3901)。 +func pairCodexIdentityHeaders(h http.Header) { originator, pairedUA, ok := openai.PairCodexClientIdentity(h.Get("user-agent")) if !ok { - originator, pairedUA = openai.CodexCLIOriginator, codexCLIUserAgent - } - if codexOriginatorNormalization.Load() { - originator, pairedUA, _ = openai.NormalizeCodexClientIdentityToCLI(originator, pairedUA) + identity := resolveCodexOutboundIdentity("") + originator, pairedUA = identity.originator, identity.userAgent + h.Set("version", identity.version) } h.Set("user-agent", pairedUA) h.Set("originator", originator) diff --git a/backend/internal/service/openai_codex_identity_test.go b/backend/internal/service/openai_codex_identity_test.go index da9adea298..ec8c4ad296 100644 --- a/backend/internal/service/openai_codex_identity_test.go +++ b/backend/internal/service/openai_codex_identity_test.go @@ -17,6 +17,9 @@ func requireOpenAICodexProbeHeaders(t *testing.T, h http.Header) { require.NotEmpty(t, h.Get("X-Codex-Window-ID")) } +// 强制统一出口:无论客户端自报什么身份,OAuth 出站的 User-Agent / originator / version +// 一律是网关规范身份。上游在容量紧张时按客户端身份分优先级降载,统一出口确保没有请求 +// 带着第三方或陈旧身份出站。 func TestEnsureCodexIdentityHeaders(t *testing.T) { t.Run("补齐缺失身份头", func(t *testing.T) { h := make(http.Header) @@ -30,124 +33,76 @@ func TestEnsureCodexIdentityHeaders(t *testing.T) { require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta")) }) - t.Run("保留已有官方UA和合法version并重新配对", func(t *testing.T) { - const vscodeUA = "codex_vscode/9.9.9 (Mac OS X 14.0; arm64) vscode (codex_vscode; 9.9.9)" + t.Run("官方非 CLI 客户端身份同样被统一", func(t *testing.T) { h := make(http.Header) - h.Set("user-agent", vscodeUA) + h.Set("user-agent", "codex_vscode/9.9.9 (Mac OS X 14.0; arm64) vscode (codex_vscode; 9.9.9)") h.Set("version", "9.9.9") h.Set("OpenAI-Beta", "assistants=v2") ensureCodexIdentityHeaders(h) enforceCodexIdentityHeaders(h) - require.Equal(t, "codex_vscode", h.Get("originator")) - require.Equal(t, vscodeUA, h.Get("user-agent")) - require.Equal(t, "9.9.9", h.Get("version")) - require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta")) - }) - - t.Run("降载身份归一化后保留版本与终端指纹", func(t *testing.T) { - h := make(http.Header) - h.Set("user-agent", "codex-tui/9.9.9 (Mac OS X 14.0; arm64) iTerm (codex-tui; 9.9.9)") - h.Set("version", "9.9.9") - - ensureCodexIdentityHeaders(h) - enforceCodexIdentityHeaders(h) - require.Equal(t, "codex_cli_rs", h.Get("originator")) - require.Equal(t, "codex_cli_rs/9.9.9 (Mac OS X 14.0; arm64) iTerm", h.Get("user-agent")) - require.Equal(t, "9.9.9", h.Get("version")) + require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) + require.Equal(t, codexCLIVersion, h.Get("version")) + require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta")) }) } func TestEnforceCodexIdentityHeaders(t *testing.T) { - const tuiUA = "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)" - // codex-tui 落在上游降载桶,收口时统一改写为 CLI 身份(保留版本/OS/架构/终端指纹)。 - const tuiNormalizedUA = "codex_cli_rs/0.140.2 (Mac OS X 14.0; arm64) iTerm" - tests := []struct { - name string - originator string - userAgent string - version string - wantOriginator string - wantUA string - wantVersion string + name string + originator string + userAgent string + version string }{ { - name: "错配 originator 按最终 UA 重配后归一化", - originator: "codex_cli_rs", - userAgent: tuiUA, - wantOriginator: "codex_cli_rs", - wantUA: tuiNormalizedUA, + name: "TUI 身份", + originator: "codex-tui", + userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", }, { - name: "降载身份改写为 CLI 身份", - originator: "codex-tui", - userAgent: tuiUA, - wantOriginator: "codex_cli_rs", - wantUA: tuiNormalizedUA, + name: "错配 originator", + originator: "codex_cli_rs", + userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", }, { - name: "非降载官方身份原样保留", - originator: "codex_vscode", - userAgent: "codex_vscode/1.2.3 (Ubuntu 22.4.0; x86_64) vscode (codex_vscode; 1.2.3)", - wantOriginator: "codex_vscode", - wantUA: "codex_vscode/1.2.3 (Ubuntu 22.4.0; x86_64) vscode (codex_vscode; 1.2.3)", + name: "官方 vscode 身份", + originator: "codex_vscode", + userAgent: "codex_vscode/1.2.3 (Ubuntu 22.4.0; x86_64) vscode (codex_vscode; 1.2.3)", }, { - name: "第三方 UA 整体回退默认身份", - originator: "opencode", - userAgent: "luna/1.0.0", - wantOriginator: "codex_cli_rs", - wantUA: codexCLIUserAgent, + name: "第三方客户端身份", + originator: "opencode", + userAgent: "luna/1.0.0", + version: "2.1.0", }, { - name: "UA 缺失回退默认身份", - originator: "codex_vscode", - wantOriginator: "codex_cli_rs", - wantUA: codexCLIUserAgent, + name: "浏览器型 UA(原浏览器兜底已被统一出口吸收)", + originator: "codex_cli_rs", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", }, { - name: "originator override UA 首段被尾部真实身份重写后归一化", - originator: "cccc", - userAgent: "cccc/0.142.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.142.0)", - wantOriginator: "codex_cli_rs", - wantUA: "codex_cli_rs/0.142.0 (Ubuntu 22.4.0; x86_64) screen", + name: "UA 缺失", + originator: "codex_vscode", }, { - name: "低于门槛的 version 提升为内置版本", - originator: "codex_cli_rs", - userAgent: "codex_cli_rs/0.125.0", - version: "0.125.0", - wantOriginator: "codex_cli_rs", - wantUA: "codex_cli_rs/0.125.0", - wantVersion: codexCLIVersion, + name: "originator override 的真实 TUI 客户端", + originator: "cccc", + userAgent: "cccc/0.142.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.142.0)", }, { - name: "达标 version 原样保留", - originator: "codex_cli_rs", - userAgent: "codex_cli_rs/0.145.0", - version: "0.145.0", - wantOriginator: "codex_cli_rs", - wantUA: "codex_cli_rs/0.145.0", - wantVersion: "0.145.0", - }, - { - name: "未携带 version 不注入", - originator: "codex_cli_rs", - userAgent: "codex_cli_rs/0.98.0", - wantOriginator: "codex_cli_rs", - wantUA: "codex_cli_rs/0.98.0", + name: "陈旧客户端版本", + originator: "codex_cli_rs", + userAgent: "codex_cli_rs/0.125.0", + version: "0.125.0", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := make(http.Header) - if tt.originator != "" { - h.Set("originator", tt.originator) - } + h.Set("originator", tt.originator) if tt.userAgent != "" { h.Set("user-agent", tt.userAgent) } @@ -157,24 +112,61 @@ func TestEnforceCodexIdentityHeaders(t *testing.T) { enforceCodexIdentityHeaders(h) - require.Equal(t, tt.wantOriginator, h.Get("originator")) - require.Equal(t, tt.wantUA, h.Get("user-agent")) - require.Equal(t, tt.wantVersion, h.Get("version")) + require.Equal(t, "codex_cli_rs", h.Get("originator")) + require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) + require.Equal(t, codexCLIVersion, h.Get("version")) }) } } -// 开关是进程级快照,零值 Config(测试 / 工具手工构造,不经 viper)必须落在「归一化开启」 -// 一侧,否则任意一处零值构造都会静默关掉全局保护。 -// -// 不得给本文件的开关类用例加 t.Parallel():它们改写进程级状态。 -func TestCodexOriginatorNormalizationZeroValueConfigKeepsItEnabled(t *testing.T) { - var cfg config.Config - require.False(t, cfg.Gateway.DisableCodexOriginatorNormalization, - "零值必须表示归一化开启;若改为正向命名的 NormalizeCodexOriginator,零值会静默关闭保护") +// 账号级自定义 UA 是管理员的显式配置,仍然生效;但必须被重新配对出配套的 originator +// 与同源 version,不允许出现自相矛盾的身份。 +func TestEnforceCodexIdentityHeadersWithAccountOverrideUA(t *testing.T) { + t.Run("官方形态覆写 UA 被配对并对齐 version", func(t *testing.T) { + h := make(http.Header) + h.Set("originator", "codex-tui") + h.Set("user-agent", "luna/1.0.0") + h.Set("version", "2.1.0") - SetCodexOriginatorNormalizationEnabled(!cfg.Gateway.DisableCodexOriginatorNormalization) - t.Cleanup(func() { SetCodexOriginatorNormalizationEnabled(true) }) + enforceCodexIdentityHeadersWithUA(h, "codex_vscode/0.150.0 (Ubuntu 22.4.0; x86_64) vscode") + + require.Equal(t, "codex_vscode", h.Get("originator")) + require.Equal(t, "codex_vscode/0.150.0 (Ubuntu 22.4.0; x86_64) vscode", h.Get("user-agent")) + require.Equal(t, "0.150.0", h.Get("version")) + }) + + t.Run("非官方形态覆写 UA 回退规范身份", func(t *testing.T) { + h := make(http.Header) + h.Set("originator", "codex_cli_rs") + + enforceCodexIdentityHeadersWithUA(h, "luna/1.0.0") + + require.Equal(t, "codex_cli_rs", h.Get("originator")) + require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) + require.Equal(t, codexCLIVersion, h.Get("version")) + }) + + // 覆写 UA 自报版本低于上游门槛时 version 头必须抬升,否则上游 404(issue #3901)。 + t.Run("覆写 UA 版本低于门槛时抬升 version", func(t *testing.T) { + h := make(http.Header) + h.Set("originator", "codex_cli_rs") + + enforceCodexIdentityHeadersWithUA(h, "codex_cli_rs/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color") + + require.Equal(t, "codex_cli_rs", h.Get("originator")) + require.Equal(t, "codex_cli_rs/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color", h.Get("user-agent")) + require.Equal(t, codexCLIVersion, h.Get("version")) + }) +} + +// 规范身份跟随注入的解析器(后台面板 UA / 自动同步版本号),无需重启或发版。 +// +// 不得给本用例加 t.Parallel():它改写进程级解析器。 +func TestEnforceCodexIdentityHeadersFollowsCanonicalResolver(t *testing.T) { + SetCodexCanonicalUserAgentResolver(func() string { + return "codex_cli_rs/0.200.1 (Ubuntu 22.4.0; x86_64) xterm-256color" + }) + t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) }) h := make(http.Header) h.Set("originator", "codex-tui") @@ -183,41 +175,104 @@ func TestCodexOriginatorNormalizationZeroValueConfigKeepsItEnabled(t *testing.T) enforceCodexIdentityHeaders(h) require.Equal(t, "codex_cli_rs", h.Get("originator")) + require.Equal(t, "codex_cli_rs/0.200.1 (Ubuntu 22.4.0; x86_64) xterm-256color", h.Get("user-agent")) + require.Equal(t, "0.200.1", h.Get("version")) } -// 关闭归一化后必须完整退回配对语义:降载身份逐字保留,供上游调整分桶后回滚使用。 -func TestEnforceCodexIdentityHeaders_NormalizationDisabled(t *testing.T) { - const tuiUA = "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)" +// 解析器返回非法值(配置被写坏、同步到异常内容)时必须回退到内置身份, +// 绝不能把不可控内容拼进出站身份。 +// +// 不得给本用例加 t.Parallel():它改写进程级解析器。 +func TestEnforceCodexIdentityHeadersRejectsInvalidCanonicalUA(t *testing.T) { + SetCodexCanonicalUserAgentResolver(func() string { return "not-a-codex-client" }) + t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) }) - SetCodexOriginatorNormalizationEnabled(false) - t.Cleanup(func() { SetCodexOriginatorNormalizationEnabled(true) }) + h := make(http.Header) + h.Set("originator", "codex_cli_rs") + + enforceCodexIdentityHeaders(h) + + require.Equal(t, "codex_cli_rs", h.Get("originator")) + require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) + require.Equal(t, codexCLIVersion, h.Get("version")) +} + +// 开关是进程级快照,零值 Config(测试 / 工具手工构造,不经 viper)必须落在「强制统一开启」 +// 一侧,否则任意一处零值构造都会静默关掉全局保护。 +// +// 不得给本文件的开关类用例加 t.Parallel():它们改写进程级状态。 +func TestCodexIdentityEnforcementZeroValueConfigKeepsItEnabled(t *testing.T) { + var cfg config.Config + require.False(t, cfg.Gateway.DisableCodexIdentityEnforcement, + "零值必须表示强制统一开启;若改为正向命名,零值会静默关闭保护") + + SetCodexIdentityEnforcementEnabled(!cfg.Gateway.DisableCodexIdentityEnforcement) + t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) }) + + h := make(http.Header) + h.Set("originator", "codex-tui") + h.Set("user-agent", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)") + + enforceCodexIdentityHeaders(h) + + require.Equal(t, "codex_cli_rs", h.Get("originator")) + require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) +} + +// 关闭强制统一后退回配对语义:客户端真实身份逐字保留,仅保证 originator 与 UA 首段配套 +// (issue #3901),供上游策略变动时回滚。 +func TestEnforceCodexIdentityHeaders_EnforcementDisabled(t *testing.T) { + const tuiUA = "codex-tui/0.145.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.145.2)" + + SetCodexIdentityEnforcementEnabled(false) + t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) }) h := make(http.Header) h.Set("originator", "codex-tui") h.Set("user-agent", tuiUA) + h.Set("version", "0.145.2") enforceCodexIdentityHeaders(h) require.Equal(t, "codex-tui", h.Get("originator")) require.Equal(t, tuiUA, h.Get("user-agent")) + require.Equal(t, "0.145.2", h.Get("version")) } -// 归一化必须是幂等的:重复收口(如透传路径先后经过多次改写)不得反复裁剪 UA。 -func TestEnforceCodexIdentityHeaders_NormalizationIsIdempotent(t *testing.T) { +// 关闭强制统一后,第三方 UA 仍整体回退为规范身份并对齐 version。 +func TestEnforceCodexIdentityHeaders_EnforcementDisabledThirdPartyFallback(t *testing.T) { + SetCodexIdentityEnforcementEnabled(false) + t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) }) + + h := make(http.Header) + h.Set("originator", "opencode") + h.Set("user-agent", "luna/1.0.0") + h.Set("version", "2.1.0") + + enforceCodexIdentityHeaders(h) + + require.Equal(t, "codex_cli_rs", h.Get("originator")) + require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) + require.Equal(t, codexCLIVersion, h.Get("version")) +} + +// 收口必须幂等:透传等路径可能先后多次经过收口。 +func TestEnforceCodexIdentityHeadersIsIdempotent(t *testing.T) { h := make(http.Header) h.Set("originator", "codex-tui") h.Set("user-agent", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)") enforceCodexIdentityHeaders(h) - first := h.Get("user-agent") + firstUA, firstVersion := h.Get("user-agent"), h.Get("version") enforceCodexIdentityHeaders(h) - require.Equal(t, first, h.Get("user-agent")) + require.Equal(t, firstUA, h.Get("user-agent")) + require.Equal(t, firstVersion, h.Get("version")) require.Equal(t, "codex_cli_rs", h.Get("originator")) } -// enforce 本身仍只负责收口:缺少 originator 时必须保持 no-op,由需要恢复身份的 -// 调用方先显式调用 ensureCodexIdentityHeaders。 +// 缺少 originator 时必须保持 no-op:compat 桥接等非 ChatGPT 内部接口路径会显式删除 +// originator,不应被补回身份头。 func TestEnforceCodexIdentityHeaders_NoOriginatorIsNoop(t *testing.T) { h := make(http.Header) h.Set("user-agent", "third-party-client/1.0.0") @@ -225,5 +280,24 @@ func TestEnforceCodexIdentityHeaders_NoOriginatorIsNoop(t *testing.T) { enforceCodexIdentityHeaders(h) require.Empty(t, h.Get("originator")) + require.Empty(t, h.Get("version")) require.Equal(t, "third-party-client/1.0.0", h.Get("user-agent")) } + +func TestNormalizeCodexClientVersion(t *testing.T) { + require.Equal(t, "0.146.0", NormalizeCodexClientVersion(" 0.146.0 ")) + require.Equal(t, "0.147.0-alpha.4", NormalizeCodexClientVersion("0.147.0-alpha.4")) + require.Equal(t, "1.2", NormalizeCodexClientVersion("1.2")) + require.Empty(t, NormalizeCodexClientVersion("")) + require.Empty(t, NormalizeCodexClientVersion("v0.146.0")) + require.Empty(t, NormalizeCodexClientVersion("0.146.0 (Ubuntu)")) + require.Empty(t, NormalizeCodexClientVersion("0.146.0\r\nX-Injected: 1")) + require.Empty(t, NormalizeCodexClientVersion("latest")) +} + +func TestBuildCodexCLIUserAgent(t *testing.T) { + require.Equal(t, "codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix, buildCodexCLIUserAgent("0.200.1")) + // 非法版本号必须回退到内置 UA,不能拼出畸形身份。 + require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent("bogus version")) + require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent("")) +} diff --git a/backend/internal/service/openai_codex_version_sync_service.go b/backend/internal/service/openai_codex_version_sync_service.go new file mode 100644 index 0000000000..cc3c37ffe5 --- /dev/null +++ b/backend/internal/service/openai_codex_version_sync_service.go @@ -0,0 +1,164 @@ +package service + +import ( + "context" + "log/slog" + "strings" + "sync" + "time" +) + +const ( + // openAICodexVersionSyncInterval 自动同步间隔。上游客户端发版频率是天级, + // 3 小时足够及时跟上,同时把对 GitHub API 的调用压到每天 8 次。 + openAICodexVersionSyncInterval = 3 * time.Hour + // openAICodexVersionSyncTimeout 单次同步的整体超时。 + openAICodexVersionSyncTimeout = 30 * time.Second + // openAICodexVersionSyncRepo 官方 Codex 客户端仓库。 + openAICodexVersionSyncRepo = "openai/codex" + // openAICodexVersionSyncPerPage 单次拉取的 release 数量。该仓库同时发布 + // 预发布与其他组件的 tag,取一页后本地筛选比只看 latest 稳妥。 + openAICodexVersionSyncPerPage = 30 + // openAICodexVersionTagPrefix 客户端 release 的 tag 前缀(如 rust-v0.146.0)。 + // 同仓库还有其他组件的 tag(如 rusty-v8-*),必须按前缀过滤,否则会同步到无关版本号。 + openAICodexVersionTagPrefix = "rust-v" +) + +// OpenAICodexVersionSyncService 周期性把官方 Codex 客户端的最新稳定版版本号同步到设置, +// 供出站规范身份使用,避免为了跟上游版本而发新版本。 +// +// 同步值写入 SettingKeyOpenAICodexClientVersionSynced(本服务独占写入);管理员在面板填写的 +// SettingKeyOpenAICodexClientVersion 优先级更高,因此手工固定版本不会被同步覆盖。 +type OpenAICodexVersionSyncService struct { + settingRepo SettingRepository + settingService *SettingService + githubClient GitHubReleaseClient + interval time.Duration + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup +} + +func NewOpenAICodexVersionSyncService( + settingRepo SettingRepository, + settingService *SettingService, + githubClient GitHubReleaseClient, + interval time.Duration, +) *OpenAICodexVersionSyncService { + return &OpenAICodexVersionSyncService{ + settingRepo: settingRepo, + settingService: settingService, + githubClient: githubClient, + interval: interval, + stopCh: make(chan struct{}), + } +} + +func (s *OpenAICodexVersionSyncService) 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.runOnce() + for { + select { + case <-ticker.C: + s.runOnce() + case <-s.stopCh: + return + } + } + }() +} + +func (s *OpenAICodexVersionSyncService) Stop() { + if s == nil { + return + } + s.stopOnce.Do(func() { + close(s.stopCh) + }) + s.wg.Wait() +} + +func (s *OpenAICodexVersionSyncService) runOnce() { + ctx, cancel := context.WithTimeout(context.Background(), openAICodexVersionSyncTimeout) + defer cancel() + + if !s.autoSyncEnabled(ctx) { + return + } + + releases, err := s.githubClient.FetchRecentReleases(ctx, openAICodexVersionSyncRepo, openAICodexVersionSyncPerPage) + if err != nil { + slog.Warn("openai_codex_version_sync_fetch_failed", "error", err) + return + } + latest := latestCodexStableReleaseVersion(releases) + if latest == "" { + slog.Warn("openai_codex_version_sync_no_stable_release", "repo", openAICodexVersionSyncRepo) + return + } + + current := NormalizeCodexClientVersion(s.currentSyncedVersion(ctx)) + // 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步的版本号降级。 + if current != "" && CompareVersions(latest, current) <= 0 { + return + } + if err := s.settingRepo.Set(ctx, SettingKeyOpenAICodexClientVersionSynced, latest); err != nil { + slog.Warn("openai_codex_version_sync_persist_failed", "version", latest, "error", err) + return + } + s.settingService.InvalidateOpenAICodexClientVersionCache() + slog.Info("openai_codex_version_synced", "previous", current, "version", latest) +} + +// autoSyncEnabled 读取面板开关。缺失或空值视为开启,与设置默认值一致; +// 读取失败时保持开启,避免一次数据库抖动就静默停掉版本跟随。 +func (s *OpenAICodexVersionSyncService) autoSyncEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAICodexVersionAutoSyncEnabled) + if err != nil { + return true + } + if strings.TrimSpace(value) == "" { + return true + } + return strings.TrimSpace(value) == "true" +} + +func (s *OpenAICodexVersionSyncService) currentSyncedVersion(ctx context.Context) string { + value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAICodexClientVersionSynced) + if err != nil { + return "" + } + return value +} + +// latestCodexStableReleaseVersion 从 release 列表里挑出最大的稳定版客户端版本号。 +// 过滤条件:tag 前缀为 rust-v(排除同仓库其他组件的 tag)、非草稿、非预发布、 +// 版本号不带 -alpha/-beta 之类后缀。取最大值而非最新发布,避免重新发布历史 tag 造成回退。 +func latestCodexStableReleaseVersion(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, openAICodexVersionTagPrefix) { + continue + } + version := NormalizeCodexClientVersion(strings.TrimPrefix(tag, openAICodexVersionTagPrefix)) + if version == "" || strings.Contains(version, "-") { + continue + } + if best == "" || CompareVersions(version, best) > 0 { + best = version + } + } + return best +} diff --git a/backend/internal/service/openai_codex_version_sync_service_test.go b/backend/internal/service/openai_codex_version_sync_service_test.go new file mode 100644 index 0000000000..b448f07a4f --- /dev/null +++ b/backend/internal/service/openai_codex_version_sync_service_test.go @@ -0,0 +1,246 @@ +package service + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +// --- mock: 只实现同步任务用到的读写,其余方法不应被调用 --- + +type codexVersionSyncSettingRepoStub struct { + SettingRepository // 嵌入接口,未实现的方法会 panic(不应被调用) + + mu sync.Mutex + values map[string]string + getErr error + setErr error + writes []string +} + +func newCodexVersionSyncSettingRepoStub(values map[string]string) *codexVersionSyncSettingRepoStub { + if values == nil { + values = map[string]string{} + } + return &codexVersionSyncSettingRepoStub{values: values} +} + +func (r *codexVersionSyncSettingRepoStub) 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 *codexVersionSyncSettingRepoStub) 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 *codexVersionSyncSettingRepoStub) syncedWrites() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.writes...) +} + +type codexVersionSyncGitHubStub struct { + GitHubReleaseClient // 嵌入接口,未实现的方法会 panic(不应被调用) + + releases []*GitHubRelease + err error + calls int +} + +func (c *codexVersionSyncGitHubStub) FetchRecentReleases(_ context.Context, _ string, _ int) ([]*GitHubRelease, error) { + c.calls++ + if c.err != nil { + return nil, c.err + } + return c.releases, nil +} + +func newCodexVersionSyncService( + repo SettingRepository, + github GitHubReleaseClient, +) *OpenAICodexVersionSyncService { + return NewOpenAICodexVersionSyncService(repo, &SettingService{}, github, openAICodexVersionSyncInterval) +} + +// 同仓库同时发布预发布版与其他组件的 tag,必须只认 rust-v 前缀的稳定版, +// 否则会把无关组件的版本号(如 rusty-v8-v150.4.0)当成客户端版本同步出去。 +func TestLatestCodexStableReleaseVersion(t *testing.T) { + releases := []*GitHubRelease{ + {TagName: "rusty-v8-v150.4.0"}, + {TagName: "rust-v0.147.0-alpha.4", Prerelease: true}, + {TagName: "rust-v0.146.0"}, + {TagName: "rust-v0.145.0"}, + {TagName: "rust-v0.999.0", Draft: true}, + {TagName: "not-a-tag"}, + nil, + } + + require.Equal(t, "0.146.0", latestCodexStableReleaseVersion(releases)) + require.Empty(t, latestCodexStableReleaseVersion(nil)) + require.Empty(t, latestCodexStableReleaseVersion([]*GitHubRelease{{TagName: "rusty-v8-v150.4.0"}})) + // 预发布 tag 即使漏标 Prerelease 也要被版本号后缀挡住。 + require.Empty(t, latestCodexStableReleaseVersion([]*GitHubRelease{{TagName: "rust-v0.147.0-alpha.4"}})) +} + +func TestOpenAICodexVersionSyncWritesLatestStableVersion(t *testing.T) { + repo := newCodexVersionSyncSettingRepoStub(nil) + github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{ + {TagName: "rust-v0.145.0"}, + {TagName: "rust-v0.146.0"}, + }} + + newCodexVersionSyncService(repo, github).runOnce() + + require.Equal(t, []string{"0.146.0"}, repo.syncedWrites()) +} + +// 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步版本降级。 +func TestOpenAICodexVersionSyncNeverMovesBackwards(t *testing.T) { + repo := newCodexVersionSyncSettingRepoStub(map[string]string{ + SettingKeyOpenAICodexClientVersionSynced: "0.146.0", + }) + github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.145.0"}}} + + newCodexVersionSyncService(repo, github).runOnce() + + require.Empty(t, repo.syncedWrites()) +} + +func TestOpenAICodexVersionSyncSkippedWhenDisabled(t *testing.T) { + repo := newCodexVersionSyncSettingRepoStub(map[string]string{ + SettingKeyOpenAICodexVersionAutoSyncEnabled: "false", + }) + github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}} + + newCodexVersionSyncService(repo, github).runOnce() + + require.Zero(t, github.calls, "关闭自动同步后不应请求上游") + require.Empty(t, repo.syncedWrites()) +} + +// 面板开关缺失或为空一律视为开启,与设置默认值一致。 +func TestOpenAICodexVersionSyncEnabledByDefault(t *testing.T) { + for _, value := range []string{"", "true"} { + repo := newCodexVersionSyncSettingRepoStub(map[string]string{ + SettingKeyOpenAICodexVersionAutoSyncEnabled: value, + }) + github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}} + + newCodexVersionSyncService(repo, github).runOnce() + + require.Equal(t, []string{"0.146.0"}, repo.syncedWrites(), "开关值 %q", value) + } +} + +// 抓取失败保持既有值,不清空、不降级。 +func TestOpenAICodexVersionSyncKeepsValueOnFetchError(t *testing.T) { + repo := newCodexVersionSyncSettingRepoStub(map[string]string{ + SettingKeyOpenAICodexClientVersionSynced: "0.146.0", + }) + github := &codexVersionSyncGitHubStub{err: errors.New("network down")} + + newCodexVersionSyncService(repo, github).runOnce() + + require.Empty(t, repo.syncedWrites()) + value, err := repo.GetValue(context.Background(), SettingKeyOpenAICodexClientVersionSynced) + require.NoError(t, err) + require.Equal(t, "0.146.0", value) +} + +// 依赖缺失时 Start 必须直接返回,不能起一个空转的 goroutine。 +func TestOpenAICodexVersionSyncStartRequiresDependencies(t *testing.T) { + require.NotPanics(t, func() { + svc := NewOpenAICodexVersionSyncService(nil, nil, nil, openAICodexVersionSyncInterval) + svc.Start() + svc.Stop() + }) +} + +// --- mock: 版本号读取只用到 GetMultiple --- + +type codexVersionSettingRepoStub struct { + SettingRepository // 嵌入接口,未实现的方法会 panic(不应被调用) + + values map[string]string + err error +} + +func (r *codexVersionSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { + if r.err != nil { + return nil, r.err + } + out := make(map[string]string, len(keys)) + for _, key := range keys { + out[key] = r.values[key] + } + return out, nil +} + +// 规范 UA 解析会先读面板的完整 UA 键。 +func (r *codexVersionSettingRepoStub) GetValue(_ context.Context, key string) (string, error) { + if r.err != nil { + return "", r.err + } + return r.values[key], nil +} + +// 版本号优先级:管理员面板覆写 → 自动同步值 → 内置常量。 +// 管理员覆写必须压过同步值,否则「固定版本」的诉求会被 3 小时后的同步冲掉。 +func TestGetOpenAICodexClientVersionPriority(t *testing.T) { + tests := []struct { + name string + override string + synced string + want string + }{ + {name: "面板覆写优先", override: "0.150.0", synced: "0.146.0", want: "0.150.0"}, + {name: "覆写为空时用同步值", synced: "0.146.0", want: "0.146.0"}, + {name: "两者皆空时用内置常量", want: codexCLIVersion}, + {name: "非法覆写回退同步值", override: "latest", synced: "0.146.0", want: "0.146.0"}, + {name: "非法同步值回退内置常量", synced: "not-a-version", want: codexCLIVersion}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ + SettingKeyOpenAICodexClientVersion: tt.override, + SettingKeyOpenAICodexClientVersionSynced: tt.synced, + }}, nil) + + require.Equal(t, tt.want, svc.GetOpenAICodexClientVersion(context.Background())) + }) + } +} + +// 读取失败必须回退内置常量,不能返回空串把畸形身份拼给上游。 +func TestGetOpenAICodexClientVersionFallsBackOnError(t *testing.T) { + svc := NewSettingService(&codexVersionSettingRepoStub{err: errors.New("db down")}, nil) + require.Equal(t, codexCLIVersion, svc.GetOpenAICodexClientVersion(context.Background())) +} + +// 规范 UA:面板未填完整 UA 时按当前生效版本号拼出标准 CLI 形态。 +func TestGetOpenAICodexCanonicalUserAgentBuildsFromVersion(t *testing.T) { + svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ + SettingKeyOpenAICodexClientVersionSynced: "0.200.1", + }}, nil) + + require.Equal(t, + "codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix, + svc.GetOpenAICodexCanonicalUserAgent(context.Background()), + ) +} diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index 9596d57290..ac5ed8d0d7 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -1021,34 +1021,15 @@ func TestForwardAsAnthropic_OAuthRestoresCodexIdentityHeaders(t *testing.T) { const tuiUA = "codex-tui/9.9.9 (Mac OS X 14.0; arm64) iTerm (codex-tui; 9.9.9)" const vscodeUA = "codex_vscode/9.9.9 (Mac OS X 14.0; arm64) vscode (codex_vscode; 9.9.9)" + // messages 桥接路径同样强制统一出站身份:三类客户端身份都收敛到规范身份。 tests := []struct { - name string - userAgent string - originator string - wantUserAgent string - wantOriginator string + name string + userAgent string + originator string }{ - { - name: "官方UA逐字保留并重新配对", - userAgent: vscodeUA, - originator: "opencode", - wantUserAgent: vscodeUA, - wantOriginator: "codex_vscode", - }, - { - name: "降载身份改写为CLI身份并保留终端指纹", - userAgent: tuiUA, - originator: "opencode", - wantUserAgent: "codex_cli_rs/9.9.9 (Mac OS X 14.0; arm64) iTerm", - wantOriginator: "codex_cli_rs", - }, - { - name: "第三方UA回退为默认Codex身份", - userAgent: "third-party-client/1.0.0", - originator: "opencode", - wantUserAgent: codexCLIUserAgent, - wantOriginator: "codex_cli_rs", - }, + {name: "官方vscode身份", userAgent: vscodeUA, originator: "opencode"}, + {name: "TUI身份", userAgent: tuiUA, originator: "opencode"}, + {name: "第三方UA", userAgent: "third-party-client/1.0.0", originator: "opencode"}, } for _, tt := range tests { @@ -1081,7 +1062,7 @@ func TestForwardAsAnthropic_OAuthRestoresCodexIdentityHeaders(t *testing.T) { result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4") require.NoError(t, err) require.NotNil(t, result) - requireOpenAIMessagesCodexIdentity(t, upstream.lastReq, tt.wantUserAgent, tt.wantOriginator) + requireOpenAIMessagesCodexIdentity(t, upstream.lastReq, codexCLIUserAgent, "codex_cli_rs") }) } } diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index c4f54c0301..0484ea7d36 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -1098,13 +1098,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. req.Header.Set("user-agent", codexCLIUserAgent) } - // 浏览器型 UA 兜底:仅 OAuth(ChatGPT 内部接口)账号生效,若最终 user-agent 仍为浏览器 - // (Chrome/Firefox/Safari/Edge 等),替换为后台配置的 Codex UA,避免 Cloudflare 触发 JS 质询。 - s.overrideBrowserUserAgent(ctx, account, req) - - // 终态收口:originator 必须与最终 User-Agent 首段配套且为官方身份,否则上游 404(issue #3901)。 + // 终态收口:强制统一 OAuth 出站身份(User-Agent / originator / version 同源自洽)。 + // 客户端自报身份不参与构造,浏览器型 UA 也因此不会再到达上游(原浏览器 UA 兜底已被吸收)。 if account.Type == AccountTypeOAuth { - enforceCodexIdentityHeaders(req.Header) + enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) } // Ensure required headers exist @@ -1118,26 +1115,12 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. return req, nil } -// overrideBrowserUserAgent 检查请求的最终 user-agent,若为浏览器 UA 则替换为后台配置的 Codex UA。 -// 用于规避 Cloudflare 对浏览器型 UA 在 ChatGPT 内部接口上的访问质询。 -// 影响范围严格限定:仅 OAuth(Codex/ChatGPT 内部接口)账号生效;API Key 等其他账号原样透传。 -// 仅在识别为浏览器(Mozilla/...)时改写,其他 CLI/工具 UA 不动。 -func (s *OpenAIGatewayService) overrideBrowserUserAgent(ctx context.Context, account *Account, req *http.Request) { - if req == nil || account == nil { - return +// codexIdentityOverrideUA 返回账号级显式配置的出站 User-Agent,供强制统一身份时作为覆写来源。 +// ForceCodexCLI 语义是「强制使用 Codex CLI 身份」,等价于使用网关规范身份,故返回空串; +// 该优先级与历史行为一致(ForceCodexCLI 在账号自定义 UA 之后生效)。 +func (s *OpenAIGatewayService) codexIdentityOverrideUA(account *Account) string { + if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { + return "" } - if account.Type != AccountTypeOAuth { - return - } - currentUA := req.Header.Get("user-agent") - if !openai.IsBrowserUserAgent(currentUA) { - return - } - codexUA := DefaultOpenAICodexUserAgent - if s != nil && s.settingService != nil { - if v := strings.TrimSpace(s.settingService.GetOpenAICodexUserAgent(ctx)); v != "" { - codexUA = v - } - } - req.Header.Set("user-agent", codexUA) + return account.GetOpenAIUserAgent() } diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 04990dc761..c86eb4f489 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -443,15 +443,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { req.Header.Set("user-agent", codexCLIUserAgent) } - // 浏览器型 UA 兜底:仅 OAuth(ChatGPT 内部接口)账号生效,若最终 user-agent 仍为浏览器 - // (Chrome/Firefox/Safari/Edge 等),替换为后台配置的 Codex UA,避免 Cloudflare 触发 JS 质询。 - s.overrideBrowserUserAgent(ctx, account, req) - - // 终态收口:originator 必须与最终 User-Agent 首段配套且为官方身份,非官方 UA 整体回退为 - // 默认 Codex CLI 身份(承接原「非 Codex UA 安全兜底」,并修复其把 codex-tui 等官方 UA 改写为 - // codex_cli_rs 造成的 originator 错配 404),详见 issue #3901。 + // 终态收口:透传路径的 OAuth 与非透传完全一致,同样强制统一出站身份 + // (User-Agent / originator / version 同源自洽),客户端自报身份不会到达上游。 if account.Type == AccountTypeOAuth { - enforceCodexIdentityHeaders(req.Header) + enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) } if req.Header.Get("content-type") == "" { @@ -767,15 +762,34 @@ func openAIStreamDataStartsClientOutput(data, eventType string) bool { return !openAIStreamEventIsPreamble(eventType) } +// openAIStreamFailedEventErrorCode 提取流内 failed 事件的错误码(小写), +// 兼容 response.failed 的嵌套形态与裸 error 形态。 +func openAIStreamFailedEventErrorCode(payload []byte) string { + code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String())) + if code == "" { + code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String())) + } + return code +} + +// isOpenAIUpstreamCapacityShedEvent 判断流内 failed 事件是否为上游容量降载信号。 +// 上游在容量紧张时会把请求丢进降载路径:HTTP 200 之后立刻推 event: error +// (code=server_is_overloaded / slow_down)并以 response.failed 收尾。 +func isOpenAIUpstreamCapacityShedEvent(payload []byte) bool { + switch openAIStreamFailedEventErrorCode(payload) { + case "server_is_overloaded", "slow_down": + return true + default: + return false + } +} + func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int { if isOpenAIContextWindowError(message, payload) { return http.StatusBadRequest } - code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String())) - if code == "" { - code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String())) - } + code := openAIStreamFailedEventErrorCode(payload) errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.type").String())) if errType == "" { errType = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.type").String())) @@ -925,7 +939,17 @@ func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool } func openAIStreamFailedEventRetryableOnSameAccount(account *Account, payload []byte, message string) bool { - if account == nil || !account.IsPoolMode() { + if account == nil { + return false + } + // 容量降载是请求级信号,不是账号级故障:上游只是让本次请求稍后再试。 + // 换账号并不改变被降载的因素(客户端身份、模型容量都与账号无关), + // 只会让单个请求把整池账号逐个消耗掉,最终仍以同一个错误告终。 + // 因此先在同一账号上做有界重试,用尽后才按常规流程切号。 + if isOpenAIUpstreamCapacityShedEvent(payload) { + return true + } + if !account.IsPoolMode() { return false } semanticStatus := openAIStreamFailedEventSemanticStatus(payload, message) @@ -1013,6 +1037,7 @@ func (s *OpenAIGatewayService) newOpenAIStreamFailoverError( ResponseBody: body, ResponseHeaders: headers, RetryableOnSameAccount: openAIStreamFailedEventRetryableOnSameAccount(account, payload, message), + RequestScopedTransient: isOpenAIUpstreamCapacityShedEvent(payload), } } diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 8f7911f002..f2e00dda4c 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -35,8 +35,13 @@ const ( openaiStickySessionTTL = time.Hour // 粘性会话TTL // 与真实 Codex CLI 的 User-Agent 结构对齐: // {originator}/{version} ({OS} {OS_version}; {arch}) {terminal} - // 旧值 "codex_cli_rs/0.125.0" 缺少 OS/架构/终端后缀,易被上游指纹识别为非官方客户端。 - codexCLIUserAgent = "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color" + // 缺少 OS/架构/终端后缀的形态易被上游指纹识别为非官方客户端。 + // 该后缀是 UA 形态的唯一定义处,buildCodexCLIUserAgent 按运行时版本号复用它。 + codexCLIUserAgentSuffix = " (Ubuntu 22.4.0; x86_64) xterm-256color" + // codexCLIUserAgent 是编译期兜底 UA;运行时优先使用由后台版本号拼出的规范 UA。 + // 版本段必须来自 codexCLIVersion:UA 与 version 头是同一个版本声明的两个出口, + // 各自硬编码会漂移成互相矛盾的身份。 + codexCLIUserAgent = "codex_cli_rs/" + codexCLIVersion + codexCLIUserAgentSuffix // codex_cli_only 拒绝时单个请求头日志长度上限(字符) codexCLIOnlyHeaderValueMaxBytes = 256 @@ -51,7 +56,12 @@ const ( openAIWSRetryJitterRatioDefault = 0.2 openAICompactSessionSeedKey = "openai_compact_session_seed" openAIUpstreamEndpointContextKey = "openai_actual_upstream_endpoint" - codexCLIVersion = "0.144.1" + // codexCLIVersion 是网关对上游声明的 Codex 客户端版本,同时供 codexCLIUserAgent + // 与 version 头使用。上游 /backend-api/codex 在容量紧张时按客户端身份分优先级降载, + // 陈旧版本会被优先丢弃(HTTP 200 + 流内 server_is_overloaded);非官方客户端配不出 + // 官方身份时整体回退到本常量,因此它必须跟随官方 CLI 的当前发布版本, + // 落后多个版本会让这些请求稳定落在被优先丢弃的一侧。 + codexCLIVersion = "0.146.0" // Codex 限额快照仅用于后台展示/诊断,不需要每个成功请求都立即落库。 openAICodexSnapshotPersistMinInterval = 30 * time.Second // 配额自动暂停时,超过该时长仍未刷新的 used% 快照视为陈旧,不再据此暂停账号。 @@ -472,9 +482,9 @@ func NewOpenAIGatewayService( userPlatformQuotaRepo UserPlatformQuotaRepository, ) *OpenAIGatewayService { // enforceCodexIdentityHeaders 是 HTTP / 透传 / WS / 探针 等出站路径共用的纯函数收口点, - // 拿不到配置,故在此发布进程级开关快照。配置取反义,零值即「归一化开启」。 + // 拿不到配置,故在此发布进程级开关快照。配置取反义,零值即「强制统一出口开启」。 if cfg != nil { - SetCodexOriginatorNormalizationEnabled(!cfg.Gateway.DisableCodexOriginatorNormalization) + SetCodexIdentityEnforcementEnabled(!cfg.Gateway.DisableCodexIdentityEnforcement) } svc := &OpenAIGatewayService{ accountRepo: accountRepo, diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index d03d48e00f..b19c7a8919 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -1692,6 +1692,10 @@ func TestOpenAIStreamingResponseFailedBeforeOutputServerOverloadedCodeReturnsFai require.ErrorAs(t, err, &failoverErr) require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) require.Contains(t, string(failoverErr.ResponseBody), "Please retry later") + // 容量降载是请求级信号:非池模式账号也要先在同账号重试,且不得据此临时封禁账号。 + // 否则单个被降载的请求会把整池账号逐个消耗掉,而降载因素在每个账号上都相同。 + require.True(t, failoverErr.RetryableOnSameAccount) + require.True(t, failoverErr.RequestScopedTransient) require.False(t, c.Writer.Written()) require.Empty(t, rec.Body.String()) } @@ -2983,33 +2987,26 @@ func TestOpenAIBuildUpstreamRequestPreservesCodexIdentityHeaders(t *testing.T) { func TestOpenAIBuildUpstreamRequestOAuthOfficialClientOriginatorCompatibility(t *testing.T) { gin.SetMode(gin.TestMode) - // 上游要求 originator 与最终 User-Agent 首段配套(issue #3901): - // originator 一律由最终 UA 推导;推导不出官方身份时整体回退默认 Codex CLI 身份。 + // 强制统一出口:客户端自报的 originator / User-Agent 都不参与上游身份构造, + // 一律改写为网关规范身份,天然满足 originator 与 UA 首段配套的上游校验(issue #3901)。 tests := []struct { - name string - userAgent string - originator string - wantOriginator string - wantUA string + name string + userAgent string + originator string }{ - {name: "official ua pairs originator", userAgent: "Codex Desktop/1.2.3", wantOriginator: "Codex Desktop", wantUA: "Codex Desktop/1.2.3"}, + {name: "official desktop ua", userAgent: "Codex Desktop/1.2.3"}, { - name: "mismatched originator repaired from ua", - userAgent: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)", - originator: "codex_cli_rs", - wantOriginator: "codex_vscode", - wantUA: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)", + name: "mismatched originator", + userAgent: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)", + originator: "codex_cli_rs", }, { - // 降载桶身份改写为 CLI 身份,只替换身份段、保留版本/OS/架构/终端指纹。 - name: "load-shed originator normalized to cli identity", - userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", - originator: "codex-tui", - wantOriginator: "codex_cli_rs", - wantUA: "codex_cli_rs/0.140.2 (Mac OS X 14.0; arm64) iTerm", + name: "tui identity", + userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", + originator: "codex-tui", }, - {name: "official originator without ua falls back to default identity", originator: "codex_vscode", wantOriginator: "codex_cli_rs", wantUA: codexCLIUserAgent}, - {name: "third-party ua masked to default identity", userAgent: "luna/1.2.0", wantOriginator: "codex_cli_rs", wantUA: codexCLIUserAgent}, + {name: "official originator without ua", originator: "codex_vscode"}, + {name: "third-party ua", userAgent: "luna/1.2.0"}, } for _, tt := range tests { @@ -3033,8 +3030,9 @@ func TestOpenAIBuildUpstreamRequestOAuthOfficialClientOriginatorCompatibility(t isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator")) req, err := svc.buildUpstreamRequest(c.Request.Context(), c, account, []byte(`{"model":"gpt-5"}`), "token", false, "", isCodexCLI) require.NoError(t, err) - require.Equal(t, tt.wantOriginator, req.Header.Get("originator")) - require.Equal(t, tt.wantUA, req.Header.Get("User-Agent")) + require.Equal(t, "codex_cli_rs", req.Header.Get("originator")) + require.Equal(t, codexCLIUserAgent, req.Header.Get("User-Agent")) + require.Equal(t, codexCLIVersion, req.Header.Get("version")) }) } } diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 66fe9db337..31f30a1d96 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -414,7 +414,8 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali // 2) only auth is replaced; inbound auth/cookie are not forwarded require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("Authorization")) - require.Equal(t, "codex_cli_rs/0.1.0", upstream.lastReq.Header.Get("User-Agent")) + // 强制统一出口:客户端自报的 codex_cli_rs/0.1.0 不会到达上游。 + require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent")) require.Empty(t, upstream.lastReq.Header.Get("Cookie")) require.Empty(t, upstream.lastReq.Header.Get("X-Api-Key")) require.Empty(t, upstream.lastReq.Header.Get("X-Goog-Api-Key")) @@ -1784,13 +1785,10 @@ func TestOpenAIGatewayService_OAuthPassthrough_NonCodexUAFallbackToCodexUA(t *te require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent")) } -// 回归(issue #3901):官方 UA 在透传模式下必须逐字保留,且 originator 由最终 UA 推导 -// 配套——历史实现会把官方 UA 强改为 codex_cli_rs,而 originator 保留客户端原值, -// 造成 originator/UA 首段错配被上游 404。 -// -// 用例取 codex_vscode 而非 codex-tui:后者落在上游降载桶,会被身份归一化改写, -// 该行为由 ..._CodexTuiIdentityNormalizedToCLI 单独覆盖。 -func TestOpenAIGatewayService_OAuthPassthrough_OfficialIdentityPreservedAndPaired(t *testing.T) { +// 透传模式的 OAuth 与非透传一致:官方客户端身份同样被强制统一为网关规范身份, +// originator 与 UA 首段天然配套,不会出现历史上 originator/UA 错配被上游 404 的形态 +// (issue #3901)。 +func TestOpenAIGatewayService_OAuthPassthrough_OfficialIdentityUnified(t *testing.T) { gin.SetMode(gin.TestMode) const tuiUA = "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)" @@ -1832,14 +1830,13 @@ func TestOpenAIGatewayService_OAuthPassthrough_OfficialIdentityPreservedAndPaire _, err := svc.Forward(context.Background(), c, account, inputBody) require.NoError(t, err) require.NotNil(t, upstream.lastReq) - require.Equal(t, tuiUA, upstream.lastReq.Header.Get("User-Agent")) - require.Equal(t, "codex_vscode", upstream.lastReq.Header.Get("originator")) + require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, "codex_cli_rs", upstream.lastReq.Header.Get("originator")) + require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("version")) } -// 透传模式同样要做降载身份归一化:codex-tui 落在上游降载桶,会被回 -// server_is_overloaded 并触发账号冷却,故改写为 CLI 身份,仅替换身份段、 -// 保留版本 / OS / 架构 / 终端指纹,且改写后仍与 originator 配套(issue #3901)。 -func TestOpenAIGatewayService_OAuthPassthrough_CodexTuiIdentityNormalizedToCLI(t *testing.T) { +// 透传模式下真实 TUI 客户端的身份同样被统一:被优先降载的身份不会带到上游。 +func TestOpenAIGatewayService_OAuthPassthrough_CodexTuiIdentityUnified(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() @@ -1877,8 +1874,9 @@ func TestOpenAIGatewayService_OAuthPassthrough_CodexTuiIdentityNormalizedToCLI(t _, err := svc.Forward(context.Background(), c, account, inputBody) require.NoError(t, err) require.NotNil(t, upstream.lastReq) - require.Equal(t, "codex_cli_rs/0.140.2 (Mac OS X 14.0; arm64) iTerm", upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, "codex_cli_rs", upstream.lastReq.Header.Get("originator")) + require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("version")) } func TestOpenAIGatewayService_CodexCLIOnly_RejectsNonCodexClient(t *testing.T) { diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index 41c3acc68a..a0ac478c2c 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -757,32 +757,25 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testing.T) { gin.SetMode(gin.TestMode) - // 上游要求 originator 与最终 user-agent 首段配套(issue #3901): - // originator 一律由最终 UA 推导;推导不出官方身份时整体回退默认 Codex CLI 身份。 + // WS 握手头与 HTTP 出站共用身份收口:同样强制统一为网关规范身份, + // 客户端自报的 originator / user-agent 不参与构造(issue #3901 的配对不变式自然满足)。 tests := []struct { - name string - userAgent string - originator string - wantOriginator string - wantUA string + name string + userAgent string + originator string }{ - {name: "official ua pairs originator", userAgent: "Codex Desktop/1.2.3", wantOriginator: "Codex Desktop", wantUA: "Codex Desktop/1.2.3"}, + {name: "official desktop ua", userAgent: "Codex Desktop/1.2.3"}, { - name: "mismatched originator repaired from ua", - userAgent: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)", - originator: "codex_cli_rs", - wantOriginator: "codex_vscode", - wantUA: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)", + name: "mismatched originator", + userAgent: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)", + originator: "codex_cli_rs", }, { - // WS 握手头与 HTTP 出站共用收口,降载桶身份同样改写为 CLI 身份。 - name: "load-shed originator normalized to cli identity", - userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", - originator: "codex-tui", - wantOriginator: "codex_cli_rs", - wantUA: "codex_cli_rs/0.140.2 (Mac OS X 14.0; arm64) iTerm", + name: "tui identity", + userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", + originator: "codex-tui", }, - {name: "official originator without ua falls back to default identity", originator: "codex_vscode", wantOriginator: "codex_cli_rs", wantUA: codexCLIUserAgent}, + {name: "official originator without ua", originator: "codex_vscode"}, } for _, tt := range tests { @@ -846,8 +839,9 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testi result, err := svc.Forward(context.Background(), c, account, body) require.NoError(t, err) require.NotNil(t, result) - require.Equal(t, tt.wantOriginator, captureDialer.lastHeaders.Get("originator")) - require.Equal(t, tt.wantUA, captureDialer.lastHeaders.Get("user-agent")) + require.Equal(t, "codex_cli_rs", captureDialer.lastHeaders.Get("originator")) + require.Equal(t, codexCLIUserAgent, captureDialer.lastHeaders.Get("user-agent")) + require.Equal(t, codexCLIVersion, captureDialer.lastHeaders.Get("version")) }) } } diff --git a/backend/internal/service/setting_gateway_runtime.go b/backend/internal/service/setting_gateway_runtime.go index 5b1c605c52..378e6a3fde 100644 --- a/backend/internal/service/setting_gateway_runtime.go +++ b/backend/internal/service/setting_gateway_runtime.go @@ -83,8 +83,9 @@ const antigravityUserAgentVersionErrorTTL = 5 * time.Second const antigravityUserAgentVersionDBTimeout = 5 * time.Second // DefaultOpenAICodexUserAgent OpenAI Codex 默认 User-Agent(用于规避 Cloudflare 对浏览器 UA 的质询)。 -// 取官方 CLI 身份而非 TUI 身份:上游按 originator 分桶调度容量,TUI 身份命中降载桶会被回 -// server_is_overloaded 并触发账号冷却,而该默认值是浏览器 UA 兜底路径上最主要的身份来源。 +// 取官方 CLI 身份而非 TUI 身份:上游在容量紧张时按客户端身份分优先级降载,TUI 身份被观测到 +// 优先命中降载,而该默认值是浏览器 UA 兜底路径上最主要的身份来源。版本段随 codexCLIVersion +// 一起更新——陈旧版本同样会被优先降载。 const DefaultOpenAICodexUserAgent = codexCLIUserAgent // cachedOpenAICodexUserAgent 缓存 OpenAI Codex UA(进程内缓存,60s TTL) @@ -93,6 +94,19 @@ type cachedOpenAICodexUserAgent struct { expiresAt int64 // unix nano } +// cachedOpenAICodexClientVersion 缓存出站 Codex 客户端版本号(进程内缓存,60s TTL) +type cachedOpenAICodexClientVersion struct { + version string + expiresAt int64 // unix nano +} + +const openAICodexClientVersionCacheTTL = 60 * time.Second +const openAICodexClientVersionErrorTTL = 5 * time.Second +const openAICodexClientVersionDBTimeout = 5 * time.Second + +// openAICodexClientVersionSFKey singleflight 键。 +const openAICodexClientVersionSFKey = "openai_codex_client_version" + type cachedOpenAIQuotaAutoPauseSettings struct { settings OpsOpenAIAccountQuotaAutoPauseSettings expiresAt int64 @@ -284,6 +298,88 @@ func (s *SettingService) GetOpenAICodexUserAgent(ctx context.Context) string { return fallback } +// GetOpenAICodexClientVersion 返回出站声明的 Codex 客户端版本号。 +// 优先级:管理员在面板覆写的版本 → 自动同步到的官方最新稳定版 → 内置常量。 +// 上游在容量紧张时按客户端身份分优先级降载,陈旧版本会被优先丢弃,故该值需保持跟随官方发布; +// 自动同步让运维不必为了跟版本而发新版本。 +func (s *SettingService) GetOpenAICodexClientVersion(ctx context.Context) string { + fallback := codexCLIVersion + if s == nil || s.settingRepo == nil { + return fallback + } + if cached, ok := s.openAICodexVersionCache.Load().(*cachedOpenAICodexClientVersion); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.version + } + } + + result, _, _ := s.openAICodexVersionSF.Do(openAICodexClientVersionSFKey, func() (any, error) { + if cached, ok := s.openAICodexVersionCache.Load().(*cachedOpenAICodexClientVersion); 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), openAICodexClientVersionDBTimeout) + defer cancel() + values, err := s.settingRepo.GetMultiple(dbCtx, []string{ + SettingKeyOpenAICodexClientVersion, + SettingKeyOpenAICodexClientVersionSynced, + }) + if err != nil { + slog.Warn("failed to get openai codex client version setting", "error", err) + s.openAICodexVersionCache.Store(&cachedOpenAICodexClientVersion{ + version: fallback, + expiresAt: time.Now().Add(openAICodexClientVersionErrorTTL).UnixNano(), + }) + return fallback, nil + } + version := NormalizeCodexClientVersion(values[SettingKeyOpenAICodexClientVersion]) + if version == "" { + version = NormalizeCodexClientVersion(values[SettingKeyOpenAICodexClientVersionSynced]) + } + if version == "" { + version = fallback + } + s.openAICodexVersionCache.Store(&cachedOpenAICodexClientVersion{ + version: version, + expiresAt: time.Now().Add(openAICodexClientVersionCacheTTL).UnixNano(), + }) + return version, nil + }) + if version, ok := result.(string); ok && version != "" { + return version + } + return fallback +} + +// InvalidateOpenAICodexClientVersionCache 丢弃版本号缓存,下次读取回源。 +// 面板保存与自动同步写入后调用。 +func (s *SettingService) InvalidateOpenAICodexClientVersionCache() { + if s == nil { + return + } + s.openAICodexVersionSF.Forget(openAICodexClientVersionSFKey) + s.openAICodexVersionCache.Store((*cachedOpenAICodexClientVersion)(nil)) +} + +// GetOpenAICodexCanonicalUserAgent 返回出站规范 Codex User-Agent。 +// 管理员在面板显式填写的完整 UA 优先;否则按当前生效的客户端版本号拼出标准 CLI UA。 +// +// 面板值等于编译期兜底常量时视同未填写:两者不可区分,而按版本号拼装能拿到更新的版本, +// 需要固定版本请填「Codex 客户端版本号」并关闭自动同步。 +func (s *SettingService) GetOpenAICodexCanonicalUserAgent(ctx context.Context) string { + if s == nil { + return codexCLIUserAgent + } + if ua := strings.TrimSpace(s.GetOpenAICodexUserAgent(ctx)); ua != "" && ua != DefaultOpenAICodexUserAgent { + return ua + } + return buildCodexCLIUserAgent(s.GetOpenAICodexClientVersion(ctx)) +} + var legacyClaudeCodeCodexWhitelistEntry = openai.AllowedClientEntry{ Originator: "Claude Code", UAContains: []string{"Claude Code/"}, diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index d5075038b1..d73e114e13 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -227,6 +227,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyEnableClientDatelineNormalization: "true", SettingKeyAntigravityUserAgentVersion: "", SettingKeyOpenAICodexUserAgent: "", + SettingKeyOpenAICodexClientVersion: "", + SettingKeyOpenAICodexClientVersionSynced: "", + SettingKeyOpenAICodexVersionAutoSyncEnabled: "true", SettingPaymentVisibleMethodAlipaySource: "", SettingPaymentVisibleMethodWxpaySource: "", SettingPaymentVisibleMethodAlipayEnabled: "false", @@ -824,6 +827,14 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } result.AntigravityUserAgentVersion = antigravity.NormalizeUserAgentVersion(settings[SettingKeyAntigravityUserAgentVersion]) result.OpenAICodexUserAgent = strings.TrimSpace(settings[SettingKeyOpenAICodexUserAgent]) + result.OpenAICodexClientVersion = NormalizeCodexClientVersion(settings[SettingKeyOpenAICodexClientVersion]) + result.OpenAICodexClientVersionSynced = NormalizeCodexClientVersion(settings[SettingKeyOpenAICodexClientVersionSynced]) + // 自动同步默认开启:缺失/空值一律视为开启,与 enable_client_dateline_normalization 同一惯例。 + if v, ok := settings[SettingKeyOpenAICodexVersionAutoSyncEnabled]; ok && v != "" { + result.OpenAICodexVersionAutoSyncEnabled = v == "true" + } else { + result.OpenAICodexVersionAutoSyncEnabled = true + } // codex_cli_only 加固 result.MinCodexVersion = settings[SettingKeyMinCodexVersion] result.MaxCodexVersion = settings[SettingKeyMaxCodexVersion] diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 35bcccb823..42489ed53b 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -57,6 +57,8 @@ type SettingService struct { antigravityUAVersionSF singleflight.Group openAICodexUACache atomic.Value // *cachedOpenAICodexUserAgent openAICodexUASF singleflight.Group + openAICodexVersionCache atomic.Value // *cachedOpenAICodexClientVersion + openAICodexVersionSF singleflight.Group codexRestrictionPolicyCache atomic.Value // *cachedCodexRestrictionPolicy codexRestrictionPolicySF singleflight.Group diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index e242c46d2c..add1328baf 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -438,6 +438,10 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyEnableClientDatelineNormalization] = strconv.FormatBool(settings.EnableClientDatelineNormalization) updates[SettingKeyAntigravityUserAgentVersion] = antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion) updates[SettingKeyOpenAICodexUserAgent] = strings.TrimSpace(settings.OpenAICodexUserAgent) + updates[SettingKeyOpenAICodexClientVersion] = NormalizeCodexClientVersion(settings.OpenAICodexClientVersion) + updates[SettingKeyOpenAICodexVersionAutoSyncEnabled] = strconv.FormatBool(settings.OpenAICodexVersionAutoSyncEnabled) + // SettingKeyOpenAICodexClientVersionSynced 由自动同步任务独占写入,此处不得覆盖, + // 否则面板保存会把同步结果清空。 // codex_cli_only 加固 updates[SettingKeyMinCodexVersion] = strings.TrimSpace(settings.MinCodexVersion) updates[SettingKeyMaxCodexVersion] = strings.TrimSpace(settings.MaxCodexVersion) @@ -610,6 +614,9 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { value: codexUA, expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(), }) + // 版本号缓存只做失效,不在此重算:生效值还取决于自动同步写入的 synced 键, + // 这里没有它的最新值,重算会把同步结果覆盖成陈旧值。 + s.InvalidateOpenAICodexClientVersionCache() openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey) openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{ lowUpstreamRatePriorityEnabled: settings.OpenAILowUpstreamRatePriorityEnabled, diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index a8ddb4908a..a9c699938a 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -212,7 +212,10 @@ type SystemSettings struct { EnableClientDatelineNormalization bool // 是否对 Anthropic OAuth/SetupToken 请求体做客户端 dateline 归一化(默认 true) RewriteMessageCacheControl bool // 是否改写 messages[*].content[*].cache_control(默认 false) AntigravityUserAgentVersion string // Antigravity 上游 User-Agent 版本号;空值使用配置/默认值 - OpenAICodexUserAgent string // OpenAI Codex 上游完整 User-Agent;空值使用内置默认 + OpenAICodexUserAgent string // OpenAI Codex 上游完整 User-Agent;空值由 Codex 客户端版本号拼出标准 CLI UA + OpenAICodexClientVersion string // 出站声明的 Codex 客户端版本号(管理员覆写);空值跟随自动同步值 + OpenAICodexClientVersionSynced string // 自动同步到的官方最新稳定版版本号(只读展示) + OpenAICodexVersionAutoSyncEnabled bool // 是否启用 Codex 客户端版本号自动同步(默认 true) MinCodexVersion string // codex_cli_only 最低 Codex 引擎版本;空=不检查 MaxCodexVersion string // codex_cli_only 最高 Codex 引擎版本;空=不检查 CodexCLIOnlyBlacklist string // codex_cli_only 全局黑名单 JSON([]AllowedClientEntry,OR deny) diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 2a07c73ea5..600370e433 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -274,6 +274,18 @@ func ProvideAccountExpiryService(accountRepo AccountRepository) *AccountExpirySe return svc } +// ProvideOpenAICodexVersionSyncService creates and starts OpenAICodexVersionSyncService. +// 出站 Codex 身份的版本号靠它跟随官方发布,无需为了跟版本而发新版本;面板可关闭。 +func ProvideOpenAICodexVersionSyncService( + settingRepo SettingRepository, + settingService *SettingService, + githubClient GitHubReleaseClient, +) *OpenAICodexVersionSyncService { + svc := NewOpenAICodexVersionSyncService(settingRepo, settingService, githubClient, openAICodexVersionSyncInterval) + svc.Start() + return svc +} + // ProvideProxyExpiryService creates and starts ProxyExpiryService. func ProvideProxyExpiryService(proxyRepo ProxyRepository) *ProxyExpiryService { svc := NewProxyExpiryService(proxyRepo, time.Minute) @@ -637,6 +649,11 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit logger.LegacyPrintf("service.setting", "Warning: migrate codex body fingerprint to signals failed: %v", err) } antigravity.SetUserAgentVersionResolver(svc.GetAntigravityUserAgentVersion) + // enforceCodexIdentityHeaders 是所有 Codex 出站路径共用的纯函数收口点,拿不到 ctx, + // 故注入无参解析器;解析器内部自带 60s TTL 缓存,热路径不触库。 + SetCodexCanonicalUserAgentResolver(func() string { + return svc.GetOpenAICodexCanonicalUserAgent(context.Background()) + }) return svc } @@ -756,6 +773,7 @@ var ProviderSet = wire.NewSet( ProvideTokenRefreshService, wire.Bind(new(GrokOAuthReconciler), new(*TokenRefreshService)), ProvideAccountExpiryService, + ProvideOpenAICodexVersionSyncService, ProvideProxyExpiryService, ProvideSubscriptionExpiryService, ProvideTimingWheelService, diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index b018b925b4..bfe3a7371f 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -574,6 +574,9 @@ export interface SystemSettings { enable_client_dateline_normalization: boolean; antigravity_user_agent_version: string; openai_codex_user_agent: string; + openai_codex_client_version: string; + openai_codex_client_version_synced: string; + openai_codex_version_auto_sync_enabled: boolean; // codex_cli_only 加固 min_codex_version: string; max_codex_version: string; @@ -865,6 +868,8 @@ export interface UpdateSettingsRequest { enable_client_dateline_normalization?: boolean; antigravity_user_agent_version?: string; openai_codex_user_agent?: string; + openai_codex_client_version?: string; + openai_codex_version_auto_sync_enabled?: boolean; // codex_cli_only 加固 min_codex_version?: string; max_codex_version?: string; diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 0f3a9e8dd2..ea0298f352 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -423,8 +423,14 @@ export default { antigravityUserAgentVersionPlaceholder: '1.23.2', antigravityUserAgentVersionHint: 'Leave empty to use ANTIGRAVITY_USER_AGENT_VERSION or the built-in default 1.23.2; when set, the admin setting takes precedence.', openaiCodexUserAgent: 'OpenAI Codex UA', - openaiCodexUserAgentPlaceholder: 'codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color', - openaiCodexUserAgentHint: 'Used to bypass Cloudflare browser-UA challenges on the OpenAI upstream. Only applies when the client User-Agent is detected as a browser (Mozilla/...). Leave empty to use the built-in default. Prefer a codex_cli_rs identity: the upstream schedules capacity per originator, and identities in a load-shed bucket get server_is_overloaded, which puts the account into cooldown.', + openaiCodexUserAgentPlaceholder: 'codex_cli_rs/0.146.0 (Ubuntu 22.4.0; x86_64) xterm-256color', + openaiCodexUserAgentHint: 'The full Codex User-Agent used for all outbound requests. Leave empty to build the standard codex_cli_rs identity from the version below (recommended). Under capacity pressure the upstream sheds load by client identity and drops stale or non-official identities first with server_is_overloaded.', + openaiCodexClientVersion: 'Codex client version', + openaiCodexClientVersionPlaceholder: 'Leave empty to follow auto-sync', + openaiCodexClientVersionHint: 'The Codex client version this gateway declares upstream, shared by the User-Agent and the version header. Leave empty to use the auto-synced latest stable release; setting a value pins it and stops following auto-sync.', + openaiCodexVersionAutoSync: 'Auto-sync Codex version', + openaiCodexVersionAutoSyncHint: 'Fetches the latest stable client version from the official repository every 3 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}', codexHardeningTitle: "Codex Settings", codexClientRestrictionTitle: "Codex client restriction", codexHardeningDesc: diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 5137d02436..ed3c48aefd 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -416,8 +416,14 @@ export default { antigravityUserAgentVersionPlaceholder: '1.23.2', antigravityUserAgentVersionHint: '留空时使用 ANTIGRAVITY_USER_AGENT_VERSION 或内置默认值 1.23.2;填写后后台设置优先。', openaiCodexUserAgent: 'OpenAI Codex UA', - openaiCodexUserAgentPlaceholder: 'codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color', - openaiCodexUserAgentHint: '用于规避 OpenAI 上游 Cloudflare 对浏览器 UA 的访问质询。仅在检测到客户端 User-Agent 为浏览器(Mozilla/...)时生效,其他客户端原样透传。留空使用内置默认值。建议填写 codex_cli_rs 形态:上游按 originator 分桶调度容量,落在降载桶的身份会被回 server_is_overloaded 并触发账号冷却。', + openaiCodexUserAgentPlaceholder: 'codex_cli_rs/0.146.0 (Ubuntu 22.4.0; x86_64) xterm-256color', + openaiCodexUserAgentHint: '出站统一使用的完整 Codex User-Agent。留空则按下方版本号自动拼出标准 codex_cli_rs 形态(推荐)。上游在容量紧张时按客户端身份分优先级降载,陈旧或非官方形态的身份会被优先丢弃并回 server_is_overloaded。', + openaiCodexClientVersion: 'Codex 客户端版本号', + openaiCodexClientVersionPlaceholder: '留空则跟随自动同步', + openaiCodexClientVersionHint: '网关对上游声明的 Codex 客户端版本号,User-Agent 与 version 头同源使用。留空表示使用自动同步到的官方最新稳定版;填写后固定为该版本,不再跟随同步。', + openaiCodexVersionAutoSync: '自动同步 Codex 版本号', + openaiCodexVersionAutoSyncHint: '每 3 小时从官方仓库获取最新稳定版客户端版本号,无需为了跟版本而升级本服务。关闭后仅使用上方手填版本或内置版本。', + openaiCodexVersionSyncedValue: '当前同步到:{version}', codexHardeningTitle: 'Codex 设置', codexClientRestrictionTitle: 'Codex 客户端限制', codexHardeningDesc: diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index fb7e81acd3..e5dfb530fc 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -5168,6 +5168,65 @@
+ ++ {{ + t( + "admin.settings.gatewayForwarding.openaiCodexClientVersionHint", + ) + }} +
++ {{ + t( + "admin.settings.gatewayForwarding.openaiCodexVersionAutoSyncHint", + ) + }} +
++ {{ codexSyncedVersionLabel }} +
+