fix(codex): 强制统一出站身份并让客户端版本号跟随官方发布

上游 /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 兜底及其辅助函数。
This commit is contained in:
zhiyu
2026-08-03 20:14:58 +08:00
parent 825ca7b1fc
commit 2eb24814fe
37 changed files with 1273 additions and 439 deletions
+5
View File
@@ -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
+7 -1
View File
@@ -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
+2
View File
@@ -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{},
+17 -5
View File
@@ -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)
@@ -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,
@@ -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")
}
@@ -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,
+3
View File
@@ -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"`
+10 -46
View File
@@ -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 等后缀)。
@@ -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)
}
@@ -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)
@@ -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"
@@ -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:
@@ -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)
@@ -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"))
@@ -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,
)
}
+137 -27
View File
@@ -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)
@@ -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(""))
}
@@ -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
}
@@ -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()),
)
}
@@ -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")
})
}
}
@@ -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()
}
@@ -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),
}
}
@@ -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,
@@ -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"))
})
}
}
@@ -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) {
@@ -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"))
})
}
}
@@ -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/"},
+11
View File
@@ -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]
@@ -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
@@ -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,
+4 -1
View File
@@ -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)
+18
View File
@@ -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,
+5
View File
@@ -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;
@@ -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:
@@ -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:
+75
View File
@@ -5168,6 +5168,65 @@
</p>
</div>
<!-- Codex 客户端版本号 -->
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t(
"admin.settings.gatewayForwarding.openaiCodexClientVersion",
)
}}
</label>
<input
v-model="form.openai_codex_client_version"
type="text"
class="input w-full font-mono text-sm"
:placeholder="
t(
'admin.settings.gatewayForwarding.openaiCodexClientVersionPlaceholder',
)
"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{
t(
"admin.settings.gatewayForwarding.openaiCodexClientVersionHint",
)
}}
</p>
</div>
<!-- Codex 版本号自动同步 -->
<div class="flex items-center justify-between">
<div>
<label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t(
"admin.settings.gatewayForwarding.openaiCodexVersionAutoSync",
)
}}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{
t(
"admin.settings.gatewayForwarding.openaiCodexVersionAutoSyncHint",
)
}}
</p>
<p
v-if="codexSyncedVersionLabel"
class="mt-0.5 text-xs text-gray-500 dark:text-gray-400"
>
{{ codexSyncedVersionLabel }}
</p>
</div>
<Toggle v-model="form.openai_codex_version_auto_sync_enabled" />
</div>
</div>
</div>
@@ -9038,6 +9097,10 @@ const form = reactive<SettingsForm>({
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: