fix(codex): normalize load-shed originators to avoid upstream capacity shedding

上游 /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 救不了这条路径。已加用例钉住该属性。

降载桶集合是上游容量策略快照而非协议常量,上游调整分桶后需同步修订。
This commit is contained in:
shaw committed 2026-08-02 23:00:12 +08:00
1 parent 7e2e9ba050
commit e1b76e2245
15 files changed
+389 -39

No files matched your search

+46
View File
@@ -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+)`)
@@ -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)
}