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
2026-08-02 23:00:12 +08:00
parent 7e2e9ba050
commit e1b76e2245
15 changed files with 389 additions and 39 deletions
+9
View File
@@ -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")
+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)
}
@@ -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)
@@ -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)
@@ -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) {
@@ -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身份",
@@ -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,
@@ -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},
@@ -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) {
@@ -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},
}
@@ -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 {
+10
View File
@@ -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 工具与桥接指令。
#
@@ -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:
@@ -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: