Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/cmd/server/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ func provideCleanup(
accountExpiry *service.AccountExpiryService,
cnProviderBalanceCheck *service.CNProviderBalanceCheckService,
codexVersionSync *service.OpenAICodexVersionSyncService,
codexClientIdentityBackfill *service.CodexClientIdentityBackfillService,
proxyExpiry *service.ProxyExpiryService,
subscriptionExpiry *service.SubscriptionExpiryService,
usageCleanup *service.UsageCleanupService,
Expand Down Expand Up @@ -309,6 +310,10 @@ func provideCleanup(
codexVersionSync.Stop()
return nil
}},
{"CodexClientIdentityBackfillService", func() error {
codexClientIdentityBackfill.Stop()
return nil
}},
{"ProxyExpiryService", func() error {
proxyExpiry.Stop()
return nil
Expand Down
8 changes: 7 additions & 1 deletion backend/cmd/server/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/cmd/server/wire_gen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
accountExpirySvc,
nil, // cnProviderBalanceCheck
codexVersionSyncSvc,
nil, // codexClientIdentityBackfill
proxyExpirySvc,
subscriptionExpirySvc,
&service.UsageCleanupService{},
Expand Down
20 changes: 19 additions & 1 deletion backend/internal/repository/openai_oauth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,25 @@ func (s *openaiOAuthService) RefreshTokenWithClientID(ctx context.Context, refre
}

func (s *openaiOAuthService) refreshTokenWithClientID(ctx context.Context, refreshToken, proxyURL, clientID string) (*openai.TokenResponse, error) {
authUA, authOriginator := service.CodexCanonicalAuthIdentity()
return s.refreshTokenWithIdentity(ctx, refreshToken, proxyURL, clientID, authUA, authOriginator)
}

// RefreshTokenWithIdentity refreshes with an explicit per-account Codex identity
// (User-Agent + originator); empty values fall back to the canonical identity.
func (s *openaiOAuthService) RefreshTokenWithIdentity(ctx context.Context, refreshToken, proxyURL, clientID, userAgent, originator string) (*openai.TokenResponse, error) {
clientID = strings.TrimSpace(clientID)
if clientID == "" {
clientID = openai.ClientID
}
authUA, authOriginator := service.CodexCanonicalAuthIdentity()
if strings.TrimSpace(userAgent) != "" && strings.TrimSpace(originator) != "" {
authUA, authOriginator = strings.TrimSpace(userAgent), strings.TrimSpace(originator)
}
return s.refreshTokenWithIdentity(ctx, refreshToken, proxyURL, clientID, authUA, authOriginator)
}

func (s *openaiOAuthService) refreshTokenWithIdentity(ctx context.Context, refreshToken, proxyURL, clientID, authUA, authOriginator string) (*openai.TokenResponse, error) {
client, err := createOpenAIReqClient(proxyURL)
if err != nil {
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_OAUTH_CLIENT_INIT_FAILED", "create HTTP client: %v", err)
Expand All @@ -96,7 +115,6 @@ 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", authUA).
Expand Down
1 change: 1 addition & 0 deletions backend/internal/repository/scheduler_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,7 @@ func filterSchedulerExtra(extra map[string]any) map[string]any {
"openai_oauth_passthrough",
"codex_fingerprint_mode",
"codex_fingerprint_seed",
service.CodexClientIdentityExtraKey,
"codex_5h_used_percent",
"codex_7d_used_percent",
"codex_5h_reset_at",
Expand Down
19 changes: 7 additions & 12 deletions backend/internal/service/account_test_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -866,17 +866,13 @@ 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")
canonical := resolveCodexOutboundIdentity("")
canonical := resolveCodexOutboundIdentityForAccount(credentialAccount, codexAccountIdentityOverrideUA(credentialAccount))
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", canonical.userAgent)
}
req.Header.Set("User-Agent", canonical.userAgent)
setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount)
// 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入,否则测试用的身份
// 与该账号真实出站的身份不是同一个(issue #3901 的配对不变式由收口保证)。
enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent())
// 与真实转发一致:使用该账号的 Codex TUI 身份,账号级自定义 UA 同样作为管理员
// 显式配置传入,否则测试用的身份与该账号真实出站的身份不是同一个。
enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount))
}

