fix(openai): pair originator with final User-Agent for codex upstream

上游 /backend-api/codex 的 404 gating 是配套校验:originator 必须与
User-Agent 首段(首个 '/' 前的 client 名)配套且为官方 codex 身份,
错配(如 originator=codex_cli_rs + UA=codex-tui/...)一律 404;version
头若携带须 >= 0.144.0。

修复:
- pkg/openai 新增 PairCodexClientIdentity:由最终出站 UA 推导配套
  originator(含 CODEX_INTERNAL_ORIGINATOR_OVERRIDE 尾部括号组身份恢复、
  精确集合大小写归一、长度/字符集校验)
- service 新增 enforceCodexIdentityHeaders 终态收口:originator 按最终
  UA 重配,推导不出官方身份整体回退默认 Codex CLI 身份;version 低于
  0.144.0 提升为内置版本;originator 缺失时 no-op(保护 messages bridge)
- 接入非透传/透传/WS 三条转发路径(透传与 WS 删除会把 codex-tui 等官方
  UA 强改为 codex_cli_rs 造成错配的旧兜底)、用量探针(指纹缓存 UA 与硬
  编码 originator 错配)、账号 responses/图像测试(opencode 错配)
- messages 兼容桥构建前显式打 bridge 标记,防止映射到非 gpt-5/codex
  模型时收口误改其刻意最小化的请求形态

Fixes #3901
This commit is contained in:
shaw
2026-07-10 19:11:26 +08:00
parent 5260a42a0b
commit 8a51119e39
13 changed files with 406 additions and 18 deletions
+61 -1
View File
@@ -127,7 +127,9 @@ func isCodexOfficialClientRequest(userAgent string, strict bool) bool {
// `(name; version)` 括号组——该组由 codex-rs engine 写入,保留真实 clientInfo.name。
// 故从尾部提取 name 可以恢复被 override 的真实客户端标识(例如 cccc → codex-tui)。
//
// input 应为已归一化(小写 + 去首尾空格)的 UA。
// input 应为去首尾空格的 UA;本函数本身大小写无关,大小写由调用方按需处理
// (isCodexOfficialClientRequest 传入已小写化的 UA 做匹配;PairCodexClientIdentity
// 传入原始大小写以保留 originator 的真实大小写)。
// 若无法解析则返回空字符串。
func codexUATrailerName(ua string) string {
last := strings.LastIndex(ua, "(")
@@ -195,6 +197,64 @@ func matchCodexClientHeaderStrictPrefixes(value string, prefixes []string) bool
return false
}
// PairCodexClientIdentity 由最终出站 User-Agent 推导与其配套的 originator,必要时归一化
// UA 首段,保证两者一致。上游 /backend-api/codex 会校验 originator 与 UA 首段(首个 '/'
// 之前的 client 名)是否配套,错配(如 originator=codex_cli_rs + UA=codex-tui/...)一律
// 404(issue #3901,2026-07 实测)。
//
// 推导优先级:
// 1. UA 首段是官方 originator(精确集合或 `Codex ` 家族前缀)→ 直接配对,UA 原样保留;
// 2. UA 尾部括号组 `(name; version)` 的 name 是官方 originator——CODEX_INTERNAL_ORIGINATOR_OVERRIDE
// 只改 UA 前缀不改尾部(如 cccc/0.142.0 ... (codex-tui; 0.142.0))→ 用尾部 name 重写
// UA 首段后配对,保留真实版本/OS/终端指纹;
// 3. 均不命中 → ok=false,调用方应整体回退为默认官方身份。
func PairCodexClientIdentity(userAgent string) (originator string, pairedUA string, ok bool) {
ua := strings.TrimSpace(userAgent)
slash := strings.IndexByte(ua, '/')
if slash <= 0 {
return "", "", false
}
if leading := strings.TrimSpace(ua[:slash]); isSaneCodexOriginator(leading) && IsCodexOfficialClientOriginator(leading) {
leading = canonicalizeCodexOriginator(leading)
return leading, leading + ua[slash:], true
}
// 传原始大小写 UA 提取 trailer,保留 `Codex ` 家族身份的真实大小写;含 '/' 的
// trailer 会破坏重写后 UA 首段与 originator 的一致性,直接拒绝。
if trailer := codexUATrailerName(ua); trailer != "" && !strings.ContainsRune(trailer, '/') &&
isSaneCodexOriginator(trailer) && IsCodexOfficialClientOriginator(trailer) {
trailer = canonicalizeCodexOriginator(trailer)
return trailer, trailer + ua[slash:], true
}
return "", "", false
}
// codexOriginatorMaxLen 官方 clientInfo.name 均为短 ASCII 标识,远低于此上限。
const codexOriginatorMaxLen = 64
// isSaneCodexOriginator 拒绝超长或含不可打印/非 ASCII 字节的候选 originator,
// 避免 `Codex ` 家族宽前缀把客户端可控的任意字节当作官方身份逐字转发给上游。
func isSaneCodexOriginator(name string) bool {
if name == "" || len(name) > codexOriginatorMaxLen {
return false
}
for i := 0; i < len(name); i++ {
if c := name[i]; c < 0x20 || c > 0x7e {
return false
}
}
return true
}
// canonicalizeCodexOriginator 把精确集合的官方 originator 大小写变体归一为规范小写形态
// (如 CODEX_CLI_RS → codex_cli_rs);`Codex ` 家族不在精确集合中,保留原大小写
// (其规范形态本就是混合大小写,上游按大小写敏感 starts_with("Codex ") 判定)。
func canonicalizeCodexOriginator(name string) string {
if lower := normalizeCodexClientHeader(name); codexOfficialClientOriginators[lower] {
return lower
}
return name
}
// codexEngineVersionPattern 提取版本段开头的三段数字 X.Y.Z(忽略 -alpha 等后缀)。
var codexEngineVersionPattern = regexp.MustCompile(`^(\d+\.\d+\.\d+)`)
@@ -0,0 +1,86 @@
package openai
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestPairCodexClientIdentity(t *testing.T) {
tests := []struct {
name string
ua string
wantOriginator string
wantUA string
wantOK bool
}{
{
name: "cli 首段直接配对",
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",
wantOK: true,
},
{
name: "tui 首段直接配对",
ua: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)",
wantOriginator: "codex-tui",
wantUA: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)",
wantOK: true,
},
{
name: "Codex 家族前缀配对保留原大小写",
ua: "Codex Desktop/1.2.3",
wantOriginator: "Codex Desktop",
wantUA: "Codex Desktop/1.2.3",
wantOK: true,
},
{
name: "originator override 用尾部 name 重写首段",
ua: "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)",
wantOK: true,
},
{
name: "override 尾部恢复保留 Codex 家族真实大小写",
ua: "cccc/1.2.3 (Ubuntu 22.4.0; x86_64) term (Codex Desktop; 1.2.3)",
wantOriginator: "Codex Desktop",
wantUA: "Codex Desktop/1.2.3 (Ubuntu 22.4.0; x86_64) term (Codex Desktop; 1.2.3)",
wantOK: true,
},
{name: "含斜杠的尾部 name 拒绝配对(防自不一致身份)", ua: "foo/1.0 (Codex Desktop/2; 1.0)", wantOK: false},
{
name: "精确集合大小写变体归一为规范小写",
ua: "CODEX_CLI_RS/1.0.0",
wantOriginator: "codex_cli_rs",
wantUA: "codex_cli_rs/1.0.0",
wantOK: true,
},
{
name: "首段尾随空格重建为规范 UA",
ua: "codex-tui /1.0.0",
wantOriginator: "codex-tui",
wantUA: "codex-tui/1.0.0",
wantOK: true,
},
{name: "家族前缀夹带不可打印字节拒绝", ua: "Codex \x01evil/1.0.0", wantOK: false},
{name: "家族前缀夹带非 ASCII 字节拒绝", ua: "Codex \xc3\xa9vil/1.0.0", wantOK: false},
{name: "超长首段拒绝", ua: "Codex " + strings.Repeat("a", 80) + "/1.0.0", wantOK: false},
{name: "第三方 UA 不可配对", ua: "luna/1.0.0", wantOK: false},
{name: "伪造前缀不可配对", ua: "codex_cli_rs_evil/1.0.0", wantOK: false},
{name: "浏览器 UA 不可配对", ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", wantOK: false},
{name: "无斜杠不可配对", ua: "curl", wantOK: false},
{name: "空 UA 不可配对", ua: "", wantOK: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
originator, pairedUA, ok := PairCodexClientIdentity(tt.ua)
require.Equal(t, tt.wantOK, ok)
require.Equal(t, tt.wantOriginator, originator)
require.Equal(t, tt.wantUA, pairedUA)
})
}
}
@@ -611,6 +611,8 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
req.Header.Set("User-Agent", codexCLIUserAgent)
}
setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount)
// 与真实转发一致:originator 与最终 User-Agent 首段配套,否则上游 404(issue #3901)。
enforceCodexIdentityHeaders(req.Header)
}
// 账号级请求头覆写:测试请求与真实转发保持一致的最终头
@@ -1712,13 +1714,15 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("OpenAI-Beta", "responses=experimental")
req.Header.Set("originator", "opencode")
req.Header.Set("originator", "codex_cli_rs")
if customUA := strings.TrimSpace(account.GetOpenAIUserAgent()); customUA != "" {
req.Header.Set("User-Agent", customUA)
} else {
req.Header.Set("User-Agent", codexCLIUserAgent)
}
setOpenAIChatGPTAccountHeaders(req.Header, account)
// 与真实转发一致:originator 与最终 User-Agent 首段配套(原 opencode 与 Codex UA 错配会 404,issue #3901)。
enforceCodexIdentityHeaders(req.Header)
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
@@ -712,6 +712,9 @@ 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)。
enforceCodexIdentityHeaders(req.Header)
setOpenAIChatGPTAccountHeaders(req.Header, account)
proxyURL := ""
@@ -0,0 +1,34 @@
package service
import (
"net/http"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
)
// codexUpstreamMinVersion 上游 /backend-api/codex 接受的最低 version 头:
// 若请求携带 version 且低于该值,上游直接 404(issue #3901,2026-07 实测)。
const codexUpstreamMinVersion = "0.144.0"
// enforceCodexIdentityHeaders 收口 OAuth(ChatGPT 内部接口)出站请求的客户端身份头。
// 上游要求 originator 与 User-Agent 首段配套且为官方客户端标识,version 头(若携带)
// 不低于 0.144.0,任一不满足即 404(issue #3901)。以最终 User-Agent 为准推导配套
// originator;推导不出官方身份(第三方 UA / UA 缺失)时整体回退为默认 Codex CLI 身份。
//
// 仅对携带 originator 的请求生效——compat messages bridge 故意不带 originator,保持原样。
// 必须在所有 User-Agent 改写(自定义 UA / ForceCodexCLI / 浏览器 UA 兜底)之后调用。
func enforceCodexIdentityHeaders(h http.Header) {
if h == nil || h.Get("originator") == "" {
return
}
originator, pairedUA, ok := openai.PairCodexClientIdentity(h.Get("user-agent"))
if !ok {
originator, pairedUA = "codex_cli_rs", codexCLIUserAgent
}
h.Set("user-agent", pairedUA)
h.Set("originator", originator)
if v := strings.TrimSpace(h.Get("version")); v != "" && CompareVersions(v, codexUpstreamMinVersion) < 0 {
h.Set("version", codexCLIVersion)
}
}
@@ -0,0 +1,114 @@
package service
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
func TestEnforceCodexIdentityHeaders(t *testing.T) {
const tuiUA = "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)"
tests := []struct {
name string
originator string
userAgent string
version string
wantOriginator string
wantUA string
wantVersion string
}{
{
name: "错配 originator 按最终 UA 重配",
originator: "codex_cli_rs",
userAgent: tuiUA,
wantOriginator: "codex-tui",
wantUA: tuiUA,
},
{
name: "官方配套身份原样保留",
originator: "codex-tui",
userAgent: tuiUA,
wantOriginator: "codex-tui",
wantUA: tuiUA,
},
{
name: "第三方 UA 整体回退默认身份",
originator: "opencode",
userAgent: "luna/1.0.0",
wantOriginator: "codex_cli_rs",
wantUA: codexCLIUserAgent,
},
{
name: "UA 缺失回退默认身份",
originator: "codex_vscode",
wantOriginator: "codex_cli_rs",
wantUA: codexCLIUserAgent,
},
{
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)",
},
{
name: "低于门槛的 version 提升为内置版本",
originator: "codex_cli_rs",
userAgent: "codex_cli_rs/0.125.0",
version: "0.125.0",
wantOriginator: "codex_cli_rs",
wantUA: "codex_cli_rs/0.125.0",
wantVersion: codexCLIVersion,
},
{
name: "达标 version 原样保留",
originator: "codex_cli_rs",
userAgent: "codex_cli_rs/0.145.0",
version: "0.145.0",
wantOriginator: "codex_cli_rs",
wantUA: "codex_cli_rs/0.145.0",
wantVersion: "0.145.0",
},
{
name: "未携带 version 不注入",
originator: "codex_cli_rs",
userAgent: "codex_cli_rs/0.98.0",
wantOriginator: "codex_cli_rs",
wantUA: "codex_cli_rs/0.98.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := make(http.Header)
if tt.originator != "" {
h.Set("originator", tt.originator)
}
if tt.userAgent != "" {
h.Set("user-agent", tt.userAgent)
}
if tt.version != "" {
h.Set("version", tt.version)
}
enforceCodexIdentityHeaders(h)
require.Equal(t, tt.wantOriginator, h.Get("originator"))
require.Equal(t, tt.wantUA, h.Get("user-agent"))
require.Equal(t, tt.wantVersion, h.Get("version"))
})
}
}
// compat messages bridge 故意不带 originator:收口必须保持 no-op,不得注入身份头。
func TestEnforceCodexIdentityHeaders_NoOriginatorIsNoop(t *testing.T) {
h := make(http.Header)
h.Set("user-agent", "luna/1.0.0")
enforceCodexIdentityHeaders(h)
require.Empty(t, h.Get("originator"))
require.Equal(t, "luna/1.0.0", h.Get("user-agent"))
}
@@ -944,6 +944,11 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
// (Chrome/Firefox/Safari/Edge 等),替换为后台配置的 Codex UA,避免 Cloudflare 触发 JS 质询。
s.overrideBrowserUserAgent(ctx, account, req)
// 终态收口:originator 必须与最终 User-Agent 首段配套且为官方身份,否则上游 404(issue #3901)。
if account.Type == AccountTypeOAuth {
enforceCodexIdentityHeaders(req.Header)
}
// Ensure required headers exist
if req.Header.Get("content-type") == "" {
req.Header.Set("content-type", "application/json")
@@ -259,6 +259,13 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
}
// 6. Build upstream request
if account.Type == AccountTypeOAuth && account.Platform != PlatformGrok {
// Messages 兼容桥即使 body 未带 todo-guard/prompt_cache_key 标记(如映射到非
// gpt-5/codex 模型),也必须让 buildUpstreamRequest 走 bridge 分支:不带
// originator、User-Agent 逐字透传,避免身份收口(issue #3901)误改本路径
// 刻意最小化的请求形态(下方的 Del(OpenAI-Beta/originator) 兜底保持不变)。
setOpenAICompatMessagesBridgeContext(c, true)
}
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
var upstreamReq *http.Request
if account.Platform == PlatformGrok {
@@ -17,7 +17,6 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
@@ -397,15 +396,17 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
req.Header.Set("user-agent", codexCLIUserAgent)
}
// OAuth 安全透传:对非 Codex UA 统一兜底,降低被上游风控拦截概率。
if account.Type == AccountTypeOAuth && !openai.IsCodexCLIRequest(req.Header.Get("user-agent")) {
req.Header.Set("user-agent", codexCLIUserAgent)
}
// 浏览器型 UA 兜底:仅 OAuth(ChatGPT 内部接口)账号生效,若最终 user-agent 仍为浏览器
// (Chrome/Firefox/Safari/Edge 等),替换为后台配置的 Codex UA,避免 Cloudflare 触发 JS 质询。
s.overrideBrowserUserAgent(ctx, account, req)
// 终态收口:originator 必须与最终 User-Agent 首段配套且为官方身份,非官方 UA 整体回退为
// 默认 Codex CLI 身份(承接原「非 Codex UA 安全兜底」,并修复其把 codex-tui 等官方 UA 改写为
// codex_cli_rs 造成的 originator 错配 404),详见 issue #3901。
if account.Type == AccountTypeOAuth {
enforceCodexIdentityHeaders(req.Header)
}
if req.Header.Get("content-type") == "" {
req.Header.Set("content-type", "application/json")
}
@@ -2482,15 +2482,25 @@ func TestOpenAIBuildUpstreamRequestPreservesCompactPathForAPIKeyBaseURL(t *testi
func TestOpenAIBuildUpstreamRequestOAuthOfficialClientOriginatorCompatibility(t *testing.T) {
gin.SetMode(gin.TestMode)
// 上游要求 originator 与最终 User-Agent 首段配套(issue #3901):
// originator 一律由最终 UA 推导;推导不出官方身份时整体回退默认 Codex CLI 身份。
tests := []struct {
name string
userAgent string
originator string
wantOriginator string
wantUA string
}{
{name: "desktop originator preserved", originator: "Codex Desktop", wantOriginator: "Codex Desktop"},
{name: "vscode originator preserved", originator: "codex_vscode", wantOriginator: "codex_vscode"},
{name: "official ua fallback to codex_cli_rs", userAgent: "Codex Desktop/1.2.3", wantOriginator: "codex_cli_rs"},
{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)",
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)",
},
{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},
}
for _, tt := range tests {
@@ -2515,6 +2525,7 @@ func TestOpenAIBuildUpstreamRequestOAuthOfficialClientOriginatorCompatibility(t
req, err := svc.buildUpstreamRequest(c.Request.Context(), c, account, []byte(`{"model":"gpt-5"}`), "token", false, "", isCodexCLI)
require.NoError(t, err)
require.Equal(t, tt.wantOriginator, req.Header.Get("originator"))
require.Equal(t, tt.wantUA, req.Header.Get("User-Agent"))
})
}
}
@@ -703,7 +703,9 @@ func TestOpenAIGatewayService_OAuthLegacy_CompositeCodexUAUsesCodexOriginator(t
_, err := svc.Forward(context.Background(), c, account, inputBody)
require.NoError(t, err)
require.NotNil(t, upstream.lastReq)
require.Equal(t, "codex_cli_rs", upstream.lastReq.Header.Get("originator"))
// 浏览器型复合 UA 被替换为默认 Codex UA(codex-tui 形态),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.NotEqual(t, "opencode", upstream.lastReq.Header.Get("originator"))
}
@@ -1111,6 +1113,55 @@ 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) {
gin.SetMode(gin.TestMode)
const tuiUA = "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)"
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
c.Request.Header.Set("User-Agent", tuiUA)
// 客户端携带错配的 originator,也必须按最终 UA 重配。
c.Request.Header.Set("originator", "codex_cli_rs")
inputBody := []byte(`{"model":"gpt-5.2","stream":false,"store":true,"input":[{"type":"text","text":"hi"}]}`)
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")),
}
upstream := &httpUpstreamRecorder{resp: resp}
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, tuiUA, upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "codex-tui", upstream.lastReq.Header.Get("originator"))
}
func TestOpenAIGatewayService_CodexCLIOnly_RejectsNonCodexClient(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -10,7 +10,6 @@ import (
"net/url"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -127,8 +126,11 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders(
if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
headers.Set("user-agent", codexCLIUserAgent)
}
if account != nil && account.Type == AccountTypeOAuth && !openai.IsCodexCLIRequest(headers.Get("user-agent")) {
headers.Set("user-agent", codexCLIUserAgent)
// 终态收口:originator 必须与最终 user-agent 首段配套且为官方身份,非官方 UA 整体回退为
// 默认 Codex CLI 身份(承接原「非 Codex UA 兜底」,并修复其把 codex-tui 等官方 UA 改写为
// codex_cli_rs 造成的 originator 错配 404),详见 issue #3901。
if account != nil && account.Type == AccountTypeOAuth {
enforceCodexIdentityHeaders(headers)
}
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)。
@@ -670,15 +670,24 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T
func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testing.T) {
gin.SetMode(gin.TestMode)
// 上游要求 originator 与最终 user-agent 首段配套(issue #3901):
// originator 一律由最终 UA 推导;推导不出官方身份时整体回退默认 Codex CLI 身份。
tests := []struct {
name string
userAgent string
originator string
wantOriginator string
wantUA string
}{
{name: "desktop originator preserved", originator: "Codex Desktop", wantOriginator: "Codex Desktop"},
{name: "vscode originator preserved", originator: "codex_vscode", wantOriginator: "codex_vscode"},
{name: "official ua fallback to codex_cli_rs", userAgent: "Codex Desktop/1.2.3", wantOriginator: "codex_cli_rs"},
{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)",
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)",
},
{name: "official originator without ua falls back to default identity", originator: "codex_vscode", wantOriginator: "codex_cli_rs", wantUA: codexCLIUserAgent},
}
for _, tt := range tests {
@@ -743,6 +752,7 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testi
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, tt.wantOriginator, captureDialer.lastHeaders.Get("originator"))
require.Equal(t, tt.wantUA, captureDialer.lastHeaders.Get("user-agent"))
})
}
}