mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-08 00:54:17 +00:00
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:
1 parent
7e2e9ba050
commit
e1b76e2245
15 files changed
+389
-39
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user