// 账号级请求头覆写:测试请求与真实转发保持一致的最终头
Expand Down Expand Up @@ -3156,9 +3152,8 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
req.Header.Set("User-Agent", canonical.userAgent)
}
setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount)
// 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入,否则测试用的身份
// 与该账号真实出站的身份不是同一个(issue #3901 的配对不变式由收口保证)。
enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent())
// 与真实转发一致:使用该账号的 Codex TUI 身份,账号级自定义 UA 同样作为管理员显式配置传入。
enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, credentialAccount.GetOpenAIUserAgent())

proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,8 +312,8 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactProbeIdentityMatc
converged := resolveConvergedSessionID(seed)
require.Equal(t, converged, upstream.lastReq.Header.Get("session-id"))
require.Equal(t, converged, upstream.lastReq.Header.Get("session_id"))
require.Equal(t, resolveConvergedInstallationID(&account, seed), upstream.lastReq.Header.Get("x-codex-installation-id"),
"真实 Codex 每个请求必带 installation-id,探测不得缺失")
require.Empty(t, upstream.lastReq.Header.Get("x-codex-installation-id"),
"真实 Codex 从不以请求头形式发送 installation id")
require.NotContains(t, upstream.lastReq.Header.Get("session-id"), "probe_compact",
"探测标识不得是可被上游一眼识别的字面量")
<-updateCalls
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/service/account_usage_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -896,7 +896,7 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco
// 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入。
// 上面写进 header 的指纹缓存 UA 只在强制统一被关闭时才参与配对(保持回滚后的历史语义);
// 强制统一开启时客户端身份不参与构造,探针与真实转发用同一套规范身份出站。
enforceCodexIdentityHeadersWithUA(req.Header, account.GetOpenAIUserAgent())
enforceCodexIdentityHeadersForAccount(req.Header, account, codexAccountIdentityOverrideUA(account))
setOpenAIChatGPTAccountHeaders(req.Header, account)

proxyURL := ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -361,12 +361,11 @@ func TestBusinessSystemPromptCacheIdentityPassthroughHeaderPriority(t *testing.T
for attempt := 0; attempt < 2; attempt++ {
req, err := svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, account, body, "test-token")
require.NoError(t, err)
session, conversation := wire, wire
if explicit {
session, conversation = "explicit-session", "explicit-conversation"
}
require.Equal(t, isolateOpenAIUpstreamSessionID(getAPIKeyIDFromContext(c), codexAccountIdentitySource(c, account), session), req.Header.Get("session_id"))
require.Equal(t, isolateOpenAIUpstreamSessionID(getAPIKeyIDFromContext(c), codexAccountIdentitySource(c, account), conversation), req.Header.Get("conversation_id"))
// 真实 Codex 不发下划线 session_id / conversation_id;缺失连字符会话头时
// 以最终 prompt_cache_key(缓存键改写后的线上值)补齐 session-id。
require.Empty(t, req.Header.Get("session_id"))
require.Empty(t, req.Header.Get("conversation_id"))
require.Equal(t, wire, req.Header.Get("session-id"))
sent := readCacheStrictBody(req)
require.Equal(t, wire, gjson.GetBytes(sent, "prompt_cache_key").String())
}
Expand Down
7 changes: 7 additions & 0 deletions backend/internal/service/oauth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ type OpenAIOAuthClient interface {
RefreshTokenWithClientID(ctx context.Context, refreshToken, proxyURL string, clientID string) (*openai.TokenResponse, error)
}

// OpenAIOAuthIdentityRefresher is the optional credential-plane extension that lets an
// existing account refresh its token with the same per-account Codex identity used for
// inference (a real Codex client refreshes with the User-Agent / originator it runs as).
type OpenAIOAuthIdentityRefresher interface {
RefreshTokenWithIdentity(ctx context.Context, refreshToken, proxyURL, clientID, userAgent, originator string) (*openai.TokenResponse, error)
}

