mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
fix(openai): restore Codex identity for OAuth Messages
This commit is contained in:
@@ -11,12 +11,32 @@ import (
|
||||
// 若请求携带 version 且低于该值,上游直接 404(issue #3901,2026-07 实测)。
|
||||
const codexUpstreamMinVersion = "0.144.0"
|
||||
|
||||
// ensureCodexIdentityHeaders 补齐 OAuth(ChatGPT 内部接口)出站请求所需的 Codex 身份头。
|
||||
// 已有 User-Agent 与 version 保持不变,交给紧随其后的 enforceCodexIdentityHeaders
|
||||
// 做官方身份配对与最低版本校正。
|
||||
func ensureCodexIdentityHeaders(h http.Header) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(h.Get("user-agent")) == "" {
|
||||
h.Set("user-agent", codexCLIUserAgent)
|
||||
}
|
||||
if strings.TrimSpace(h.Get("originator")) == "" {
|
||||
h.Set("originator", "codex_cli_rs")
|
||||
}
|
||||
if strings.TrimSpace(h.Get("version")) == "" {
|
||||
h.Set("version", codexCLIVersion)
|
||||
}
|
||||
h.Set("OpenAI-Beta", "responses=experimental")
|
||||
}
|
||||
|
||||
// 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,保持原样。
|
||||
// 仅对携带 originator 的请求生效;需要从缺失身份头恢复的调用方应先调用
|
||||
// ensureCodexIdentityHeaders。
|
||||
// 必须在所有 User-Agent 改写(自定义 UA / ForceCodexCLI / 浏览器 UA 兜底)之后调用。
|
||||
func enforceCodexIdentityHeaders(h http.Header) {
|
||||
if h == nil || h.Get("originator") == "" {
|
||||
|
||||
@@ -7,6 +7,36 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEnsureCodexIdentityHeaders(t *testing.T) {
|
||||
t.Run("补齐缺失身份头", func(t *testing.T) {
|
||||
h := make(http.Header)
|
||||
|
||||
ensureCodexIdentityHeaders(h)
|
||||
enforceCodexIdentityHeaders(h)
|
||||
|
||||
require.Equal(t, "codex_cli_rs", h.Get("originator"))
|
||||
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
|
||||
require.Equal(t, codexCLIVersion, h.Get("version"))
|
||||
require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta"))
|
||||
})
|
||||
|
||||
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)"
|
||||
h := make(http.Header)
|
||||
h.Set("user-agent", tuiUA)
|
||||
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, "9.9.9", h.Get("version"))
|
||||
require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnforceCodexIdentityHeaders(t *testing.T) {
|
||||
const tuiUA = "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)"
|
||||
|
||||
@@ -102,13 +132,14 @@ func TestEnforceCodexIdentityHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// compat messages bridge 故意不带 originator:收口必须保持 no-op,不得注入身份头。
|
||||
// enforce 本身仍只负责收口:缺少 originator 时必须保持 no-op,由需要恢复身份的
|
||||
// 调用方先显式调用 ensureCodexIdentityHeaders。
|
||||
func TestEnforceCodexIdentityHeaders_NoOriginatorIsNoop(t *testing.T) {
|
||||
h := make(http.Header)
|
||||
h.Set("user-agent", "luna/1.0.0")
|
||||
h.Set("user-agent", "third-party-client/1.0.0")
|
||||
|
||||
enforceCodexIdentityHeaders(h)
|
||||
|
||||
require.Empty(t, h.Get("originator"))
|
||||
require.Equal(t, "luna/1.0.0", h.Get("user-agent"))
|
||||
require.Equal(t, "third-party-client/1.0.0", h.Get("user-agent"))
|
||||
}
|
||||
|
||||
@@ -837,8 +837,7 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, firstResult)
|
||||
require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state"))
|
||||
require.Empty(t, upstream.requests[0].Header.Get("OpenAI-Beta"))
|
||||
require.Empty(t, upstream.requests[0].Header.Get("originator"))
|
||||
requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, "codex_cli_rs")
|
||||
|
||||
secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
|
||||
secondRec := httptest.NewRecorder()
|
||||
@@ -852,12 +851,73 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) {
|
||||
require.Equal(t, "turn_state_first", upstream.requests[1].Header.Get("x-codex-turn-state"))
|
||||
require.Equal(t, generateSessionUUID(isolateOpenAISessionID(0, "stable-cache-key")), upstream.requests[1].Header.Get("session_id"))
|
||||
require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
|
||||
require.Empty(t, upstream.requests[1].Header.Get("OpenAI-Beta"))
|
||||
require.Empty(t, upstream.requests[1].Header.Get("originator"))
|
||||
requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, "codex_cli_rs")
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists())
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
|
||||
}
|
||||
|
||||
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)"
|
||||
tests := []struct {
|
||||
name string
|
||||
userAgent string
|
||||
originator string
|
||||
wantUserAgent string
|
||||
wantOriginator string
|
||||
}{
|
||||
{
|
||||
name: "官方UA逐字保留并重新配对",
|
||||
userAgent: tuiUA,
|
||||
originator: "opencode",
|
||||
wantUserAgent: tuiUA,
|
||||
wantOriginator: "codex-tui",
|
||||
},
|
||||
{
|
||||
name: "第三方UA回退为默认Codex身份",
|
||||
userAgent: "third-party-client/1.0.0",
|
||||
originator: "opencode",
|
||||
wantUserAgent: codexCLIUserAgent,
|
||||
wantOriginator: "codex_cli_rs",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Header.Set("User-Agent", tt.userAgent)
|
||||
c.Request.Header.Set("originator", tt.originator)
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_identity", "gpt-5.4")}
|
||||
svc := &OpenAIGatewayService{
|
||||
httpUpstream: upstream,
|
||||
cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
|
||||
}
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Name: "openai-oauth",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "oauth-token",
|
||||
"chatgpt_account_id": "chatgpt-acc",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
requireOpenAIMessagesCodexIdentity(t, upstream.lastReq, tt.wantUserAgent, tt.wantOriginator)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
@@ -896,6 +956,7 @@ func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey
|
||||
firstSessionID := upstream.requests[0].Header.Get("session_id")
|
||||
require.NotEmpty(t, firstSessionID)
|
||||
require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state"))
|
||||
requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, "codex_cli_rs")
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists())
|
||||
|
||||
secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
|
||||
@@ -910,6 +971,7 @@ func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey
|
||||
require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session_id"))
|
||||
require.Equal(t, "turn_state_digest_first", upstream.requests[1].Header.Get("x-codex-turn-state"))
|
||||
require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
|
||||
requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, "codex_cli_rs")
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists())
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
|
||||
}
|
||||
@@ -1064,8 +1126,7 @@ func TestForwardAsAnthropic_OAuthKeepsSystemAsDeveloperInput(t *testing.T) {
|
||||
instructions := gjson.GetBytes(upstream.lastBody, "instructions")
|
||||
require.True(t, instructions.Exists())
|
||||
require.Empty(t, instructions.String())
|
||||
require.Empty(t, upstream.requests[0].Header.Get("OpenAI-Beta"))
|
||||
require.Empty(t, upstream.requests[0].Header.Get("originator"))
|
||||
requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, "codex_cli_rs")
|
||||
}
|
||||
|
||||
func TestForwardAsAnthropic_OAuthAddsClaudeCodeTodoGuardForCompatModel(t *testing.T) {
|
||||
@@ -1202,6 +1263,15 @@ func openAICompatSSECompletedResponse(responseID, model string) *http.Response {
|
||||
}
|
||||
}
|
||||
|
||||
func requireOpenAIMessagesCodexIdentity(t *testing.T, req *http.Request, wantUserAgent, wantOriginator string) {
|
||||
t.Helper()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, wantUserAgent, req.Header.Get("User-Agent"))
|
||||
require.Equal(t, wantOriginator, req.Header.Get("originator"))
|
||||
require.Equal(t, codexCLIVersion, req.Header.Get("version"))
|
||||
require.Equal(t, "responses=experimental", req.Header.Get("OpenAI-Beta"))
|
||||
}
|
||||
|
||||
func openAICompatSSEResponseWithoutUsage(responseID, model string) *http.Response {
|
||||
body := strings.Join([]string{
|
||||
`data: {"type":"response.completed","response":{"id":"` + responseID + `","object":"response","model":"` + model + `","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}]}}`,
|
||||
|
||||
@@ -878,6 +878,8 @@ func TestForwardAsAnthropicForGrokUsesXAIResponses(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
body := []byte(`{"model":"grok","max_tokens":32,"stream":false,"messages":[{"role":"user","content":"hi"}]}`)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
|
||||
c.Request.Header.Set("OpenAI-Beta", "grok-experimental")
|
||||
c.Request.Header.Set("originator", "opencode")
|
||||
|
||||
account := &Account{
|
||||
ID: 54,
|
||||
@@ -908,6 +910,9 @@ func TestForwardAsAnthropicForGrokUsesXAIResponses(t *testing.T) {
|
||||
require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String())
|
||||
require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization"))
|
||||
require.Equal(t, "sub2api-grok/1.0", upstream.lastReq.Header.Get("User-Agent"))
|
||||
require.Equal(t, "grok-experimental", upstream.lastReq.Header.Get("OpenAI-Beta"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("originator"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("version"))
|
||||
require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())
|
||||
require.NotContains(t, string(upstream.lastBody), "chatgpt.com")
|
||||
|
||||
@@ -261,9 +261,8 @@ 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) 兜底保持不变)。
|
||||
// gpt-5/codex 模型),也必须让 buildUpstreamRequest 走 bridge 分支,以保留
|
||||
// 既有 body/session/conversation 行为。身份头在 post-build 阶段统一恢复。
|
||||
setOpenAICompatMessagesBridgeContext(c, true)
|
||||
}
|
||||
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
|
||||
@@ -288,12 +287,16 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
}
|
||||
}
|
||||
if account.Type == AccountTypeOAuth && account.Platform != PlatformGrok {
|
||||
// Anthropic Messages compatibility uses the ChatGPT Codex SSE endpoint.
|
||||
// Match airgate-openai's request shape: the SSE endpoint does not need
|
||||
// the Responses experimental beta header, and forcing originator can make
|
||||
// ChatGPT select a different internal continuation path.
|
||||
upstreamReq.Header.Del("OpenAI-Beta")
|
||||
upstreamReq.Header.Del("originator")
|
||||
// buildUpstreamRequest 保留 Messages bridge 的 body/session 兼容行为,并会先
|
||||
// 清除身份头。真正发送前恢复完整 Codex 身份,避免 ChatGPT Codex 上游因缺失
|
||||
// originator/OpenAI-Beta 返回 404(issue #3901)。
|
||||
ensureCodexIdentityHeaders(upstreamReq.Header)
|
||||
enforceCodexIdentityHeaders(upstreamReq.Header)
|
||||
logger.L().Debug("openai messages: upstream identity restored",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.String("upstream_model", upstreamModel),
|
||||
zap.Bool("compat_identity_restored", true),
|
||||
)
|
||||
}
|
||||
if account.Type == AccountTypeOAuth && promptCacheKey != "" && strings.TrimSpace(c.GetHeader("conversation_id")) == "" {
|
||||
upstreamReq.Header.Del("conversation_id")
|
||||
|
||||
@@ -356,6 +356,8 @@ func TestForwardAsAnthropic_ResponsesSupportedAccountStillUsesResponsesEndpoint(
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Header.Set("User-Agent", "third-party-client/1.0.0")
|
||||
c.Request.Header.Set("originator", "opencode")
|
||||
|
||||
upstreamBody := strings.Join([]string{
|
||||
`data: {"type":"response.completed","response":{"id":"resp_native","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
|
||||
@@ -385,5 +387,9 @@ func TestForwardAsAnthropic_ResponsesSupportedAccountStillUsesResponsesEndpoint(
|
||||
"responses-capable account must stay on /v1/responses, got %s", upstream.lastReq.URL.String())
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists())
|
||||
require.Equal(t, "third-party-client/1.0.0", upstream.lastReq.Header.Get("User-Agent"))
|
||||
require.Equal(t, "opencode", upstream.lastReq.Header.Get("originator"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("version"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta"))
|
||||
require.Equal(t, "ok", gjson.Get(rec.Body.String(), "content.0.text").String())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user