diff --git a/backend/internal/pkg/openai/request.go b/backend/internal/pkg/openai/request.go index 5ecde7f318..7c025c6563 100644 --- a/backend/internal/pkg/openai/request.go +++ b/backend/internal/pkg/openai/request.go @@ -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+)`) diff --git a/backend/internal/pkg/openai/request_identity_test.go b/backend/internal/pkg/openai/request_identity_test.go new file mode 100644 index 0000000000..191d0f465b --- /dev/null +++ b/backend/internal/pkg/openai/request_identity_test.go @@ -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) + }) + } +} diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 7bf02afd51..b9dae84057 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -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 { diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 4add6310c6..281122d4f0 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -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 := "" diff --git a/backend/internal/service/openai_codex_identity.go b/backend/internal/service/openai_codex_identity.go new file mode 100644 index 0000000000..68d4105d84 --- /dev/null +++ b/backend/internal/service/openai_codex_identity.go @@ -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) + } +} diff --git a/backend/internal/service/openai_codex_identity_test.go b/backend/internal/service/openai_codex_identity_test.go new file mode 100644 index 0000000000..7d2c6d8520 --- /dev/null +++ b/backend/internal/service/openai_codex_identity_test.go @@ -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")) +} diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index ee18302056..53cc06989e 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -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") diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index b1e99f8da9..219b5e4be4 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -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 { diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 12eb248fcf..238d359b9e 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -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") } diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index b33918652f..bc14350394 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -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")) }) } } diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 3fbdbf8534..de8ecf030b 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -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) diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index cd84e6f25b..a4d47218e7 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -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)。 diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index bd26220793..adae109e09 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -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")) }) } }