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.
This commit is contained in:
spongehah
2026-08-18 13:03:22 +08:00
parent 8869775ed3
commit bb6c3b4f6a
14 changed files with 106 additions and 41 deletions
@@ -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)
@@ -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}`)
@@ -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 同样作为管理员显式配置传入,否则测试用的身份
@@ -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))
@@ -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))
}
@@ -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)
}
}
@@ -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())
}
@@ -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 {
@@ -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") != "" {
@@ -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 {
@@ -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)
@@ -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。
@@ -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(与非透传路径相同的相对位置:
@@ -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 的值只在强制统一被关闭时才参与配对)。