mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:08:03 +08:00
Merge pull request #5198 from Wei-Shaw/fix/codex-originator-load-shed
fix(codex): 归一化降载 originator,缓解 Codex 账号频繁过载不可用
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user