mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #3984 from Wei-Shaw/fix/issue-3901-codex-identity-pairing
fix(openai): originator 与最终 User-Agent 首段配套收口,修复 codex 上游 404(#3901)
This commit is contained in:
@@ -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"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user