// GrokOAuthClient interface for xAI/Grok OAuth operations.
type GrokOAuthClient interface {
ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL, clientID string) (*xai.TokenResponse, error)
Expand Down
13 changes: 7 additions & 6 deletions backend/internal/service/openai_agent_identity_compat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,9 @@ func TestOpenAIAgentIdentityPassthroughKeepsSessionAndPromptCacheHeaders(t *test
require.NoError(t, err)
require.Equal(t, "AgentAssertion", strings.SplitN(req.Header.Get("Authorization"), " ", 2)[0])
require.Equal(t, "account-agent-passthrough", req.Header.Get("chatgpt-account-id"))
require.NotEqual(t, "client-session", req.Header.Get("session_id"))
require.NotEqual(t, "client-conversation", req.Header.Get("conversation_id"))
require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "client-session"), req.Header.Get("session_id"))
require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "client-conversation"), req.Header.Get("conversation_id"))
require.Empty(t, req.Header.Get("session_id"), "真实 Codex 不发下划线 session_id")
require.Empty(t, req.Header.Get("conversation_id"))
require.Equal(t, "cache-agent", req.Header.Get("session-id"), "缺失连字符会话头时以最终 prompt_cache_key 补齐")
requestBody, err := io.ReadAll(req.Body)
require.NoError(t, err)
require.Contains(t, string(requestBody), `"prompt_cache_key":"cache-agent"`)
Expand Down Expand Up @@ -483,8 +482,10 @@ func TestOpenAIAgentIdentityChatRecoveryKeepsAutoDerivedSessionIsolationStable(t
secondKey := gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String()
require.NotEmpty(t, firstKey)
require.Equal(t, firstKey, secondKey)
require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(99, codexAccountIdentitySource(c, account), firstKey)), upstream.requests[0].Header.Get("session_id"))
require.Equal(t, upstream.requests[0].Header.Get("session_id"), upstream.requests[1].Header.Get("session_id"))
// 构造器已用最终 body prompt_cache_key 补出连字符 session-id,后置回退不再覆盖它。
require.Equal(t, firstKey, upstream.requests[0].Header.Get("session-id"))
require.Empty(t, upstream.requests[0].Header.Get("session_id"))
require.Equal(t, upstream.requests[0].Header.Get("session-id"), upstream.requests[1].Header.Get("session-id"))
}

func decodeAgentAssertionTask(t *testing.T, header string) string {
Expand Down
10 changes: 5 additions & 5 deletions backend/internal/service/openai_alpha_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -768,12 +768,12 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c
}
apiKeyID := getAPIKeyIDFromContext(c)
if sessionID := strings.TrimSpace(gjson.GetBytes(alphaBody, "id").String()); sessionID != "" {
isolated := isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionID)
req.Header.Set("Session_ID", isolated)
req.Header.Set("Conversation_ID", isolated)
// 搜索会话 id 以连字符会话头(UUID 形态)补齐缺失的会话头;账号/Key 作用域改写由
// 下面的 applyCodexAccountIdentityHeaders 统一完成,与其他 Codex 路径一致。
fillCodexSessionIdentityHeaders(req.Header, generateSessionUUID(sessionID))
}
applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), apiKeyID)
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
enforceCodexIdentityHeadersForAccount(req.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account))
account.ApplyHeaderOverrides(req.Header)
return req, nil
}
Expand Down Expand Up @@ -912,7 +912,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context
req.Header.Set("User-Agent", canonical.userAgent)
}
applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
enforceCodexIdentityHeadersForAccount(req.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account))
}

account.ApplyHeaderOverrides(req.Header)
Expand Down
6 changes: 3 additions & 3 deletions backend/internal/service/openai_alpha_search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,11 @@ func TestForwardAlphaSearchOAuthPreservesWire(t *testing.T) {
require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version"))
require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta"))
require.Equal(t,
scopeCodexAccountIdentityValue(account, 0, "session", "search-session"),
scopeCodexAccountIdentityValue(account, 0, "search-session"),
gjson.Get(upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"), "session_id").String(),
)
require.Equal(t,
scopeCodexAccountIdentityValue(account, 0, "turn", "search-turn"),
scopeCodexAccountIdentityValue(account, 0, "search-turn"),
gjson.Get(upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"), "turn_id").String(),
)
require.JSONEq(t, string(body), string(upstream.lastBody))
Expand Down Expand Up @@ -166,7 +166,7 @@ func TestForwardAlphaSearchPATUsesResponsesWebSearchFallback(t *testing.T) {
require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta"))
require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version"))
require.Equal(t,
scopeCodexAccountIdentityValue(account, 0, "turn", "turn-1"),
scopeCodexAccountIdentityValue(account, 0, "turn-1"),
gjson.Get(upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"), "turn_id").String(),
)
require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("Originator"))
Expand Down
Loading
Loading