From 8ffa2a31ffb35958e7f466b75d11dabb10ff933a Mon Sep 17 00:00:00 2001 From: Chenhao <24435007+tylzh97@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:02:22 -0700 Subject: [PATCH] =?UTF-8?q?fix(codex):=20=E4=BF=9D=E7=95=99=E7=BA=A7?= =?UTF-8?q?=E8=81=94=E4=B8=AD=E7=BB=A7=E7=9A=84=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E6=8C=87=E7=BA=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/service/account_test_service.go | 8 ++-- ...ccount_test_service_openai_compact_test.go | 1 + .../account_test_service_openai_test.go | 31 ++++++++++++ .../service/openai_apikey_responses_probe.go | 1 + .../openai_apikey_responses_probe_test.go | 42 ++++++++++++++++ .../internal/service/openai_codex_identity.go | 10 ++++ .../service/openai_codex_identity_test.go | 9 ++++ .../service/openai_gateway_service.go | 48 ++++++++++--------- .../service/openai_gateway_service_test.go | 26 ++++++++++ .../service/openai_oauth_passthrough_test.go | 4 ++ .../service/openai_ws_forwarder_payload.go | 5 ++ .../openai_ws_forwarder_success_test.go | 30 ++++++++++++ 12 files changed, 189 insertions(+), 26 deletions(-) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 9cf120b13d..29f6114500 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -610,6 +610,9 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account // Set common headers req.Header.Set("Content-Type", "application/json") + if !isOAuth { + applyOpenAICodexProbeHeaders(req.Header) + } if credentialAccount.IsOpenAIAgentIdentity() { authHeaders, authErr := buildAgentIdentityAuthenticationHeaders(ctx, s.accountRepo, s.agentIdentityWS, &s.agentIdentityTaskMu, credentialAccount) if authErr != nil { @@ -937,10 +940,7 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account } else { req.Header.Set("Authorization", "Bearer "+authToken) } - req.Header.Set("OpenAI-Beta", "responses=experimental") - req.Header.Set("Originator", "codex_cli_rs") - req.Header.Set("User-Agent", codexCLIUserAgent) - req.Header.Set("Version", codexCLIVersion) + applyOpenAICodexProbeHeaders(req.Header) probeSessionID := compactProbeSessionID(account.ID) req.Header.Set("Session_ID", probeSessionID) req.Header.Set("Conversation_ID", probeSessionID) diff --git a/backend/internal/service/account_test_service_openai_compact_test.go b/backend/internal/service/account_test_service_openai_compact_test.go index 7e2ec88ce0..5b973285a8 100644 --- a/backend/internal/service/account_test_service_openai_compact_test.go +++ b/backend/internal/service/account_test_service_openai_compact_test.go @@ -155,6 +155,7 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyUsesCompact require.NoError(t, err) require.Equal(t, "https://example.com/v1/responses/compact", upstream.lastReq.URL.String()) + requireOpenAICodexProbeHeaders(t, upstream.lastReq.Header) require.Equal(t, "gpt-5.4-openai-compact", gjson.GetBytes(upstream.lastBody, "model").String()) updates := <-updateCalls require.Equal(t, true, updates["openai_compact_supported"]) diff --git a/backend/internal/service/account_test_service_openai_test.go b/backend/internal/service/account_test_service_openai_test.go index 083d882ea7..2cd63ebe3f 100644 --- a/backend/internal/service/account_test_service_openai_test.go +++ b/backend/internal/service/account_test_service_openai_test.go @@ -424,6 +424,37 @@ func TestAccountTestService_OpenAI401SetsPermanentErrorOnly(t *testing.T) { require.Nil(t, account.RateLimitResetAt) } +func TestAccountTestService_OpenAIAPIKeyResponsesUsesCodexProbeHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := newTestContext() + + resp := newJSONResponse(http.StatusOK, "") + resp.Body = io.NopCloser(strings.NewReader("data: {\"type\":\"response.completed\"}\n\n")) + upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}} + svc := &AccountTestService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 95, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://compat-upstream.example/v1", + }, + Extra: map[string]any{openai_compat.ExtraKeyResponsesSupported: true}, + } + + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "") + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + req := upstream.requests[0] + require.Equal(t, "https://compat-upstream.example/v1/responses", req.URL.String()) + requireOpenAICodexProbeHeaders(t, req.Header) +} + func TestAccountTestService_OpenAIAPIKeyResponsesUnsupportedUsesChatCompletionsPath(t *testing.T) { gin.SetMode(gin.TestMode) ctx, recorder := newTestContext() diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go index 10cf050029..ddff4bd2b2 100644 --- a/backend/internal/service/openai_apikey_responses_probe.go +++ b/backend/internal/service/openai_apikey_responses_probe.go @@ -148,6 +148,7 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Accept", "application/json") + applyOpenAICodexProbeHeaders(req.Header) // 账号级请求头覆写:能力探测与真实转发保持一致的最终头 account.ApplyHeaderOverrides(req.Header) diff --git a/backend/internal/service/openai_apikey_responses_probe_test.go b/backend/internal/service/openai_apikey_responses_probe_test.go index 9eed851868..fac351a0ec 100644 --- a/backend/internal/service/openai_apikey_responses_probe_test.go +++ b/backend/internal/service/openai_apikey_responses_probe_test.go @@ -1,12 +1,54 @@ package service import ( + "context" + "io" + "net/http" + "strings" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" "github.com/stretchr/testify/require" ) +func TestProbeOpenAIAPIKeyResponsesSupportUsesCodexProbeHeaders(t *testing.T) { + updateCalls := make(chan map[string]any, 1) + account := Account{ + ID: 96, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://compat-upstream.example/v1", + }, + } + repo := &snapshotUpdateAccountRepo{ + stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}, + updateExtraCalls: updateCalls, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"output":[{"type":"function_call","name":"probe_ping"}]}`)), + }} + svc := &AccountTestService{ + accountRepo: repo, + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + + svc.ProbeOpenAIAPIKeyResponsesSupport(context.Background(), account.ID) + + require.NotNil(t, upstream.lastReq) + require.Equal(t, "https://compat-upstream.example/v1/responses", upstream.lastReq.URL.String()) + requireOpenAICodexProbeHeaders(t, upstream.lastReq.Header) + updates := <-updateCalls + require.Equal(t, true, updates[openai_compat.ExtraKeyResponsesSupported]) +} + func TestDecideResponsesProbeSupport(t *testing.T) { fnCall := []byte(`{"output":[{"type":"reasoning"},{"type":"function_call","name":"probe_ping"}]}`) reasoningOnly := []byte(`{"output":[{"type":"reasoning"}]}`) diff --git a/backend/internal/service/openai_codex_identity.go b/backend/internal/service/openai_codex_identity.go index 47bc19ce0c..369f391852 100644 --- a/backend/internal/service/openai_codex_identity.go +++ b/backend/internal/service/openai_codex_identity.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/google/uuid" ) // codexUpstreamMinVersion 上游 /backend-api/codex 接受的最低 version 头: @@ -30,6 +31,15 @@ func ensureCodexIdentityHeaders(h http.Header) { h.Set("OpenAI-Beta", "responses=experimental") } +// applyOpenAICodexProbeHeaders 为合成探测请求补齐 Codex 身份和引擎指纹。 +func applyOpenAICodexProbeHeaders(h http.Header) { + if h == nil { + return + } + ensureCodexIdentityHeaders(h) + h.Set("X-Codex-Window-ID", uuid.NewString()) +} + // enforceCodexIdentityHeaders 收口 OAuth(ChatGPT 内部接口)出站请求的客户端身份头。 // 上游要求 originator 与 User-Agent 首段配套且为官方客户端标识,version 头(若携带) // 不低于 0.144.0,任一不满足即 404(issue #3901)。以最终 User-Agent 为准推导配套 diff --git a/backend/internal/service/openai_codex_identity_test.go b/backend/internal/service/openai_codex_identity_test.go index ecb6eb7f6f..f2794ff254 100644 --- a/backend/internal/service/openai_codex_identity_test.go +++ b/backend/internal/service/openai_codex_identity_test.go @@ -7,6 +7,15 @@ import ( "github.com/stretchr/testify/require" ) +func requireOpenAICodexProbeHeaders(t *testing.T, h http.Header) { + t.Helper() + require.Equal(t, codexCLIUserAgent, h.Get("User-Agent")) + require.Equal(t, "codex_cli_rs", h.Get("Originator")) + require.Equal(t, codexCLIVersion, h.Get("Version")) + require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta")) + require.NotEmpty(t, h.Get("X-Codex-Window-ID")) +} + func TestEnsureCodexIdentityHeaders(t *testing.T) { t.Run("补齐缺失身份头", func(t *testing.T) { h := make(http.Header) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index d741f1ac01..b0040800fa 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -61,33 +61,37 @@ const ( // OpenAI allowed headers whitelist (for non-passthrough). var openaiAllowedHeaders = map[string]bool{ - "accept-language": true, - "content-type": true, - "conversation_id": true, - "user-agent": true, - "originator": true, - "session_id": true, - "x-codex-beta-features": true, - "x-codex-turn-state": true, - "x-codex-turn-metadata": true, - responsesLiteHeaderKey: true, + "accept-language": true, + "content-type": true, + "conversation_id": true, + "user-agent": true, + "originator": true, + "session_id": true, + "x-codex-beta-features": true, + "x-codex-installation-id": true, + "x-codex-turn-state": true, + "x-codex-turn-metadata": true, + "x-codex-window-id": true, + responsesLiteHeaderKey: true, } // OpenAI passthrough allowed headers whitelist. // 透传模式下仅放行这些低风险请求头,避免将非标准/环境噪声头传给上游触发风控。 var openaiPassthroughAllowedHeaders = map[string]bool{ - "accept": true, - "accept-language": true, - "content-type": true, - "conversation_id": true, - "openai-beta": true, - "user-agent": true, - "originator": true, - "session_id": true, - "x-codex-beta-features": true, - "x-codex-turn-state": true, - "x-codex-turn-metadata": true, - responsesLiteHeaderKey: true, + "accept": true, + "accept-language": true, + "content-type": true, + "conversation_id": true, + "openai-beta": true, + "user-agent": true, + "originator": true, + "session_id": true, + "x-codex-beta-features": true, + "x-codex-installation-id": true, + "x-codex-turn-state": true, + "x-codex-turn-metadata": true, + "x-codex-window-id": true, + responsesLiteHeaderKey: true, } // codex_cli_only 拒绝时记录的请求头白名单(仅用于诊断日志,不参与上游透传) diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 49ef5b5f7e..d0bebb2ef6 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -2504,6 +2504,32 @@ func TestOpenAIBuildUpstreamRequestPreservesCompactPathForAPIKeyBaseURL(t *testi require.Equal(t, "https://example.com/v1/responses/compact", req.URL.String()) } +func TestOpenAIBuildUpstreamRequestPreservesCodexIdentityHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set("X-Codex-Window-ID", "window-http") + c.Request.Header.Set("X-Codex-Installation-ID", "installation-http") + c.Request.Header.Set("X-Test", "blocked") + + body := []byte(`{"model":"gpt-5","input":"hello"}`) + svc := &OpenAIGatewayService{cfg: &config.Config{ + Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{Enabled: false}, + }, + }} + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + + req, err := svc.buildUpstreamRequest(c.Request.Context(), c, account, body, "token", false, "", true) + require.NoError(t, err) + require.Equal(t, "window-http", req.Header.Get("X-Codex-Window-ID")) + require.Equal(t, "installation-http", req.Header.Get("X-Codex-Installation-ID")) + require.Empty(t, req.Header.Get("X-Test")) + require.True(t, openai.EvaluateEngineFingerprint(req.Header, body, openai.DefaultEngineFingerprintSignals)) +} + func TestOpenAIBuildUpstreamRequestOAuthOfficialClientOriginatorCompatibility(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 5fc5d2996a..d4f6e22203 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -1960,6 +1960,8 @@ func TestOpenAIGatewayService_APIKeyPassthrough_PreservesBodyAndUsesResponsesEnd c.Request.Header.Set("User-Agent", "curl/8.0") c.Request.Header.Set("X-Test", "keep") c.Request.Header.Set("x-codex-beta-features", "remote_compaction_v2") + c.Request.Header.Set("X-Codex-Window-ID", "window-passthrough") + c.Request.Header.Set("X-Codex-Installation-ID", "installation-passthrough") originalBody := []byte(`{"model":"gpt-5.2","stream":false,"service_tier":"flex","max_output_tokens":128,"input":[{"type":"text","text":"hi"}]}`) resp := &http.Response{ @@ -1998,6 +2000,8 @@ func TestOpenAIGatewayService_APIKeyPassthrough_PreservesBodyAndUsesResponsesEnd require.Equal(t, "Bearer sk-api-key", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "curl/8.0", upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, "remote_compaction_v2", upstream.lastReq.Header.Get("x-codex-beta-features")) + require.Equal(t, "window-passthrough", upstream.lastReq.Header.Get("X-Codex-Window-ID")) + require.Equal(t, "installation-passthrough", upstream.lastReq.Header.Get("X-Codex-Installation-ID")) require.Empty(t, upstream.lastReq.Header.Get("X-Test")) } diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index 511b28d0ba..b598813f18 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -91,6 +91,11 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( headers.Add("x-codex-beta-features", value) } } + for _, name := range [...]string{"x-codex-window-id", "x-codex-installation-id"} { + if value := c.Request.Header.Get(name); strings.TrimSpace(value) != "" { + headers.Set(name, value) + } + } } // OAuth 账号:将 apiKeyID 混入 session 标识符,防止跨用户会话碰撞。 if account != nil && account.Type == AccountTypeOAuth { diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index 0d031de189..5872f125bf 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -391,6 +391,36 @@ func requestToJSONString(payload map[string]any) string { return string(b) } +func TestOpenAIGatewayService_BuildOpenAIWSHeadersPreservesCodexIdentity(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set("X-Codex-Window-ID", "window-ws") + c.Request.Header.Set("X-Codex-Installation-ID", "installation-ws") + c.Request.Header.Set("X-Test", "blocked") + + svc := &OpenAIGatewayService{} + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + headers, _, err := svc.buildOpenAIWSHeaders( + context.Background(), + c, + account, + "token", + OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2}, + true, + "", + "", + "", + ) + + require.NoError(t, err) + require.Equal(t, "window-ws", headers.Get("X-Codex-Window-ID")) + require.Equal(t, "installation-ws", headers.Get("X-Codex-Installation-ID")) + require.Empty(t, headers.Get("X-Test")) +} + func TestLogOpenAIWSBindResponseAccountWarn(t *testing.T) { require.NotPanics(t, func() { logOpenAIWSBindResponseAccountWarn(1, 2, "resp_ok", nil)