From e1b76e2245cf099485c86a1d3ebca5303daa690a Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 2 Aug 2026 23:00:12 +0800 Subject: [PATCH] fix(codex): normalize load-shed originators to avoid upstream capacity shedding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游 /backend-api/codex 按 Originator 头分桶调度容量:落在降载桶的请求即使返回 HTTP 200,也会立刻推 SSE `event: error`(code=server_is_overloaded)并以 response.failed 收尾。2026-07-29 起 codex-tui 落入降载桶,codex_cli_rs 正常—— 判定因子是 originator 而非 User-Agent(codex_cli_rs 配 curl UA 亦可正常返回)。 网关会把该错误判定为瞬时上游故障并冷却账号,对外表现为 Codex 账号频繁过载不可用: server_is_overloaded → isOpenAITransientProcessingError → shouldCooldownOpenAITransientUpstreamError → 账号冷却 → 客户端 503。 本项目有三处降载身份来源:浏览器 UA 兜底的默认 UA、客户端透传的真实 TUI 身份、 以及指纹缓存注入探针的 UA。 修复收口在 enforceCodexIdentityHeaders——HTTP / 透传 / WS 握手 / compat 桥接 / 探针 / PAT / 模型列表 / alpha-search 八条出站路径共用的唯一纯函数收口点: - 新增 NormalizeCodexClientIdentityToCLI,把降载桶身份改写为 codex_cli_rs, 只替换身份段并裁掉尾部 (name; version) 客户端标识组,保留版本 / OS / 架构 / 终端指纹;改写后 originator 与 UA 首段仍然配套,不破坏 #3901 的配对不变式, 且改写幂等。 - DefaultOpenAICodexUserAgent 从 TUI 身份改为 CLI 身份(浏览器兜底路径上最大的 降载身份来源)。 - 管理端 Codex UA 的 placeholder / hint 原本在把管理员往降载桶引导,一并修正。 新增 gateway.disable_codex_originator_normalization(默认 false,即归一化开启), 供上游调整分桶后回滚。该开关经 NewOpenAIGatewayService 发布为进程级快照,故必须 保持反义命名:正向命名的 Go 零值 false 会让未经 viper 加载而手工构造的 Config 静默关掉全局保护,viper.SetDefault 救不了这条路径。已加用例钉住该属性。 降载桶集合是上游容量策略快照而非协议常量,上游调整分桶后需同步修订。 --- backend/internal/config/config.go | 9 ++ backend/internal/pkg/openai/request.go | 46 ++++++++ .../pkg/openai/request_load_shed_test.go | 106 ++++++++++++++++++ .../internal/service/account_usage_service.go | 4 +- .../internal/service/openai_codex_identity.go | 28 ++++- .../service/openai_codex_identity_test.go | 101 ++++++++++++++--- .../service/openai_compat_model_test.go | 12 +- .../service/openai_gateway_service.go | 5 + .../service/openai_gateway_service_test.go | 14 ++- .../service/openai_oauth_passthrough_test.go | 65 +++++++++-- .../openai_ws_forwarder_success_test.go | 14 ++- .../service/setting_gateway_runtime.go | 6 +- deploy/config.example.yaml | 10 ++ .../src/i18n/locales/en/admin/settings.ts | 4 +- .../src/i18n/locales/zh/admin/settings.ts | 4 +- 15 files changed, 389 insertions(+), 39 deletions(-) create mode 100644 backend/internal/pkg/openai/request_load_shed_test.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e3e0ab0c2e..ce99a302b8 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -907,6 +907,14 @@ 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,网关据此冷却账号,表现为账号频繁过载不可用。 + // + // 取反义命名是为了让零值安全:该开关会发布为进程级快照,未经 viper 加载而手工构造的 + // Config(测试、工具)其零值必须落在「归一化开启」这一侧,否则会静默丢掉这层保护。 + // 仅当上游调整分桶、使归一化反而落入降载桶时才置 true。 + DisableCodexOriginatorNormalization bool `mapstructure:"disable_codex_originator_normalization"` // CodexImageGenerationBridgeEnabled: 是否为 Codex `/v1/responses` 自动注入 image_generation 工具和桥接指令。 // 默认关闭,避免纯文本 Codex 请求被意外改写;显式携带 image_generation 工具的请求仍按分组能力转发。 CodexImageGenerationBridgeEnabled bool `mapstructure:"codex_image_generation_bridge_enabled"` @@ -2217,6 +2225,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_originator_normalization", false) viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false) viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false) viper.SetDefault("gateway.openai_compact_model", "gpt-5.4") diff --git a/backend/internal/pkg/openai/request.go b/backend/internal/pkg/openai/request.go index 7c025c6563..8387b017c1 100644 --- a/backend/internal/pkg/openai/request.go +++ b/backend/internal/pkg/openai/request.go @@ -255,6 +255,52 @@ func canonicalizeCodexOriginator(name string) string { return name } +// CodexCLIOriginator 官方 Codex CLI 默认 originator(codex-rs DEFAULT_ORIGINATOR), +// 也是身份归一化的目标身份。 +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 + } + ua := strings.TrimSpace(userAgent) + slash := strings.IndexByte(ua, '/') + if slash <= 0 { + return CodexCLIOriginator, ua, true + } + 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], " ") + } + } + return CodexCLIOriginator, CodexCLIOriginator + rest, true +} + // codexEngineVersionPattern 提取版本段开头的三段数字 X.Y.Z(忽略 -alpha 等后缀)。 var codexEngineVersionPattern = regexp.MustCompile(`^(\d+\.\d+\.\d+)`) diff --git a/backend/internal/pkg/openai/request_load_shed_test.go b/backend/internal/pkg/openai/request_load_shed_test.go new file mode 100644 index 0000000000..e00e83615c --- /dev/null +++ b/backend/internal/pkg/openai/request_load_shed_test.go @@ -0,0 +1,106 @@ +package openai + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsCodexLoadShedOriginator(t *testing.T) { + require.True(t, IsCodexLoadShedOriginator("codex-tui")) + require.True(t, IsCodexLoadShedOriginator(" CODEX-TUI ")) + require.False(t, IsCodexLoadShedOriginator("codex_cli_rs")) + require.False(t, IsCodexLoadShedOriginator("codex_vscode")) + require.False(t, IsCodexLoadShedOriginator("Codex Desktop")) + require.False(t, IsCodexLoadShedOriginator("")) +} + +func TestNormalizeCodexClientIdentityToCLI(t *testing.T) { + tests := []struct { + name string + originator string + ua string + wantOriginator string + wantUA string + wantChanged bool + }{ + { + name: "tui 完整 UA 改写首段并裁掉客户端标识组", + originator: "codex-tui", + ua: "codex-tui/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.144.1)", + wantOriginator: "codex_cli_rs", + wantUA: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color", + wantChanged: true, + }, + { + name: "无客户端标识组时仅改写首段", + originator: "codex-tui", + ua: "codex-tui/0.144.1 (Mac OS X 14.0; arm64)", + wantOriginator: "codex_cli_rs", + wantUA: "codex_cli_rs/0.144.1 (Mac OS X 14.0; arm64)", + wantChanged: true, + }, + { + name: "OS 括号组不是客户端标识不得被裁剪", + originator: "codex-tui", + ua: "codex-tui/0.144.1 (Ubuntu 22.4.0; x86_64)", + wantOriginator: "codex_cli_rs", + wantUA: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64)", + wantChanged: true, + }, + { + name: "缺少版本段时只替换 originator", + originator: "codex-tui", + ua: "codex-tui", + wantOriginator: "codex_cli_rs", + wantUA: "codex-tui", + wantChanged: true, + }, + { + name: "健康身份原样返回", + originator: "codex_cli_rs", + ua: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color", + wantOriginator: "codex_cli_rs", + wantUA: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color", + wantChanged: false, + }, + { + name: "其他官方身份不受影响", + originator: "codex_vscode", + ua: "codex_vscode/1.0.0 (Ubuntu 22.4.0; x86_64) vscode (codex_vscode; 1.0.0)", + wantOriginator: "codex_vscode", + wantUA: "codex_vscode/1.0.0 (Ubuntu 22.4.0; x86_64) vscode (codex_vscode; 1.0.0)", + wantChanged: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotOriginator, gotUA, changed := NormalizeCodexClientIdentityToCLI(tt.originator, tt.ua) + + require.Equal(t, tt.wantOriginator, gotOriginator) + require.Equal(t, tt.wantUA, gotUA) + require.Equal(t, tt.wantChanged, changed) + }) + } +} + +// 归一化后的身份必须仍然通过上游的 originator ↔ UA 首段配对校验(issue #3901), +// 且再次归一化保持幂等。 +func TestNormalizeCodexClientIdentityToCLIStaysPaired(t *testing.T) { + originator, ua, changed := NormalizeCodexClientIdentityToCLI( + "codex-tui", + "codex-tui/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.144.1)", + ) + require.True(t, changed) + + pairedOriginator, pairedUA, ok := PairCodexClientIdentity(ua) + require.True(t, ok) + require.Equal(t, originator, pairedOriginator) + require.Equal(t, ua, pairedUA) + + againOriginator, againUA, againChanged := NormalizeCodexClientIdentityToCLI(originator, ua) + require.False(t, againChanged) + require.Equal(t, originator, againOriginator) + require.Equal(t, ua, againUA) +} diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 29f2580585..d00f82dcc9 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -767,8 +767,8 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco req.Header.Set("User-Agent", strings.TrimSpace(fp.UserAgent)) } } - // 与真实转发一致:originator 与最终 User-Agent(可能来自指纹缓存,如 codex-tui)首段配套, - // 否则探针被上游 404(issue #3901)。 + // 与真实转发一致:originator 与最终 User-Agent(可能来自指纹缓存)首段配套,否则探针被上游 + // 404(issue #3901);缓存里的降载桶身份同样在此归一化,避免探针被回 server_is_overloaded。 enforceCodexIdentityHeaders(req.Header) setOpenAIChatGPTAccountHeaders(req.Header, account) diff --git a/backend/internal/service/openai_codex_identity.go b/backend/internal/service/openai_codex_identity.go index 369f391852..2b51244c23 100644 --- a/backend/internal/service/openai_codex_identity.go +++ b/backend/internal/service/openai_codex_identity.go @@ -3,6 +3,7 @@ package service import ( "net/http" "strings" + "sync/atomic" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/google/uuid" @@ -12,6 +13,22 @@ 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 { + v := &atomic.Bool{} + v.Store(true) + return v +}() + +// SetCodexOriginatorNormalizationEnabled 发布 Codex 降载身份归一化开关。 +// enforceCodexIdentityHeaders 是所有出站路径共用的纯函数收口点,无法在热路径注入配置, +// 故由持有配置的服务在构造时发布进程级快照。 +func SetCodexOriginatorNormalizationEnabled(enabled bool) { + codexOriginatorNormalization.Store(enabled) +} + // ensureCodexIdentityHeaders 补齐 OAuth(ChatGPT 内部接口)出站请求所需的 Codex 身份头。 // 已有 User-Agent 与 version 保持不变,交给紧随其后的 enforceCodexIdentityHeaders // 做官方身份配对与最低版本校正。 @@ -23,7 +40,7 @@ func ensureCodexIdentityHeaders(h http.Header) { h.Set("user-agent", codexCLIUserAgent) } if strings.TrimSpace(h.Get("originator")) == "" { - h.Set("originator", "codex_cli_rs") + h.Set("originator", openai.CodexCLIOriginator) } if strings.TrimSpace(h.Get("version")) == "" { h.Set("version", codexCLIVersion) @@ -45,6 +62,10 @@ func applyOpenAICodexProbeHeaders(h http.Header) { // 不低于 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 兜底)之后调用。 @@ -54,7 +75,10 @@ func enforceCodexIdentityHeaders(h http.Header) { } originator, pairedUA, ok := openai.PairCodexClientIdentity(h.Get("user-agent")) if !ok { - originator, pairedUA = "codex_cli_rs", codexCLIUserAgent + originator, pairedUA = openai.CodexCLIOriginator, codexCLIUserAgent + } + if codexOriginatorNormalization.Load() { + originator, pairedUA, _ = openai.NormalizeCodexClientIdentityToCLI(originator, pairedUA) } h.Set("user-agent", pairedUA) h.Set("originator", originator) diff --git a/backend/internal/service/openai_codex_identity_test.go b/backend/internal/service/openai_codex_identity_test.go index f2794ff254..da9adea298 100644 --- a/backend/internal/service/openai_codex_identity_test.go +++ b/backend/internal/service/openai_codex_identity_test.go @@ -4,6 +4,7 @@ import ( "net/http" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/require" ) @@ -30,24 +31,39 @@ func TestEnsureCodexIdentityHeaders(t *testing.T) { }) t.Run("保留已有官方UA和合法version并重新配对", func(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)" h := make(http.Header) - h.Set("user-agent", tuiUA) + h.Set("user-agent", vscodeUA) h.Set("version", "9.9.9") h.Set("OpenAI-Beta", "assistants=v2") ensureCodexIdentityHeaders(h) enforceCodexIdentityHeaders(h) - require.Equal(t, "codex-tui", h.Get("originator")) - require.Equal(t, tuiUA, h.Get("user-agent")) + 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")) + }) } 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 @@ -59,18 +75,25 @@ func TestEnforceCodexIdentityHeaders(t *testing.T) { wantVersion string }{ { - name: "错配 originator 按最终 UA 重配", + name: "错配 originator 按最终 UA 重配后归一化", originator: "codex_cli_rs", userAgent: tuiUA, - wantOriginator: "codex-tui", - wantUA: tuiUA, + wantOriginator: "codex_cli_rs", + wantUA: tuiNormalizedUA, }, { - name: "官方配套身份原样保留", + name: "降载身份改写为 CLI 身份", originator: "codex-tui", userAgent: tuiUA, - wantOriginator: "codex-tui", - wantUA: tuiUA, + wantOriginator: "codex_cli_rs", + wantUA: tuiNormalizedUA, + }, + { + 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: "第三方 UA 整体回退默认身份", @@ -86,11 +109,11 @@ func TestEnforceCodexIdentityHeaders(t *testing.T) { wantUA: codexCLIUserAgent, }, { - name: "originator override UA 首段被尾部真实身份重写", + 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-tui", - wantUA: "codex-tui/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: "低于门槛的 version 提升为内置版本", @@ -141,6 +164,58 @@ func TestEnforceCodexIdentityHeaders(t *testing.T) { } } +// 开关是进程级快照,零值 Config(测试 / 工具手工构造,不经 viper)必须落在「归一化开启」 +// 一侧,否则任意一处零值构造都会静默关掉全局保护。 +// +// 不得给本文件的开关类用例加 t.Parallel():它们改写进程级状态。 +func TestCodexOriginatorNormalizationZeroValueConfigKeepsItEnabled(t *testing.T) { + var cfg config.Config + require.False(t, cfg.Gateway.DisableCodexOriginatorNormalization, + "零值必须表示归一化开启;若改为正向命名的 NormalizeCodexOriginator,零值会静默关闭保护") + + SetCodexOriginatorNormalizationEnabled(!cfg.Gateway.DisableCodexOriginatorNormalization) + t.Cleanup(func() { SetCodexOriginatorNormalizationEnabled(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")) +} + +// 关闭归一化后必须完整退回配对语义:降载身份逐字保留,供上游调整分桶后回滚使用。 +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)" + + SetCodexOriginatorNormalizationEnabled(false) + t.Cleanup(func() { SetCodexOriginatorNormalizationEnabled(true) }) + + h := make(http.Header) + h.Set("originator", "codex-tui") + h.Set("user-agent", tuiUA) + + enforceCodexIdentityHeaders(h) + + require.Equal(t, "codex-tui", h.Get("originator")) + require.Equal(t, tuiUA, h.Get("user-agent")) +} + +// 归一化必须是幂等的:重复收口(如透传路径先后经过多次改写)不得反复裁剪 UA。 +func TestEnforceCodexIdentityHeaders_NormalizationIsIdempotent(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") + enforceCodexIdentityHeaders(h) + + require.Equal(t, first, h.Get("user-agent")) + require.Equal(t, "codex_cli_rs", h.Get("originator")) +} + // enforce 本身仍只负责收口:缺少 originator 时必须保持 no-op,由需要恢复身份的 // 调用方先显式调用 ensureCodexIdentityHeaders。 func TestEnforceCodexIdentityHeaders_NoOriginatorIsNoop(t *testing.T) { diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index 68313e2b36..9596d57290 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -1020,6 +1020,7 @@ func TestForwardAsAnthropic_OAuthRestoresCodexIdentityHeaders(t *testing.T) { gin.SetMode(gin.TestMode) 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)" tests := []struct { name string userAgent string @@ -1029,10 +1030,17 @@ func TestForwardAsAnthropic_OAuthRestoresCodexIdentityHeaders(t *testing.T) { }{ { name: "官方UA逐字保留并重新配对", + userAgent: vscodeUA, + originator: "opencode", + wantUserAgent: vscodeUA, + wantOriginator: "codex_vscode", + }, + { + name: "降载身份改写为CLI身份并保留终端指纹", userAgent: tuiUA, originator: "opencode", - wantUserAgent: tuiUA, - wantOriginator: "codex-tui", + wantUserAgent: "codex_cli_rs/9.9.9 (Mac OS X 14.0; arm64) iTerm", + wantOriginator: "codex_cli_rs", }, { name: "第三方UA回退为默认Codex身份", diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 78b8e8f995..8f7911f002 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -471,6 +471,11 @@ func NewOpenAIGatewayService( settingService *SettingService, userPlatformQuotaRepo UserPlatformQuotaRepository, ) *OpenAIGatewayService { + // enforceCodexIdentityHeaders 是 HTTP / 透传 / WS / 探针 等出站路径共用的纯函数收口点, + // 拿不到配置,故在此发布进程级开关快照。配置取反义,零值即「归一化开启」。 + if cfg != nil { + SetCodexOriginatorNormalizationEnabled(!cfg.Gateway.DisableCodexOriginatorNormalization) + } svc := &OpenAIGatewayService{ accountRepo: accountRepo, usageLogRepo: usageLogRepo, diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 616f0f2f6a..0e86389d13 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -2876,10 +2876,18 @@ func TestOpenAIBuildUpstreamRequestOAuthOfficialClientOriginatorCompatibility(t {name: "official ua pairs originator", userAgent: "Codex Desktop/1.2.3", wantOriginator: "Codex Desktop", wantUA: "Codex Desktop/1.2.3"}, { name: "mismatched originator repaired from ua", - userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", + userAgent: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)", originator: "codex_cli_rs", - wantOriginator: "codex-tui", - wantUA: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", + wantOriginator: "codex_vscode", + wantUA: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)", + }, + { + // 降载桶身份改写为 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: "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}, diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index a572e8ef8d..66fe9db337 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -988,9 +988,10 @@ func TestOpenAIGatewayService_OAuthLegacy_CompositeCodexUAUsesCodexOriginator(t _, err := svc.Forward(context.Background(), c, account, inputBody) require.NoError(t, err) require.NotNil(t, upstream.lastReq) - // 浏览器型复合 UA 被替换为默认 Codex UA(codex-tui 形态),originator 随最终 UA 配套(issue #3901)。 + // 浏览器型复合 UA 被替换为默认 Codex UA(CLI 形态,避开上游降载桶), + // originator 随最终 UA 配套(issue #3901)。 require.Equal(t, DefaultOpenAICodexUserAgent, upstream.lastReq.Header.Get("User-Agent")) - require.Equal(t, "codex-tui", upstream.lastReq.Header.Get("originator")) + require.Equal(t, "codex_cli_rs", upstream.lastReq.Header.Get("originator")) require.NotEqual(t, "opencode", upstream.lastReq.Header.Get("originator")) } @@ -1783,13 +1784,16 @@ func TestOpenAIGatewayService_OAuthPassthrough_NonCodexUAFallbackToCodexUA(t *te require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent")) } -// 回归(issue #3901):codex-tui 等官方 UA 在透传模式下必须逐字保留,且 originator -// 由最终 UA 推导配套——历史实现会把 codex-tui UA 强改为 codex_cli_rs,而 originator -// 保留客户端原值,造成 originator/UA 首段错配被上游 404。 -func TestOpenAIGatewayService_OAuthPassthrough_CodexTuiIdentityPreservedAndPaired(t *testing.T) { +// 回归(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) { gin.SetMode(gin.TestMode) - const tuiUA = "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)" + const tuiUA = "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)" rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -1829,7 +1833,52 @@ func TestOpenAIGatewayService_OAuthPassthrough_CodexTuiIdentityPreservedAndPaire require.NoError(t, err) require.NotNil(t, upstream.lastReq) require.Equal(t, tuiUA, upstream.lastReq.Header.Get("User-Agent")) - require.Equal(t, "codex-tui", upstream.lastReq.Header.Get("originator")) + require.Equal(t, "codex_vscode", upstream.lastReq.Header.Get("originator")) +} + +// 透传模式同样要做降载身份归一化:codex-tui 落在上游降载桶,会被回 +// server_is_overloaded 并触发账号冷却,故改写为 CLI 身份,仅替换身份段、 +// 保留版本 / OS / 架构 / 终端指纹,且改写后仍与 originator 配套(issue #3901)。 +func TestOpenAIGatewayService_OAuthPassthrough_CodexTuiIdentityNormalizedToCLI(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)") + c.Request.Header.Set("originator", "codex-tui") + + inputBody := []byte(`{"model":"gpt-5.2","stream":false,"store":true,"input":[{"type":"text","text":"hi"}]}`) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}}, + httpUpstream: upstream, + } + + account := &Account{ + ID: 123, + Name: "acc", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, + Extra: map[string]any{"openai_passthrough": true}, + Status: StatusActive, + Schedulable: true, + RateMultiplier: f64p(1), + } + + _, 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, "codex_cli_rs", upstream.lastReq.Header.Get("originator")) } func TestOpenAIGatewayService_CodexCLIOnly_RejectsNonCodexClient(t *testing.T) { diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index c7cbbc0b2f..41c3acc68a 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -769,10 +769,18 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testi {name: "official ua pairs originator", userAgent: "Codex Desktop/1.2.3", wantOriginator: "Codex Desktop", wantUA: "Codex Desktop/1.2.3"}, { name: "mismatched originator repaired from ua", - userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", + userAgent: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)", originator: "codex_cli_rs", - wantOriginator: "codex-tui", - wantUA: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", + wantOriginator: "codex_vscode", + wantUA: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)", + }, + { + // 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: "official originator without ua falls back to default identity", originator: "codex_vscode", wantOriginator: "codex_cli_rs", wantUA: codexCLIUserAgent}, } diff --git a/backend/internal/service/setting_gateway_runtime.go b/backend/internal/service/setting_gateway_runtime.go index 3ed4f39def..5b1c605c52 100644 --- a/backend/internal/service/setting_gateway_runtime.go +++ b/backend/internal/service/setting_gateway_runtime.go @@ -82,8 +82,10 @@ const antigravityUserAgentVersionCacheTTL = 60 * time.Second const antigravityUserAgentVersionErrorTTL = 5 * time.Second const antigravityUserAgentVersionDBTimeout = 5 * time.Second -// DefaultOpenAICodexUserAgent OpenAI Codex 默认 User-Agent(用于规避 Cloudflare 对浏览器 UA 的质询) -const DefaultOpenAICodexUserAgent = "codex-tui/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.144.1)" +// DefaultOpenAICodexUserAgent OpenAI Codex 默认 User-Agent(用于规避 Cloudflare 对浏览器 UA 的质询)。 +// 取官方 CLI 身份而非 TUI 身份:上游按 originator 分桶调度容量,TUI 身份命中降载桶会被回 +// server_is_overloaded 并触发账号冷却,而该默认值是浏览器 UA 兜底路径上最主要的身份来源。 +const DefaultOpenAICodexUserAgent = codexCLIUserAgent // cachedOpenAICodexUserAgent 缓存 OpenAI Codex UA(进程内缓存,60s TTL) type cachedOpenAICodexUserAgent struct { diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 9b62151db6..4e730b4fe0 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -269,6 +269,16 @@ gateway: # # 注意:开启后会影响所有客户端的行为(不仅限于 VS Code / Codex CLI),请谨慎开启。 force_codex_cli: false + # Stop rewriting load-shed Codex originators to the official CLI identity. + # 关闭「把落在上游降载桶的 Codex originator 改写为官方 CLI 身份(codex_cli_rs)」。 + # + # 上游 /backend-api/codex 按 originator 分桶调度容量:命中降载桶的请求即使 HTTP 200, + # 也会立刻推 server_is_overloaded 错误事件,网关据此判定瞬时上游故障并冷却账号, + # 对外表现为 Codex 账号频繁过载不可用。归一化只替换身份段,保留版本/OS/架构/终端指纹, + # 改写后 originator 与 User-Agent 首段仍然配套。 + # + # 默认 false(即归一化开启);仅当上游调整分桶、使归一化反而落入降载桶时才置 true。 + disable_codex_originator_normalization: false # Enable Codex image-generation bridge injection for /openai/v1/responses. # 是否为 Codex /responses 请求自动注入 image_generation 工具与桥接指令。 # diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 73d771abba..0f3a9e8dd2 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -423,8 +423,8 @@ 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-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)', - 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.', + 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.', codexHardeningTitle: "Codex Settings", codexClientRestrictionTitle: "Codex client restriction", codexHardeningDesc: diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 72f2d7a143..5137d02436 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -416,8 +416,8 @@ export default { antigravityUserAgentVersionPlaceholder: '1.23.2', antigravityUserAgentVersionHint: '留空时使用 ANTIGRAVITY_USER_AGENT_VERSION 或内置默认值 1.23.2;填写后后台设置优先。', openaiCodexUserAgent: 'OpenAI Codex UA', - openaiCodexUserAgentPlaceholder: 'codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)', - openaiCodexUserAgentHint: '用于规避 OpenAI 上游 Cloudflare 对浏览器 UA 的访问质询。仅在检测到客户端 User-Agent 为浏览器(Mozilla/...)时生效,其他客户端原样透传。留空使用内置默认值。', + 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 并触发账号冷却。', codexHardeningTitle: 'Codex 设置', codexClientRestrictionTitle: 'Codex 客户端限制', codexHardeningDesc: