From bb6c3b4f6a0543788dac7cafb8025846ae293709 Mon Sep 17 00:00:00 2001 From: spongehah <2635879218@qq.com> Date: Tue, 18 Aug 2026 13:03:20 +0800 Subject: [PATCH 1/2] fix: unify Codex OAuth outbound identity onto the inference resolver Token exchange, PAT whoami, models, probes, and pre-writes now follow the same UA/version chain as Codex inference instead of hardcoded codex-cli/0.91.0 or compile-time constants. --- .../repository/openai_oauth_service.go | 4 +-- .../repository/openai_oauth_service_test.go | 11 ++++++++ .../internal/service/account_test_service.go | 13 +++++++--- .../internal/service/account_usage_service.go | 9 ++++--- .../internal/service/openai_alpha_search.go | 19 +++++++------- .../internal/service/openai_codex_identity.go | 25 ++++++++++++++++++- .../service/openai_codex_identity_test.go | 23 +++++++++++++++++ .../service/openai_codex_models_service.go | 14 +++++++---- .../openai_codex_models_service_test.go | 8 +++--- .../service/openai_codex_pat_service.go | 4 +-- .../service/openai_codex_pat_service_test.go | 2 +- .../service/openai_gateway_forward.go | 6 ++--- .../service/openai_gateway_passthrough.go | 7 +++--- .../service/openai_ws_forwarder_payload.go | 2 +- 14 files changed, 106 insertions(+), 41 deletions(-) diff --git a/backend/internal/repository/openai_oauth_service.go b/backend/internal/repository/openai_oauth_service.go index acb270a3b3..32b1652f65 100644 --- a/backend/internal/repository/openai_oauth_service.go +++ b/backend/internal/repository/openai_oauth_service.go @@ -48,7 +48,7 @@ func (s *openaiOAuthService) ExchangeCode(ctx context.Context, code, codeVerifie resp, err := client.R(). SetContext(ctx). - SetHeader("User-Agent", "codex-cli/0.91.0"). + SetHeader("User-Agent", service.CodexCanonicalUserAgent()). SetFormDataFromValues(formData). SetSuccessResult(&tokenResp). Post(s.tokenURL) @@ -96,7 +96,7 @@ func (s *openaiOAuthService) refreshTokenWithClientID(ctx context.Context, refre resp, err := client.R(). SetContext(ctx). - SetHeader("User-Agent", "codex-cli/0.91.0"). + SetHeader("User-Agent", service.CodexCanonicalUserAgent()). SetFormDataFromValues(formData). SetSuccessResult(&tokenResp). Post(s.tokenURL) diff --git a/backend/internal/repository/openai_oauth_service_test.go b/backend/internal/repository/openai_oauth_service_test.go index b43e2b52fc..b3feda7483 100644 --- a/backend/internal/repository/openai_oauth_service_test.go +++ b/backend/internal/repository/openai_oauth_service_test.go @@ -10,6 +10,7 @@ import ( infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -77,6 +78,11 @@ func (s *OpenAIOAuthServiceSuite) TestExchangeCode_DefaultRedirectURI() { w.WriteHeader(http.StatusBadRequest) return } + if got := r.Header.Get("User-Agent"); got != service.CodexCanonicalUserAgent() { + errCh <- "user-agent mismatch" + w.WriteHeader(http.StatusBadRequest) + return + } w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"access_token":"at","refresh_token":"rt","token_type":"bearer","expires_in":3600}`) @@ -121,6 +127,11 @@ func (s *OpenAIOAuthServiceSuite) TestRefreshToken_FormFields() { w.WriteHeader(http.StatusBadRequest) return } + if got := r.Header.Get("User-Agent"); got != service.CodexCanonicalUserAgent() { + errCh <- "user-agent mismatch" + w.WriteHeader(http.StatusBadRequest) + return + } w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"access_token":"at2","refresh_token":"rt2","token_type":"bearer","expires_in":3600}`) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 496ef57aed..8e21ba149f 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -731,11 +731,12 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account req.Host = "chatgpt.com" req.Header.Set("accept", "text/event-stream") req.Header.Set("OpenAI-Beta", "responses=experimental") - req.Header.Set("Originator", openai.CodexDefaultOriginator) + canonical := resolveCodexOutboundIdentity("") + req.Header.Set("Originator", canonical.originator) if customUA := strings.TrimSpace(credentialAccount.GetOpenAIUserAgent()); customUA != "" { req.Header.Set("User-Agent", customUA) } else { - req.Header.Set("User-Agent", codexCLIUserAgent) + req.Header.Set("User-Agent", canonical.userAgent) } setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) // 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入,否则测试用的身份 @@ -2063,6 +2064,9 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account req.Header.Set("Authorization", "Bearer "+authToken) } applyOpenAICodexProbeHeaders(req.Header) + if isOAuth { + enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent()) + } probeSessionID := compactProbeSessionID(account.ID) req.Header.Set("Session_ID", probeSessionID) req.Header.Set("Conversation_ID", probeSessionID) @@ -2968,11 +2972,12 @@ 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", openai.CodexDefaultOriginator) + canonical := resolveCodexOutboundIdentity("") + req.Header.Set("originator", canonical.originator) if customUA := strings.TrimSpace(credentialAccount.GetOpenAIUserAgent()); customUA != "" { req.Header.Set("User-Agent", customUA) } else { - req.Header.Set("User-Agent", codexCLIUserAgent) + req.Header.Set("User-Agent", canonical.userAgent) } setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) // 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入,否则测试用的身份 diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 084d153ff2..3cdc713377 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -113,7 +113,7 @@ const ( openAIProbeCacheTTL = 10 * time.Minute grokProbeRetryTTL = 1 * time.Minute grokFreeQuotaWindow = 24 * time.Hour - openAICodexProbeVersion = codexCLIVersion // 与网关出站身份同源,避免两处硬编码版本各自漂移 + openAICodexProbeVersion = codexCLIVersion // 编译期兜底;运行时探针 version 走 CodexCanonicalClientVersion ) // UsageCache 封装账户使用量相关的缓存 @@ -860,9 +860,10 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco } req.Header.Set("Accept", "text/event-stream") req.Header.Set("OpenAI-Beta", "responses=experimental") - req.Header.Set("Originator", openaipkg.CodexDefaultOriginator) - req.Header.Set("Version", openAICodexProbeVersion) - req.Header.Set("User-Agent", codexCLIUserAgent) + canonical := resolveCodexOutboundIdentity("") + req.Header.Set("Originator", canonical.originator) + req.Header.Set("Version", canonical.version) + req.Header.Set("User-Agent", canonical.userAgent) if s.identityCache != nil { if fp, fpErr := s.identityCache.GetFingerprint(reqCtx, account.ID); fpErr == nil && fp != nil && strings.TrimSpace(fp.UserAgent) != "" { req.Header.Set("User-Agent", strings.TrimSpace(fp.UserAgent)) diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index 0ddc8f5255..7e7a13f3b5 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -11,7 +11,6 @@ import ( "strings" "time" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" ) @@ -239,25 +238,26 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c if turnMetadata := openAIAlphaSearchInboundHeader(c, "X-Codex-Turn-Metadata"); turnMetadata != "" { req.Header.Set("X-Codex-Turn-Metadata", turnMetadata) } + canonical := resolveCodexOutboundIdentity("") if version := openAIAlphaSearchInboundHeader(c, "Version"); version != "" { req.Header.Set("Version", version) } else { - req.Header.Set("Version", codexCLIVersion) + req.Header.Set("Version", canonical.version) } if originator := openAIAlphaSearchInboundHeader(c, "Originator"); originator != "" { req.Header.Set("Originator", originator) } else { - req.Header.Set("Originator", openai.CodexDefaultOriginator) + req.Header.Set("Originator", canonical.originator) } if customUA := account.GetOpenAIUserAgent(); customUA != "" { req.Header.Set("User-Agent", customUA) } else if userAgent := openAIAlphaSearchInboundHeader(c, "User-Agent"); userAgent != "" { req.Header.Set("User-Agent", userAgent) } else { - req.Header.Set("User-Agent", codexCLIUserAgent) + req.Header.Set("User-Agent", canonical.userAgent) } if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - req.Header.Set("User-Agent", codexCLIUserAgent) + req.Header.Set("User-Agent", canonical.userAgent) } apiKeyID := getAPIKeyIDFromContext(c) if sessionID := strings.TrimSpace(gjson.GetBytes(alphaBody, "id").String()); sessionID != "" { @@ -382,25 +382,26 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context if turnMetadata := openAIAlphaSearchInboundHeader(c, "X-Codex-Turn-Metadata"); turnMetadata != "" { req.Header.Set("X-Codex-Turn-Metadata", turnMetadata) } + canonical := resolveCodexOutboundIdentity("") if version := openAIAlphaSearchInboundHeader(c, "Version"); version != "" { req.Header.Set("Version", version) } else { - req.Header.Set("Version", codexCLIVersion) + req.Header.Set("Version", canonical.version) } if originator := openAIAlphaSearchInboundHeader(c, "Originator"); originator != "" { req.Header.Set("Originator", originator) } else { - req.Header.Set("Originator", openai.CodexDefaultOriginator) + req.Header.Set("Originator", canonical.originator) } if customUA := account.GetOpenAIUserAgent(); customUA != "" { req.Header.Set("User-Agent", customUA) } else if userAgent := openAIAlphaSearchInboundHeader(c, "User-Agent"); userAgent != "" { req.Header.Set("User-Agent", userAgent) } else { - req.Header.Set("User-Agent", codexCLIUserAgent) + req.Header.Set("User-Agent", canonical.userAgent) } if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - req.Header.Set("User-Agent", codexCLIUserAgent) + req.Header.Set("User-Agent", canonical.userAgent) } enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) } diff --git a/backend/internal/service/openai_codex_identity.go b/backend/internal/service/openai_codex_identity.go index 510ee07cf2..7d9edc4e28 100644 --- a/backend/internal/service/openai_codex_identity.go +++ b/backend/internal/service/openai_codex_identity.go @@ -76,6 +76,29 @@ func SetCodexCanonicalUserAgentResolver(resolver func() string) { codexCanonicalUAResolver = resolver } +// CodexCanonicalUserAgent 返回当前生效的规范 Codex User-Agent。 +// 取值走与推理相同的解析链:面板 UA 指纹 + 面板/自动同步版本号 + 编译期兜底。 +// 供无账号句柄的出站路径(OAuth 换 Token / 刷新)使用。 +func CodexCanonicalUserAgent() string { + return resolveCodexOutboundIdentity("").userAgent +} + +// ApplyCodexCanonicalIdentity 为无账号句柄的出站请求写入与推理同源的身份三元组。 +func ApplyCodexCanonicalIdentity(h http.Header) { + if h == nil { + return + } + identity := resolveCodexOutboundIdentity("") + h.Set("user-agent", identity.userAgent) + h.Set("originator", identity.originator) + h.Set("version", identity.version) +} + +// CodexCanonicalClientVersion 返回当前生效的 Codex 客户端版本号。 +func CodexCanonicalClientVersion() string { + return resolveCodexOutboundIdentity("").version +} + // codexCanonicalUserAgent 返回出站规范 User-Agent。 func codexCanonicalUserAgent() string { codexCanonicalUAMu.RLock() @@ -210,6 +233,6 @@ func pairCodexIdentityHeaders(h http.Header) { 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) + h.Set("version", resolveCodexOutboundIdentity("").version) } } diff --git a/backend/internal/service/openai_codex_identity_test.go b/backend/internal/service/openai_codex_identity_test.go index 9b295241ce..7820646431 100644 --- a/backend/internal/service/openai_codex_identity_test.go +++ b/backend/internal/service/openai_codex_identity_test.go @@ -322,3 +322,26 @@ func TestBuildCodexCLIUserAgent(t *testing.T) { require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent("bogus version")) require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent("")) } + +func TestCodexCanonicalUserAgentFollowsResolver(t *testing.T) { + SetCodexCanonicalUserAgentResolver(func() string { + return "codex_cli_rs/0.200.1" + codexCLIUserAgentSuffix + }) + t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) }) + + require.Equal(t, "codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix, CodexCanonicalUserAgent()) + require.Equal(t, "0.200.1", CodexCanonicalClientVersion()) + + h := make(http.Header) + ApplyCodexCanonicalIdentity(h) + require.Equal(t, "codex_cli_rs", h.Get("originator")) + require.Equal(t, "codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix, h.Get("user-agent")) + require.Equal(t, "0.200.1", h.Get("version")) +} + +func TestCodexCanonicalUserAgentFallsBackWithoutResolver(t *testing.T) { + SetCodexCanonicalUserAgentResolver(nil) + + require.Equal(t, codexCLIUserAgent, CodexCanonicalUserAgent()) + require.Equal(t, codexCLIVersion, CodexCanonicalClientVersion()) +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index c36c46eb13..8b5bcda915 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -18,7 +18,6 @@ import ( infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "golang.org/x/net/http2" "golang.org/x/sync/singleflight" ) @@ -243,7 +242,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc clientVersion = strings.TrimSpace(clientVersion) if clientVersion == "" { - clientVersion = openAICodexProbeVersion + clientVersion = CodexCanonicalClientVersion() } requestEndpoint := chatgptCodexModelsURL @@ -305,9 +304,14 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc setOpenAIChatGPTAccountHeaders(headers, credAccount) } headers.Set("Accept", "application/json") - headers.Set("Originator", openai.CodexDefaultOriginator) - headers.Set("Version", clientVersion) - headers.Set("User-Agent", codexCLIUserAgent) + overrideUA := "" + if !useAPIKeyUpstream { + overrideUA = credAccount.GetOpenAIUserAgent() + } + identity := resolveCodexOutboundIdentity(overrideUA) + headers.Set("Originator", identity.originator) + headers.Set("User-Agent", identity.userAgent) + headers.Set("Version", identity.version) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 585ad95d5e..c7eb7e29ec 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -348,8 +348,8 @@ func TestFetchCodexModelsManifestDefaultClientVersion(t *testing.T) { if _, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "", ""); err != nil { t.Fatalf("FetchCodexModelsManifest returned error: %v", err) } - if gotClientVersion != openAICodexProbeVersion { - t.Errorf("default client_version: got %q, want %q", gotClientVersion, openAICodexProbeVersion) + if gotClientVersion != CodexCanonicalClientVersion() { + t.Errorf("default client_version: got %q, want %q", gotClientVersion, CodexCanonicalClientVersion()) } } @@ -451,10 +451,10 @@ func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) { if gotRequest.Header.Get("Originator") != openai.CodexDefaultOriginator { t.Errorf("originator header: got %q", gotRequest.Header.Get("Originator")) } - if gotRequest.Header.Get("Version") != "0.144.0" { + if gotRequest.Header.Get("Version") != CodexCanonicalClientVersion() { t.Errorf("version header: got %q", gotRequest.Header.Get("Version")) } - if gotRequest.Header.Get("User-Agent") != codexCLIUserAgent { + if gotRequest.Header.Get("User-Agent") != CodexCanonicalUserAgent() { t.Errorf("user-agent header: got %q", gotRequest.Header.Get("User-Agent")) } if gotRequest.Header.Get("chatgpt-account-id") != "" { diff --git a/backend/internal/service/openai_codex_pat_service.go b/backend/internal/service/openai_codex_pat_service.go index dce6785920..f4c4cc9f11 100644 --- a/backend/internal/service/openai_codex_pat_service.go +++ b/backend/internal/service/openai_codex_pat_service.go @@ -10,7 +10,6 @@ import ( infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai" ) const openAICodexPATWhoamiURLDefault = "https://auth.openai.com/api/accounts/v1/user-auth-credential/whoami" @@ -59,8 +58,7 @@ func (s *OpenAIOAuthService) ValidateCodexPersonalAccessToken(ctx context.Contex } req.Header.Set("authorization", "Bearer "+accessToken) req.Header.Set("accept", "application/json") - req.Header.Set("originator", openai.CodexDefaultOriginator) - req.Header.Set("user-agent", codexCLIUserAgent) + ApplyCodexCanonicalIdentity(req.Header) resp, err := client.Do(req) if err != nil { diff --git a/backend/internal/service/openai_codex_pat_service_test.go b/backend/internal/service/openai_codex_pat_service_test.go index e7656868ba..3210ed3062 100644 --- a/backend/internal/service/openai_codex_pat_service_test.go +++ b/backend/internal/service/openai_codex_pat_service_test.go @@ -40,7 +40,7 @@ func TestOpenAIOAuthService_ValidateCodexPersonalAccessToken(t *testing.T) { require.NoError(t, err) require.Equal(t, "Bearer at-test-token", gotAuthorization) require.Equal(t, openai.CodexDefaultOriginator, gotOriginator) - require.Equal(t, codexCLIUserAgent, gotUserAgent) + require.Equal(t, CodexCanonicalUserAgent(), gotUserAgent) require.Equal(t, OpenAIAuthModePersonalAccessToken, info.AuthMode) require.Equal(t, "user@example.com", info.Email) require.Equal(t, "user-123", info.ChatGPTUserID) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 6dab7fb702..9d8218a114 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -1128,7 +1128,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. if isOpenAIResponsesCompactPath(c) { req.Header.Set("accept", "application/json") if req.Header.Get("version") == "" { - req.Header.Set("version", codexCLIVersion) + req.Header.Set("version", CodexCanonicalClientVersion()) } compactSession := resolveOpenAICompactSessionID(c) req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, compactSession)) @@ -1154,10 +1154,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. req.Header.Set("user-agent", customUA) } - // 若开启 ForceCodexCLI,则强制将上游 User-Agent 伪装为 Codex CLI。 + // 若开启 ForceCodexCLI,则强制将上游 User-Agent 伪装为规范 Codex 身份。 // 用于网关未透传/改写 User-Agent 时,仍能命中 Codex 侧识别逻辑。 if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - req.Header.Set("user-agent", codexCLIUserAgent) + req.Header.Set("user-agent", CodexCanonicalUserAgent()) } // 指纹收敛:使用 Forward() 中预计算的收敛 ID 改写出站头,与请求体使用同一份 IDs。 diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 4bfd2fa26c..7440a40351 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" @@ -445,7 +444,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if isOpenAIResponsesCompactPath(c) { req.Header.Set("accept", "application/json") if req.Header.Get("version") == "" { - req.Header.Set("version", codexCLIVersion) + req.Header.Set("version", CodexCanonicalClientVersion()) } if clientSessionID == "" { clientSessionID = resolveOpenAICompactSessionID(c) @@ -454,7 +453,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( req.Header.Set("accept", "text/event-stream") } if req.Header.Get("originator") == "" { - req.Header.Set("originator", openai.CodexDefaultOriginator) + req.Header.Set("originator", resolveCodexOutboundIdentity("").originator) } // 用隔离后的 session 标识符覆盖客户端透传值,防止跨用户会话碰撞。 if clientSessionID == "" { @@ -482,7 +481,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( req.Header.Set("user-agent", customUA) } if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - req.Header.Set("user-agent", codexCLIUserAgent) + req.Header.Set("user-agent", CodexCanonicalUserAgent()) } // 指纹收敛:使用 forwardOpenAIPassthrough 中预计算的收敛 ID 改写出站头, // 与请求体 client_metadata 共享同一份 IDs(与非透传路径相同的相对位置: diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index 797361a3a6..7eabe7d31f 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -161,7 +161,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( } } if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - headers.Set("user-agent", codexCLIUserAgent) + headers.Set("user-agent", CodexCanonicalUserAgent()) } // 终态收口:WS 握手与 HTTP 出站共用同一套身份语义,账号级自定义 UA 同样作为 // 管理员显式配置传入(上面写进 headers 的值只在强制统一被关闭时才参与配对)。 From a341239596caa92d3a546c84126283787de5670e Mon Sep 17 00:00:00 2001 From: yaxin Date: Tue, 18 Aug 2026 15:20:08 +0800 Subject: [PATCH 2/2] fix(fingerprint): align credential-face identity with the real client and de-drift models version - Replace ApplyCodexCanonicalIdentity with CodexCanonicalAuthIdentity / ApplyCodexCanonicalAuthIdentity: the credential face (auth.openai.com token exchange / refresh / PAT whoami) now sends the originator + canonical User-Agent pair and no version header, matching codex-rs default_headers(); the version gate (#3901) only exists on the /backend-api/codex inference face. whoami keeps its original header shape (originator + UA) with the canonical UA source. - Token exchange and refresh send the full pair instead of a bare UA, eliminating the half-identity (UA without originator) combination no real client ever emits. - Codex models manifest: the Version header now follows the client's own client_version when it is valid and >= the upstream floor (same source as the query param, restoring the pre-refactor consistency), falling back to the canonical version otherwise; the query param keeps its verbatim passthrough contract. - Drop the now-unreferenced openAICodexProbeVersion constant and its vacuous consistency assertions; probes resolve their version through resolveCodexOutboundIdentity at runtime. --- .../repository/openai_oauth_service.go | 8 +++++-- .../repository/openai_oauth_service_test.go | 16 ++++++++++++-- .../internal/service/account_usage_service.go | 17 +++++++-------- .../service/openai_capacity_shed_test.go | 1 - .../internal/service/openai_codex_identity.go | 21 +++++++++++++------ .../service/openai_codex_identity_test.go | 5 +++-- .../service/openai_codex_models_service.go | 10 ++++++++- .../openai_codex_models_service_test.go | 4 ++-- .../service/openai_codex_pat_service.go | 2 +- .../openai_codex_version_consistency_test.go | 3 --- 10 files changed, 58 insertions(+), 29 deletions(-) diff --git a/backend/internal/repository/openai_oauth_service.go b/backend/internal/repository/openai_oauth_service.go index 32b1652f65..e7a34ad6a0 100644 --- a/backend/internal/repository/openai_oauth_service.go +++ b/backend/internal/repository/openai_oauth_service.go @@ -46,9 +46,11 @@ func (s *openaiOAuthService) ExchangeCode(ctx context.Context, code, codeVerifie var tokenResp openai.TokenResponse + authUA, authOriginator := service.CodexCanonicalAuthIdentity() resp, err := client.R(). SetContext(ctx). - SetHeader("User-Agent", service.CodexCanonicalUserAgent()). + SetHeader("User-Agent", authUA). + SetHeader("originator", authOriginator). SetFormDataFromValues(formData). SetSuccessResult(&tokenResp). Post(s.tokenURL) @@ -94,9 +96,11 @@ func (s *openaiOAuthService) refreshTokenWithClientID(ctx context.Context, refre var tokenResp openai.TokenResponse + authUA, authOriginator := service.CodexCanonicalAuthIdentity() resp, err := client.R(). SetContext(ctx). - SetHeader("User-Agent", service.CodexCanonicalUserAgent()). + SetHeader("User-Agent", authUA). + SetHeader("originator", authOriginator). SetFormDataFromValues(formData). SetSuccessResult(&tokenResp). Post(s.tokenURL) diff --git a/backend/internal/repository/openai_oauth_service_test.go b/backend/internal/repository/openai_oauth_service_test.go index b3feda7483..d43b208d4e 100644 --- a/backend/internal/repository/openai_oauth_service_test.go +++ b/backend/internal/repository/openai_oauth_service_test.go @@ -78,11 +78,17 @@ func (s *OpenAIOAuthServiceSuite) TestExchangeCode_DefaultRedirectURI() { w.WriteHeader(http.StatusBadRequest) return } - if got := r.Header.Get("User-Agent"); got != service.CodexCanonicalUserAgent() { + wantUA, wantOriginator := service.CodexCanonicalAuthIdentity() + if got := r.Header.Get("User-Agent"); got != wantUA { errCh <- "user-agent mismatch" w.WriteHeader(http.StatusBadRequest) return } + if got := r.Header.Get("originator"); got != wantOriginator { + errCh <- "originator mismatch" + w.WriteHeader(http.StatusBadRequest) + return + } w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"access_token":"at","refresh_token":"rt","token_type":"bearer","expires_in":3600}`) @@ -127,11 +133,17 @@ func (s *OpenAIOAuthServiceSuite) TestRefreshToken_FormFields() { w.WriteHeader(http.StatusBadRequest) return } - if got := r.Header.Get("User-Agent"); got != service.CodexCanonicalUserAgent() { + wantUA, wantOriginator := service.CodexCanonicalAuthIdentity() + if got := r.Header.Get("User-Agent"); got != wantUA { errCh <- "user-agent mismatch" w.WriteHeader(http.StatusBadRequest) return } + if got := r.Header.Get("originator"); got != wantOriginator { + errCh <- "originator mismatch" + w.WriteHeader(http.StatusBadRequest) + return + } w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"access_token":"at2","refresh_token":"rt2","token_type":"bearer","expires_in":3600}`) diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 3cdc713377..9d82d5caa1 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -105,15 +105,14 @@ type antigravityUsageCache struct { } const ( - apiCacheTTL = 3 * time.Minute - apiErrorCacheTTL = 1 * time.Minute // 负缓存 TTL:429 等错误缓存 1 分钟 - antigravityErrorTTL = 1 * time.Minute // Antigravity 错误缓存 TTL(可恢复错误) - apiQueryMaxJitter = 800 * time.Millisecond // 用量查询最大随机延迟 - windowStatsCacheTTL = 1 * time.Minute - openAIProbeCacheTTL = 10 * time.Minute - grokProbeRetryTTL = 1 * time.Minute - grokFreeQuotaWindow = 24 * time.Hour - openAICodexProbeVersion = codexCLIVersion // 编译期兜底;运行时探针 version 走 CodexCanonicalClientVersion + apiCacheTTL = 3 * time.Minute + apiErrorCacheTTL = 1 * time.Minute // 负缓存 TTL:429 等错误缓存 1 分钟 + antigravityErrorTTL = 1 * time.Minute // Antigravity 错误缓存 TTL(可恢复错误) + apiQueryMaxJitter = 800 * time.Millisecond // 用量查询最大随机延迟 + windowStatsCacheTTL = 1 * time.Minute + openAIProbeCacheTTL = 10 * time.Minute + grokProbeRetryTTL = 1 * time.Minute + grokFreeQuotaWindow = 24 * time.Hour ) // UsageCache 封装账户使用量相关的缓存 diff --git a/backend/internal/service/openai_capacity_shed_test.go b/backend/internal/service/openai_capacity_shed_test.go index e68a48db1c..eef8261bb6 100644 --- a/backend/internal/service/openai_capacity_shed_test.go +++ b/backend/internal/service/openai_capacity_shed_test.go @@ -250,7 +250,6 @@ func TestCodexOutboundVersionHasSingleSource(t *testing.T) { strings.HasPrefix(codexCLIUserAgent, openai.CodexDefaultOriginator+"/"+codexCLIVersion+" "), "codexCLIUserAgent=%q 必须以 codexCLIVersion=%q 作为版本段", codexCLIUserAgent, codexCLIVersion, ) - require.Equal(t, codexCLIVersion, openAICodexProbeVersion) require.GreaterOrEqual(t, CompareVersions(codexCLIVersion, codexUpstreamMinVersion), 0, "codexCLIVersion=%q 不得低于上游最低门槛 %q", codexCLIVersion, codexUpstreamMinVersion, ) diff --git a/backend/internal/service/openai_codex_identity.go b/backend/internal/service/openai_codex_identity.go index 7d9edc4e28..2cb8a45c40 100644 --- a/backend/internal/service/openai_codex_identity.go +++ b/backend/internal/service/openai_codex_identity.go @@ -83,15 +83,24 @@ func CodexCanonicalUserAgent() string { return resolveCodexOutboundIdentity("").userAgent } -// ApplyCodexCanonicalIdentity 为无账号句柄的出站请求写入与推理同源的身份三元组。 -func ApplyCodexCanonicalIdentity(h http.Header) { +// CodexCanonicalAuthIdentity 返回凭据面(auth.openai.com:换 Token / 刷新 / whoami) +// 出站请求的身份对:规范 User-Agent 与配套 originator,与推理解析链同源。 +// 凭据面不发 version 头——真实 Codex 客户端在该面只携带 originator 与 User-Agent +// (codex-rs login/default_client.rs 的 default_headers()),version 门槛 +// (issue #3901)只存在于 /backend-api/codex 推理面。 +func CodexCanonicalAuthIdentity() (userAgent, originator string) { + identity := resolveCodexOutboundIdentity("") + return identity.userAgent, identity.originator +} + +// ApplyCodexCanonicalAuthIdentity 为凭据面出站请求写入身份对(不含 version)。 +func ApplyCodexCanonicalAuthIdentity(h http.Header) { if h == nil { return } - identity := resolveCodexOutboundIdentity("") - h.Set("user-agent", identity.userAgent) - h.Set("originator", identity.originator) - h.Set("version", identity.version) + userAgent, originator := CodexCanonicalAuthIdentity() + h.Set("user-agent", userAgent) + h.Set("originator", originator) } // CodexCanonicalClientVersion 返回当前生效的 Codex 客户端版本号。 diff --git a/backend/internal/service/openai_codex_identity_test.go b/backend/internal/service/openai_codex_identity_test.go index 7820646431..15768e5ea5 100644 --- a/backend/internal/service/openai_codex_identity_test.go +++ b/backend/internal/service/openai_codex_identity_test.go @@ -333,10 +333,11 @@ func TestCodexCanonicalUserAgentFollowsResolver(t *testing.T) { require.Equal(t, "0.200.1", CodexCanonicalClientVersion()) h := make(http.Header) - ApplyCodexCanonicalIdentity(h) + ApplyCodexCanonicalAuthIdentity(h) require.Equal(t, "codex_cli_rs", h.Get("originator")) require.Equal(t, "codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix, h.Get("user-agent")) - require.Equal(t, "0.200.1", h.Get("version")) + // 凭据面不发 version 头(真实客户端在 auth.openai.com 只带 originator + UA)。 + require.Empty(t, h.Get("version")) } func TestCodexCanonicalUserAgentFallsBackWithoutResolver(t *testing.T) { diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 8b5bcda915..ee52003017 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -311,7 +311,15 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc identity := resolveCodexOutboundIdentity(overrideUA) headers.Set("Originator", identity.originator) headers.Set("User-Agent", identity.userAgent) - headers.Set("Version", identity.version) + // Version 头优先与 client_version 查询参数同源:客户端自报版本合法且不低于上游 + // 门槛时原样使用;否则回退规范版本,避免陈旧 version 触发上游 404(issue #3901)。 + // client_version 查询参数本身始终按客户端原值透传(内容协商语义,契约见 + // TestFetchCodexModelsManifestPassthrough)。 + headerVersion := NormalizeCodexClientVersion(clientVersion) + if headerVersion == "" || CompareVersions(headerVersion, codexUpstreamMinVersion) < 0 { + headerVersion = identity.version + } + headers.Set("Version", headerVersion) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index c7eb7e29ec..8f4c495755 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -451,8 +451,8 @@ func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) { if gotRequest.Header.Get("Originator") != openai.CodexDefaultOriginator { t.Errorf("originator header: got %q", gotRequest.Header.Get("Originator")) } - if gotRequest.Header.Get("Version") != CodexCanonicalClientVersion() { - t.Errorf("version header: got %q", gotRequest.Header.Get("Version")) + if gotRequest.Header.Get("Version") != "0.144.0" { + t.Errorf("version header must match the client_version query param: got %q", gotRequest.Header.Get("Version")) } if gotRequest.Header.Get("User-Agent") != CodexCanonicalUserAgent() { t.Errorf("user-agent header: got %q", gotRequest.Header.Get("User-Agent")) diff --git a/backend/internal/service/openai_codex_pat_service.go b/backend/internal/service/openai_codex_pat_service.go index f4c4cc9f11..fa89aa962d 100644 --- a/backend/internal/service/openai_codex_pat_service.go +++ b/backend/internal/service/openai_codex_pat_service.go @@ -58,7 +58,7 @@ func (s *OpenAIOAuthService) ValidateCodexPersonalAccessToken(ctx context.Contex } req.Header.Set("authorization", "Bearer "+accessToken) req.Header.Set("accept", "application/json") - ApplyCodexCanonicalIdentity(req.Header) + ApplyCodexCanonicalAuthIdentity(req.Header) resp, err := client.Do(req) if err != nil { diff --git a/backend/internal/service/openai_codex_version_consistency_test.go b/backend/internal/service/openai_codex_version_consistency_test.go index aede2ae432..fe95994237 100644 --- a/backend/internal/service/openai_codex_version_consistency_test.go +++ b/backend/internal/service/openai_codex_version_consistency_test.go @@ -11,9 +11,6 @@ import ( ) func TestCodexVersionConstants_Consistency(t *testing.T) { - require.Equal(t, codexCLIVersion, openAICodexProbeVersion, - "codexCLIVersion and openAICodexProbeVersion must stay in sync") - require.True(t, strings.Contains(codexCLIUserAgent, openai.CodexDefaultOriginator+"/"+codexCLIVersion), "codexCLIUserAgent must embed codexCLIVersion")