From 2eb24814fe347536891b399fd7c110b99d9c9b2b Mon Sep 17 00:00:00 2001
From: zhiyu
Date: Mon, 3 Aug 2026 20:14:58 +0800
Subject: [PATCH] =?UTF-8?q?fix(codex):=20=E5=BC=BA=E5=88=B6=E7=BB=9F?=
=?UTF-8?q?=E4=B8=80=E5=87=BA=E7=AB=99=E8=BA=AB=E4=BB=BD=E5=B9=B6=E8=AE=A9?=
=?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=89=88=E6=9C=AC=E5=8F=B7=E8=B7=9F?=
=?UTF-8?q?=E9=9A=8F=E5=AE=98=E6=96=B9=E5=8F=91=E5=B8=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
上游 /backend-api/codex 在容量紧张时按客户端身份分优先级降载,被降载的请求
HTTP 200 后立刻推流内 server_is_overloaded。此前网关对配不出官方身份的客户端
整体回退到硬编码的 codex_cli_rs/0.144.1(落后官方 4 个发布),这些请求稳定
落在被优先丢弃的一侧。
- 强制统一出口:所有 OAuth 出站的 User-Agent / originator / version 一律改写
为网关规范身份,客户端自报身份不参与构造;HTTP / 透传 / WS / alpha-search /
探针全覆盖。compat 桥接故意删除 originator 的路径保持 no-op。
- 版本号收敛为单一来源,运行时优先级为面板覆写 → 自动同步值 → 内置常量;
UA 与 version 头同源派生,不再各自硬编码。
- 新增 3 小时自动同步官方客户端最新稳定版,面板可关闭,无需为跟版本而发版。
- 流内 server_is_overloaded / slow_down 改为先在同账号有界重试再切号,并标记为
请求级瞬时故障,不再据此临时封禁账号。
- 移除被取代的降载身份黑名单、浏览器 UA 兜底及其辅助函数。
---
backend/cmd/server/wire.go | 5 +
backend/cmd/server/wire_gen.go | 8 +-
backend/cmd/server/wire_gen_test.go | 2 +
backend/internal/config/config.go | 22 +-
.../internal/handler/admin/setting_handler.go | 3 +
.../handler/admin/setting_handler_audit.go | 6 +
.../handler/admin/setting_handler_update.go | 28 ++
backend/internal/handler/dto/settings.go | 3 +
backend/internal/pkg/openai/request.go | 56 +---
.../pkg/openai/request_load_shed_test.go | 106 -------
.../internal/service/account_usage_service.go | 4 +-
backend/internal/service/domain_constants.go | 10 +
backend/internal/service/gateway_service.go | 6 +
.../internal/service/openai_alpha_search.go | 6 +-
.../service/openai_alpha_search_test.go | 4 +-
.../service/openai_capacity_shed_test.go | 85 ++++++
.../internal/service/openai_codex_identity.go | 164 ++++++++--
.../service/openai_codex_identity_test.go | 288 +++++++++++-------
.../openai_codex_version_sync_service.go | 164 ++++++++++
.../openai_codex_version_sync_service_test.go | 246 +++++++++++++++
.../service/openai_compat_model_test.go | 35 +--
.../service/openai_gateway_forward.go | 37 +--
.../service/openai_gateway_passthrough.go | 51 +++-
.../service/openai_gateway_service.go | 20 +-
.../service/openai_gateway_service_test.go | 44 ++-
.../service/openai_oauth_passthrough_test.go | 28 +-
.../openai_ws_forwarder_success_test.go | 38 +--
.../service/setting_gateway_runtime.go | 100 +++++-
backend/internal/service/setting_parse.go | 11 +
backend/internal/service/setting_service.go | 2 +
backend/internal/service/setting_update.go | 7 +
backend/internal/service/settings_view.go | 5 +-
backend/internal/service/wire.go | 18 ++
frontend/src/api/admin/settings.ts | 5 +
.../src/i18n/locales/en/admin/settings.ts | 10 +-
.../src/i18n/locales/zh/admin/settings.ts | 10 +-
frontend/src/views/admin/SettingsView.vue | 75 +++++
37 files changed, 1273 insertions(+), 439 deletions(-)
delete mode 100644 backend/internal/pkg/openai/request_load_shed_test.go
create mode 100644 backend/internal/service/openai_capacity_shed_test.go
create mode 100644 backend/internal/service/openai_codex_version_sync_service.go
create mode 100644 backend/internal/service/openai_codex_version_sync_service_test.go
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 }}
+
+
+
+
+
@@ -9038,6 +9097,10 @@ const form = reactive({
enable_client_dateline_normalization: true,
antigravity_user_agent_version: "",
openai_codex_user_agent: "",
+ openai_codex_client_version: "",
+ // 只读展示:自动同步任务写入的官方最新稳定版,不参与提交(提交载荷按字段显式构造)
+ openai_codex_client_version_synced: "",
+ openai_codex_version_auto_sync_enabled: true,
// codex_cli_only 加固
min_codex_version: "",
max_codex_version: "",
@@ -9957,6 +10020,14 @@ function removeCodexWhitelistRow(i: number): void {
codexWhitelistRows.value.splice(i, 1);
}
+const codexSyncedVersionLabel = computed(() => {
+ const synced = form.openai_codex_client_version_synced?.trim();
+ if (!synced) return "";
+ return t("admin.settings.gatewayForwarding.openaiCodexVersionSyncedValue", {
+ version: synced,
+ });
+});
+
async function loadSettings() {
loading.value = true;
loadFailed.value = false;
@@ -10510,6 +10581,10 @@ async function saveSettings() {
form.antigravity_user_agent_version?.trim() || "",
openai_codex_user_agent:
form.openai_codex_user_agent?.trim() || "",
+ openai_codex_client_version:
+ form.openai_codex_client_version?.trim() || "",
+ openai_codex_version_auto_sync_enabled:
+ form.openai_codex_version_auto_sync_enabled,
min_codex_version: form.min_codex_version?.trim() || "",
max_codex_version: form.max_codex_version?.trim() || "",
codex_cli_only_allow_app_server_clients: