Skip to content

Commit 2eb2481

Browse files
committed
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 兜底及其辅助函数。
1 parent 825ca7b commit 2eb2481

37 files changed

Lines changed: 1268 additions & 434 deletions

‎backend/cmd/server/wire.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ func provideCleanup(
8888
schedulerSnapshot *service.SchedulerSnapshotService,
8989
tokenRefresh *service.TokenRefreshService,
9090
accountExpiry *service.AccountExpiryService,
91+
codexVersionSync *service.OpenAICodexVersionSyncService,
9192
proxyExpiry *service.ProxyExpiryService,
9293
subscriptionExpiry *service.SubscriptionExpiryService,
9394
usageCleanup *service.UsageCleanupService,
@@ -236,6 +237,10 @@ func provideCleanup(
236237
accountExpiry.Stop()
237238
return nil
238239
}},
240+
{"OpenAICodexVersionSyncService", func() error {
241+
codexVersionSync.Stop()
242+
return nil
243+
}},
239244
{"ProxyExpiryService", func() error {
240245
proxyExpiry.Stop()
241246
return nil

‎backend/cmd/server/wire_gen.go‎

Lines changed: 7 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎backend/cmd/server/wire_gen_test.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
4040
nil,
4141
)
4242
accountExpirySvc := service.NewAccountExpiryService(nil, time.Second)
43+
codexVersionSyncSvc := service.NewOpenAICodexVersionSyncService(nil, nil, nil, time.Second)
4344
proxyExpirySvc := service.NewProxyExpiryService(nil, time.Second)
4445
subscriptionExpirySvc := service.NewSubscriptionExpiryService(nil, time.Second)
4546
pricingSvc := service.NewPricingService(cfg, nil)
@@ -65,6 +66,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
6566
schedulerSnapshotSvc,
6667
tokenRefreshSvc,
6768
accountExpirySvc,
69+
codexVersionSyncSvc,
6870
proxyExpirySvc,
6971
subscriptionExpirySvc,
7072
&service.UsageCleanupService{},

‎backend/internal/config/config.go‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -907,13 +907,17 @@ type GatewayConfig struct {
907907
// ForceCodexCLI: 强制将 OpenAI `/v1/responses` 请求按 Codex CLI 处理。
908908
// 用于网关未透传/改写 User-Agent 时的兼容兜底(默认关闭,避免影响其他客户端)。
909909
ForceCodexCLI bool `mapstructure:"force_codex_cli"`
910-
// DisableCodexOriginatorNormalization: 关闭「把落在上游降载桶的 Codex originator 改写为
911-
// 官方 CLI 身份」。上游 /backend-api/codex 按 originator 分桶调度容量,命中降载桶的请求会被回
912-
// server_is_overloaded,网关据此冷却账号,表现为账号频繁过载不可用。
910+
// DisableCodexIdentityEnforcement: 关闭「强制统一 Codex 出站身份」。上游 /backend-api/codex
911+
// 在容量紧张时按客户端身份分优先级降载,被降载的请求会拿到 HTTP 200 + 流内
912+
// server_is_overloaded,该次请求失败。默认强制统一出口:所有 OAuth 出站的
913+
// User-Agent / originator / version 都改写为网关规范身份,确保没有请求带着第三方或陈旧身份
914+
// 出站。置 true 后退回「仅按最终 User-Agent 配对 originator」的收口语义,供上游策略变动时回滚。
913915
//
914916
// 取反义命名是为了让零值安全:该开关会发布为进程级快照,未经 viper 加载而手工构造的
915-
// Config(测试、工具)其零值必须落在「归一化开启」这一侧,否则会静默丢掉这层保护。
916-
// 仅当上游调整分桶、使归一化反而落入降载桶时才置 true。
917+
// Config(测试、工具)其零值必须落在「强制统一开启」这一侧,否则会静默丢掉这层保护。
918+
DisableCodexIdentityEnforcement bool `mapstructure:"disable_codex_identity_enforcement"`
919+
// DisableCodexOriginatorNormalization: 已废弃,等价于 DisableCodexIdentityEnforcement。
920+
// 保留以兼容既有配置文件;加载时会折叠进新键,不要在新代码里直接读取。
917921
DisableCodexOriginatorNormalization bool `mapstructure:"disable_codex_originator_normalization"`
918922
// CodexImageGenerationBridgeEnabled: 是否为 Codex `/v1/responses` 自动注入 image_generation 工具和桥接指令。
919923
// 默认关闭,避免纯文本 Codex 请求被意外改写;显式携带 image_generation 工具的请求仍按分组能力转发。
@@ -1774,6 +1778,13 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
17741778
cfg.Gateway.ForcedCodexInstructionsTemplate = string(content)
17751779
}
17761780

1781+
// 兼容旧键 gateway.disable_codex_originator_normalization:语义已被
1782+
// disable_codex_identity_enforcement 取代(身份改写升级为强制统一出口),
1783+
// 任一为 true 即关闭强制统一。
1784+
if cfg.Gateway.DisableCodexOriginatorNormalization {
1785+
cfg.Gateway.DisableCodexIdentityEnforcement = true
1786+
}
1787+
17771788
// 兼容旧键 gateway.openai_ws.sticky_previous_response_ttl_seconds。
17781789
// 新键未配置(<=0)时回退旧键;新键优先。
17791790
if cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds <= 0 && cfg.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds > 0 {
@@ -2225,6 +2236,7 @@ func setDefaults() {
22252236
viper.SetDefault("gateway.max_account_switches", 10)
22262237
viper.SetDefault("gateway.max_account_switches_gemini", 3)
22272238
viper.SetDefault("gateway.force_codex_cli", false)
2239+
viper.SetDefault("gateway.disable_codex_identity_enforcement", false)
22282240
viper.SetDefault("gateway.disable_codex_originator_normalization", false)
22292241
viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false)
22302242
viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false)

‎backend/internal/handler/admin/setting_handler.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
279279
EnableClientDatelineNormalization: settings.EnableClientDatelineNormalization,
280280
AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion,
281281
OpenAICodexUserAgent: settings.OpenAICodexUserAgent,
282+
OpenAICodexClientVersion: settings.OpenAICodexClientVersion,
283+
OpenAICodexClientVersionSynced: settings.OpenAICodexClientVersionSynced,
284+
OpenAICodexVersionAutoSyncEnabled: settings.OpenAICodexVersionAutoSyncEnabled,
282285
MinCodexVersion: settings.MinCodexVersion,
283286
MaxCodexVersion: settings.MaxCodexVersion,
284287
CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist,

‎backend/internal/handler/admin/setting_handler_audit.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
443443
if before.OpenAICodexUserAgent != after.OpenAICodexUserAgent {
444444
changed = append(changed, "openai_codex_user_agent")
445445
}
446+
if before.OpenAICodexClientVersion != after.OpenAICodexClientVersion {
447+
changed = append(changed, "openai_codex_client_version")
448+
}
449+
if before.OpenAICodexVersionAutoSyncEnabled != after.OpenAICodexVersionAutoSyncEnabled {
450+
changed = append(changed, "openai_codex_version_auto_sync_enabled")
451+
}
446452
if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource {
447453
changed = append(changed, "payment_visible_method_alipay_source")
448454
}

‎backend/internal/handler/admin/setting_handler_update.go‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@ type UpdateSettingsRequest struct {
236236
EnableClientDatelineNormalization *bool `json:"enable_client_dateline_normalization"`
237237
AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"`
238238
OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
239+
OpenAICodexClientVersion *string `json:"openai_codex_client_version"`
240+
OpenAICodexVersionAutoSyncEnabled *bool `json:"openai_codex_version_auto_sync_enabled"`
239241

240242
// codex_cli_only 加固(global-only)
241243
MinCodexVersion string `json:"min_codex_version"`
@@ -1276,6 +1278,15 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
12761278
return
12771279
}
12781280
}
1281+
if req.OpenAICodexClientVersion != nil {
1282+
// 该值会被拼进出站 User-Agent 与 version 头,必须是合法版本号;空串表示跟随自动同步。
1283+
normalized := strings.TrimSpace(*req.OpenAICodexClientVersion)
1284+
if normalized != "" && service.NormalizeCodexClientVersion(normalized) == "" {
1285+
response.Error(c, http.StatusBadRequest, "openai_codex_client_version must be empty or a valid version (e.g. 0.146.0)")
1286+
return
1287+
}
1288+
req.OpenAICodexClientVersion = &normalized
1289+
}
12791290

12801291
// codex_cli_only 加固:最低/最高 Codex 版本(空=禁用,或合法 semver;max>=min)
12811292
if req.MinCodexVersion != "" && !semverPattern.MatchString(req.MinCodexVersion) {
@@ -1555,6 +1566,20 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
15551566
}
15561567
return previousSettings.OpenAICodexUserAgent
15571568
}(),
1569+
OpenAICodexClientVersion: func() string {
1570+
if req.OpenAICodexClientVersion != nil {
1571+
return *req.OpenAICodexClientVersion
1572+
}
1573+
return previousSettings.OpenAICodexClientVersion
1574+
}(),
1575+
// 同步值由自动同步任务独占写入,面板保存时原样带回,避免被清空。
1576+
OpenAICodexClientVersionSynced: previousSettings.OpenAICodexClientVersionSynced,
1577+
OpenAICodexVersionAutoSyncEnabled: func() bool {
1578+
if req.OpenAICodexVersionAutoSyncEnabled != nil {
1579+
return *req.OpenAICodexVersionAutoSyncEnabled
1580+
}
1581+
return previousSettings.OpenAICodexVersionAutoSyncEnabled
1582+
}(),
15581583
MinCodexVersion: strings.TrimSpace(req.MinCodexVersion),
15591584
MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion),
15601585
CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist),
@@ -2027,6 +2052,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
20272052
EnableClientDatelineNormalization: updatedSettings.EnableClientDatelineNormalization,
20282053
AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion,
20292054
OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent,
2055+
OpenAICodexClientVersion: updatedSettings.OpenAICodexClientVersion,
2056+
OpenAICodexClientVersionSynced: updatedSettings.OpenAICodexClientVersionSynced,
2057+
OpenAICodexVersionAutoSyncEnabled: updatedSettings.OpenAICodexVersionAutoSyncEnabled,
20302058
MinCodexVersion: updatedSettings.MinCodexVersion,
20312059
MaxCodexVersion: updatedSettings.MaxCodexVersion,
20322060
CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist,

‎backend/internal/handler/dto/settings.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,9 @@ type SystemSettings struct {
199199
EnableClientDatelineNormalization bool `json:"enable_client_dateline_normalization"`
200200
AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"`
201201
OpenAICodexUserAgent string `json:"openai_codex_user_agent"`
202+
OpenAICodexClientVersion string `json:"openai_codex_client_version"`
203+
OpenAICodexClientVersionSynced string `json:"openai_codex_client_version_synced"`
204+
OpenAICodexVersionAutoSyncEnabled bool `json:"openai_codex_version_auto_sync_enabled"`
202205

203206
// codex_cli_only 加固
204207
MinCodexVersion string `json:"min_codex_version"`

‎backend/internal/pkg/openai/request.go‎

Lines changed: 10 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,6 @@ var codexOfficialClientOriginators = map[string]bool{
5757
"codex_sdk_ts": true, // TypeScript SDK
5858
}
5959

60-
// IsBrowserUserAgent 判断 User-Agent 是否来自浏览器(Chrome/Firefox/Safari/Edge/Opera 等)。
61-
// 所有现代浏览器的 UA 均以 "Mozilla/" 作为前缀,CLI 工具(codex/claude/curl/postman/python-requests 等)不会。
62-
// 该判定用于避免 Cloudflare 对浏览器型 UA 在 OpenAI 上游接口上触发 JS 质询。
63-
func IsBrowserUserAgent(userAgent string) bool {
64-
ua := strings.TrimSpace(userAgent)
65-
if ua == "" {
66-
return false
67-
}
68-
return strings.HasPrefix(strings.ToLower(ua), "mozilla/")
69-
}
70-
7160
// IsCodexCLIRequest checks if the User-Agent indicates a Codex CLI request
7261
func IsCodexCLIRequest(userAgent string) bool {
7362
ua := normalizeCodexClientHeader(userAgent)
@@ -259,46 +248,21 @@ func canonicalizeCodexOriginator(name string) string {
259248
// 也是身份归一化的目标身份。
260249
const CodexCLIOriginator = "codex_cli_rs"
261250

262-
// codexLoadShedOriginators:上游 /backend-api/codex 按 originator 分桶调度容量,命中降载桶的
263-
// 请求即使 HTTP 200 也会立刻推 SSE `event: error`(code=server_is_overloaded)并以
264-
// response.failed 收尾。2026-07-29 起 codex-tui 被观测到落入降载桶:同账号、同请求体、同 UA,
265-
// 仅把 originator 换成 codex_cli_rs 即恢复正常(换言之 UA 不是判定因子,originator 才是)。
266-
// 网关会把该错误判定为瞬时上游故障并让账号进入冷却,对外表现为「账号过载不可用」,
267-
// 因此出站前需要把命中的身份改写为 CLI 身份。
268-
//
269-
// 该集合是上游容量策略的快照而非协议常量,上游调整分桶后需同步修订。
270-
var codexLoadShedOriginators = map[string]bool{
271-
"codex-tui": true,
272-
}
273-
274-
// IsCodexLoadShedOriginator 判断 originator 是否落在上游降载桶。
275-
func IsCodexLoadShedOriginator(originator string) bool {
276-
return codexLoadShedOriginators[normalizeCodexClientHeader(originator)]
277-
}
278-
279-
// NormalizeCodexClientIdentityToCLI 把落在降载桶的官方身份改写为 Codex CLI 身份:
280-
// UA 首段替换为 codex_cli_rs,并裁掉尾部 `(name; version)` 客户端标识组(真实 CLI UA 无该组),
281-
// 版本 / OS / 架构 / 终端指纹原样保留。返回配套的 originator 与 UA,未命中降载桶时 changed=false。
282-
//
283-
// 入参应为 PairCodexClientIdentity 输出的已配对身份;改写后 UA 首段与 originator 仍然配套,
284-
// 不破坏上游的配对校验。
285-
func NormalizeCodexClientIdentityToCLI(originator, userAgent string) (string, string, bool) {
286-
if !IsCodexLoadShedOriginator(originator) {
287-
return originator, userAgent, false
288-
}
251+
// CodexUserAgentVersion 提取 Codex UA 的完整版本段,即 `{client}/{version} (...` 中的 version。
252+
// 与 ParseCodexEngineVersion 的区别:后者只取三段数字用于引擎版本比较(会丢掉 -alpha.4
253+
// 之类的预发布后缀),本函数保留原样,因为出站 version 头必须与 UA 版本段逐字一致。
254+
// 取不到(非 Codex 形态 UA)时返回空串。
255+
func CodexUserAgentVersion(userAgent string) string {
289256
ua := strings.TrimSpace(userAgent)
290257
slash := strings.IndexByte(ua, '/')
291258
if slash <= 0 {
292-
return CodexCLIOriginator, ua, true
259+
return ""
293260
}
294-
rest := ua[slash:]
295-
// 仅当尾部括号组确为官方客户端标识时才裁剪,避免误截合法 UA 尾巴(如 `(Ubuntu 22.4.0; x86_64)`)。
296-
if trailer := codexUATrailerName(ua); trailer != "" && IsCodexOfficialClientOriginator(trailer) {
297-
if open := strings.LastIndex(rest, "("); open > 0 {
298-
rest = strings.TrimRight(rest[:open], " ")
299-
}
261+
rest := ua[slash+1:]
262+
if space := strings.IndexByte(rest, ' '); space >= 0 {
263+
rest = rest[:space]
300264
}
301-
return CodexCLIOriginator, CodexCLIOriginator + rest, true
265+
return strings.TrimSpace(rest)
302266
}
303267

304268
// codexEngineVersionPattern 提取版本段开头的三段数字 X.Y.Z(忽略 -alpha 等后缀)。

0 commit comments

Comments
 (0)