From 3d0f496a0b9eac631a4b495b93dfe740ddc00724 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 18 Sep 2026 03:38:06 +0800 Subject: [PATCH 1/3] feat(codex): give every OpenAI OAuth account a fixed, realistic Codex TUI identity - derive a per-account Codex TUI identity (OS, version, arch, terminal, sandbox) from the system fingerprint seed, persist it in accounts.extra, and use it for every outbound request of that account (HTTP, passthrough, WS handshake, probes, token refresh) - default codex_fingerprint_mode to device for OpenAI OAuth-like accounts; explicit off is still honored; migration 243 backfills stored accounts and seeds - forward the hyphenated session-id / thread-id / x-client-request-id headers, stop sending the legacy underscore session_id/conversation_id outside compact, never emit x-codex-installation-id as a header, and keep UUIDv7 time bits when scoping identifiers - turn-metadata sandbox follows the effective outbound User-Agent --- backend/cmd/server/wire.go | 5 + backend/cmd/server/wire_gen.go | 8 +- backend/cmd/server/wire_gen_test.go | 1 + .../repository/openai_oauth_service.go | 20 +- .../internal/repository/scheduler_cache.go | 1 + .../internal/service/account_test_service.go | 19 +- ...ccount_test_service_openai_compact_test.go | 4 +- .../internal/service/account_usage_service.go | 2 +- ...iness_system_prompt_cache_identity_test.go | 11 +- backend/internal/service/oauth_service.go | 7 + .../openai_agent_identity_compat_test.go | 7 +- .../internal/service/openai_alpha_search.go | 4 +- .../service/openai_alpha_search_test.go | 6 +- .../service/openai_codex_account_identity.go | 155 +++++--- .../openai_codex_account_identity_test.go | 14 +- .../service/openai_codex_client_identity.go | 371 ++++++++++++++++++ .../openai_codex_client_identity_test.go | 138 +++++++ .../service/openai_codex_fingerprint.go | 118 ++++-- .../service/openai_codex_fingerprint_test.go | 52 ++- .../internal/service/openai_codex_identity.go | 40 ++ .../service/openai_codex_models_service.go | 3 +- .../service/openai_compat_model_test.go | 12 +- .../service/openai_gateway_forward.go | 23 +- .../service/openai_gateway_messages.go | 2 +- .../service/openai_gateway_passthrough.go | 35 +- .../service/openai_gateway_service.go | 56 +-- .../service/openai_gateway_service_test.go | 4 +- .../service/openai_oauth_passthrough_test.go | 31 +- .../internal/service/openai_oauth_service.go | 19 +- .../service/openai_setup_token_compat_test.go | 4 +- ...penai_ws_forwarder_ingress_session_test.go | 3 +- .../service/openai_ws_forwarder_payload.go | 21 +- .../openai_ws_forwarder_success_test.go | 30 +- .../openai_ws_http_bridge_resume_test.go | 8 +- .../openai_ws_v2_passthrough_adapter.go | 7 + backend/internal/service/upstream_models.go | 4 +- backend/internal/service/wire.go | 9 + .../243_codex_fingerprint_device_default.sql | 36 ++ .../account/BulkEditAccountModal.vue | 11 +- .../components/account/CreateAccountModal.vue | 13 +- .../components/account/EditAccountModal.vue | 18 +- .../__tests__/BulkEditAccountModal.spec.ts | 6 +- .../src/i18n/locales/en/admin/accounts.ts | 4 +- .../src/i18n/locales/zh/admin/accounts.ts | 4 +- 44 files changed, 1076 insertions(+), 270 deletions(-) create mode 100644 backend/internal/service/openai_codex_client_identity.go create mode 100644 backend/internal/service/openai_codex_client_identity_test.go create mode 100644 backend/migrations/243_codex_fingerprint_device_default.sql diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index dd6f0e259a4d..893b3d787763 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -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, @@ -309,6 +310,10 @@ func provideCleanup( codexVersionSync.Stop() return nil }}, + {"CodexClientIdentityBackfillService", func() error { + codexClientIdentityBackfill.Stop() + return nil + }}, {"ProxyExpiryService", func() error { proxyExpiry.Stop() return nil diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 307abb58c5fd..2d4568b62780 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -370,6 +370,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { accountExpiryService := service.ProvideAccountExpiryService(accountRepository) cnProviderBalanceCheckService := service.ProvideCNProviderBalanceCheckService(accountRepository, cnProviderBalanceService, cnProviderQuotaService, configConfig) openAICodexVersionSyncService := service.ProvideOpenAICodexVersionSyncService(settingRepository, settingService, gitHubReleaseClient) + codexClientIdentityBackfillService := service.ProvideCodexClientIdentityBackfillService(accountRepository) proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository) subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db) batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig) @@ -387,7 +388,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { if err != nil { return nil, err } - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, businessSystemPromptService, remoteSkillRegistryService, accountJobRuntime, cindyHealthService, cindyBalanceProbeService, imageStudioRuntime) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, codexClientIdentityBackfillService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, businessSystemPromptService, remoteSkillRegistryService, accountJobRuntime, cindyHealthService, cindyBalanceProbeService, imageStudioRuntime) application := &Application{ Server: httpServer, PromptAudit: promptService, @@ -448,6 +449,7 @@ func provideCleanup( accountExpiry *service.AccountExpiryService, cnProviderBalanceCheck *service.CNProviderBalanceCheckService, codexVersionSync *service.OpenAICodexVersionSyncService, + codexClientIdentityBackfill *service.CodexClientIdentityBackfillService, proxyExpiry *service.ProxyExpiryService, subscriptionExpiry *service.SubscriptionExpiryService, usageCleanup *service.UsageCleanupService, @@ -649,6 +651,10 @@ func provideCleanup( codexVersionSync.Stop() return nil }}, + {"CodexClientIdentityBackfillService", func() error { + codexClientIdentityBackfill.Stop() + return nil + }}, {"ProxyExpiryService", func() error { proxyExpiry.Stop() return nil diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index f072c8804b2d..f6393a9a3ac4 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -68,6 +68,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { accountExpirySvc, nil, // cnProviderBalanceCheck codexVersionSyncSvc, + nil, // codexClientIdentityBackfill proxyExpirySvc, subscriptionExpirySvc, &service.UsageCleanupService{}, diff --git a/backend/internal/repository/openai_oauth_service.go b/backend/internal/repository/openai_oauth_service.go index e7a34ad6a04a..f7ad47e2e041 100644 --- a/backend/internal/repository/openai_oauth_service.go +++ b/backend/internal/repository/openai_oauth_service.go @@ -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) @@ -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). diff --git a/backend/internal/repository/scheduler_cache.go b/backend/internal/repository/scheduler_cache.go index ae53ae6fcf89..df2718272b5e 100644 --- a/backend/internal/repository/scheduler_cache.go +++ b/backend/internal/repository/scheduler_cache.go @@ -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", diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 69fa44a50479..832c55da7532 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -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, credentialAccount.GetOpenAIUserAgent()) 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, credentialAccount.GetOpenAIUserAgent()) } // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 @@ -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 { 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 aa2b5f70bd86..fcdd825aa23d 100644 --- a/backend/internal/service/account_test_service_openai_compact_test.go +++ b/backend/internal/service/account_test_service_openai_compact_test.go @@ -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 diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index ea0ee011822a..a8d5988b96da 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -896,7 +896,7 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco // 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入。 // 上面写进 header 的指纹缓存 UA 只在强制统一被关闭时才参与配对(保持回滚后的历史语义); // 强制统一开启时客户端身份不参与构造,探针与真实转发用同一套规范身份出站。 - enforceCodexIdentityHeadersWithUA(req.Header, account.GetOpenAIUserAgent()) + enforceCodexIdentityHeadersForAccount(req.Header, account, account.GetOpenAIUserAgent()) setOpenAIChatGPTAccountHeaders(req.Header, account) proxyURL := "" diff --git a/backend/internal/service/business_system_prompt_cache_identity_test.go b/backend/internal/service/business_system_prompt_cache_identity_test.go index 621f4ebe2d7a..a40ba422fb9d 100644 --- a/backend/internal/service/business_system_prompt_cache_identity_test.go +++ b/backend/internal/service/business_system_prompt_cache_identity_test.go @@ -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()) } diff --git a/backend/internal/service/oauth_service.go b/backend/internal/service/oauth_service.go index b92d5cc0099e..d163933b12d8 100644 --- a/backend/internal/service/oauth_service.go +++ b/backend/internal/service/oauth_service.go @@ -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) diff --git a/backend/internal/service/openai_agent_identity_compat_test.go b/backend/internal/service/openai_agent_identity_compat_test.go index c576a4c40460..ade25a947bbb 100644 --- a/backend/internal/service/openai_agent_identity_compat_test.go +++ b/backend/internal/service/openai_agent_identity_compat_test.go @@ -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"`) diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index 686b75753590..14b51bb361ab 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -773,7 +773,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c req.Header.Set("Conversation_ID", isolated) } 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 } @@ -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) diff --git a/backend/internal/service/openai_alpha_search_test.go b/backend/internal/service/openai_alpha_search_test.go index aa1c9f13baa4..bdd73bce7c23 100644 --- a/backend/internal/service/openai_alpha_search_test.go +++ b/backend/internal/service/openai_alpha_search_test.go @@ -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)) @@ -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")) diff --git a/backend/internal/service/openai_codex_account_identity.go b/backend/internal/service/openai_codex_account_identity.go index d0e336ec6ccb..d0f6ca036070 100644 --- a/backend/internal/service/openai_codex_account_identity.go +++ b/backend/internal/service/openai_codex_account_identity.go @@ -6,14 +6,20 @@ import ( "encoding/json" "fmt" "net/http" + "regexp" "strings" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) -const codexAccountIdentityNamespaceVersion = "v1" +// codexAccountIdentityNamespaceVersion 进入派生种子;v2 起 UUIDv7 保留时间位且同一原始值 +// 在所有字段映射到同一结果。 +const codexAccountIdentityNamespaceVersion = "v2" + +var codexIdentityUUIDPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) const codexAccountIdentitySourceContextKey = "openai_codex_account_identity_source" @@ -90,37 +96,77 @@ func isolateOpenAIUpstreamSessionID(apiKeyID int64, account *Account, raw string return fmt.Sprintf("%x", sum[:8]) } -func scopeCodexAccountIdentityValue(account *Account, apiKeyID int64, kind, raw string) string { +// scopeCodexAccountIdentityValue 把客户端标识按 (API Key, OAuth 凭据) 作用域改写, +// 并保持真实 Codex 的形态不变式: +// - 同一个原始 UUID 无论出现在哪个字段(session-id 头、thread-id 头、x-client-request-id、 +// client_metadata.session_id/thread_id、prompt_cache_key)都映射到同一个值, +// 因此 root 线程的 session == thread == request id == prompt_cache_key 仍然成立; +// - UUIDv7 保留 48 位时间戳与版本位,只替换随机位,改写后仍是合法的 Codex 形态 UUIDv7; +// - ":"(x-codex-window-id)只改写 uuid 前缀,保留 ":"; +// - ":"(内部子代理的 prompt_cache_key)只改写 uuid 部分; +// - 其他形态退化为确定性 UUIDv4。 +func scopeCodexAccountIdentityValue(account *Account, apiKeyID int64, raw string) string { raw = strings.TrimSpace(raw) namespace := codexAccountIdentityNamespace(account) if raw == "" || namespace == "" { return raw } - return deriveStableUUIDv4(fmt.Sprintf( - "sub2api:codex-account-identity:%s:user:%d:account:%s:kind:%s:value:%s", + if codexIdentityUUIDPattern.MatchString(raw) { + return scopeCodexIdentityUUID(apiKeyID, namespace, raw) + } + if idx := strings.IndexByte(raw, ':'); idx > 0 && idx < len(raw)-1 { + head, tail := raw[:idx], raw[idx+1:] + if codexIdentityUUIDPattern.MatchString(head) { + return scopeCodexIdentityUUID(apiKeyID, namespace, head) + ":" + tail + } + if codexIdentityUUIDPattern.MatchString(tail) { + return head + ":" + scopeCodexIdentityUUID(apiKeyID, namespace, tail) + } + } + return deriveStableUUIDv4(codexAccountIdentityScopeSeed(apiKeyID, namespace, raw)) +} + +func codexAccountIdentityScopeSeed(apiKeyID int64, namespace, raw string) string { + return fmt.Sprintf( + "sub2api:codex-account-identity:%s:user:%d:account:%s:value:%s", codexAccountIdentityNamespaceVersion, apiKeyID, namespace, - kind, raw, - )) + ) +} + +// scopeCodexIdentityUUID 改写单个 UUID:v7 保留前 48 位时间戳与版本位,其余位由 +// 作用域种子确定性派生;非 v7 退化为确定性 UUIDv4(如 installation_id 本就是 v4)。 +func scopeCodexIdentityUUID(apiKeyID int64, namespace, raw string) string { + seed := codexAccountIdentityScopeSeed(apiKeyID, namespace, strings.ToLower(raw)) + parsed, err := uuid.Parse(raw) + if err != nil || parsed.Version() != 7 { + return deriveStableUUIDv4(seed) + } + h := sha256.Sum256([]byte(seed)) + var out uuid.UUID + copy(out[:6], parsed[:6]) + out[6] = 0x70 | (h[6] & 0x0f) + out[7] = h[7] + out[8] = 0x80 | (h[8] & 0x3f) + copy(out[9:], h[9:16]) + return out.String() } -var codexAccountIdentityFields = []struct { - name string - kind string -}{ - {name: "installation_id", kind: "installation"}, - {name: "x-codex-installation-id", kind: "installation"}, - {name: "session_id", kind: "session"}, - {name: "session-id", kind: "session"}, - {name: "thread_id", kind: "thread"}, - {name: "thread-id", kind: "thread"}, - {name: "turn_id", kind: "turn"}, - {name: "turn-id", kind: "turn"}, - {name: "window_id", kind: "window"}, - {name: "x-codex-window-id", kind: "window"}, - {name: "x-client-request-id", kind: "request"}, +// codexAccountIdentityFields 是需要按账号作用域改写的客户端标识字段(头名 / client_metadata 键)。 +var codexAccountIdentityFields = []string{ + "installation_id", + "x-codex-installation-id", + "session_id", + "session-id", + "thread_id", + "thread-id", + "turn_id", + "turn-id", + "window_id", + "x-codex-window-id", + "x-client-request-id", } func applyCodexAccountIdentityFields(values map[string]any, account *Account, apiKeyID int64) bool { @@ -128,14 +174,14 @@ func applyCodexAccountIdentityFields(values map[string]any, account *Account, ap return false } changed := false - for _, field := range codexAccountIdentityFields { - raw, ok := values[field.name].(string) + for _, name := range codexAccountIdentityFields { + raw, ok := values[name].(string) if !ok || strings.TrimSpace(raw) == "" { continue } - next := scopeCodexAccountIdentityValue(account, apiKeyID, field.kind, raw) + next := scopeCodexAccountIdentityValue(account, apiKeyID, raw) if next != raw { - values[field.name] = next + values[name] = next changed = true } } @@ -168,9 +214,7 @@ func applyCodexAccountIdentityClientMetadataMap(requestBody map[string]any, acco } changed := false clientMetadata, _ := requestBody["client_metadata"].(map[string]any) - originalBodySessionID := "" if clientMetadata != nil { - originalBodySessionID, _ = clientMetadata["session_id"].(string) if applyCodexAccountIdentityFields(clientMetadata, account, apiKeyID) { changed = true } @@ -179,11 +223,7 @@ func applyCodexAccountIdentityClientMetadataMap(requestBody map[string]any, acco } } if raw, ok := requestBody["prompt_cache_key"].(string); ok && strings.TrimSpace(raw) != "" { - kind := "prompt-cache" - if strings.TrimSpace(originalBodySessionID) != "" && raw == originalBodySessionID { - kind = "session" - } - next := scopeCodexAccountIdentityValue(account, apiKeyID, kind, raw) + next := scopeCodexAccountIdentityValue(account, apiKeyID, raw) if next != raw { requestBody["prompt_cache_key"] = next changed = true @@ -206,13 +246,11 @@ func applyCodexAccountIdentityClientMetadataRaw(body []byte, account *Account, a next := body changed := false - originalBodySessionID := "" if cm := gjson.GetBytes(body, "client_metadata"); cm.IsObject() { clientMetadata := map[string]any{} if err := json.Unmarshal([]byte(cm.Raw), &clientMetadata); err != nil { return body, false, fmt.Errorf("decode client_metadata for account identity: %w", err) } - originalBodySessionID, _ = clientMetadata["session_id"].(string) metadataChanged := applyCodexAccountIdentityFields(clientMetadata, account, apiKeyID) if applyCodexAccountIdentityEmbeddedMetadata(clientMetadata, account, apiKeyID) { metadataChanged = true @@ -232,11 +270,7 @@ func applyCodexAccountIdentityClientMetadataRaw(body []byte, account *Account, a } if promptCacheKey := gjson.GetBytes(body, "prompt_cache_key"); promptCacheKey.Type == gjson.String && strings.TrimSpace(promptCacheKey.String()) != "" { raw := promptCacheKey.String() - kind := "prompt-cache" - if strings.TrimSpace(originalBodySessionID) != "" && raw == originalBodySessionID { - kind = "session" - } - scoped := scopeCodexAccountIdentityValue(account, apiKeyID, kind, raw) + scoped := scopeCodexAccountIdentityValue(account, apiKeyID, raw) if scoped != raw { rewritten, err := sjson.SetBytes(next, "prompt_cache_key", scoped) if err != nil { @@ -253,15 +287,21 @@ func applyCodexAccountIdentityHeaders(headers http.Header, account *Account, api if headers == nil || codexAccountIdentityNamespace(account) == "" { return } - for _, field := range codexAccountIdentityFields { - // Underscore session/conversation headers are rebuilt separately from the - // prompt cache key by each request builder. - if field.name == "session_id" { + for _, name := range codexAccountIdentityFields { + switch name { + case "session_id": + // Underscore session/conversation headers are rebuilt separately by the + // compact request builders; the Codex inference paths never send them. + continue + case "x-codex-installation-id": + // A real Codex client only carries the installation id inside body + // client_metadata; never emit it as a request header. + headers.Del(name) continue } - raw := strings.TrimSpace(headers.Get(field.name)) + raw := strings.TrimSpace(headers.Get(name)) if raw != "" { - headers.Set(field.name, scopeCodexAccountIdentityValue(account, apiKeyID, field.kind, raw)) + headers.Set(name, scopeCodexAccountIdentityValue(account, apiKeyID, raw)) } } if raw := strings.TrimSpace(headers.Get(openAIWSTurnMetadataHeader)); raw != "" { @@ -273,3 +313,28 @@ func applyCodexAccountIdentityHeaders(headers http.Header, account *Account, api } } } + +// ensureCodexSessionIdentityHeaders 补齐真实 Codex 客户端必带、但入站请求可能缺失的 +// 连字符会话头:session-id 缺失时取最终(已按账号作用域改写的)会话标识, +// thread-id 缺失时等于 session-id(root 线程不变式),x-client-request-id 缺失时等于 +// thread-id。sessionID 必须已经是出站形态,这里不再二次改写。 +func ensureCodexSessionIdentityHeaders(h http.Header, sessionID string) { + if h == nil { + return + } + if strings.TrimSpace(h.Get("session-id")) == "" { + if sessionID = strings.TrimSpace(sessionID); sessionID != "" { + h.Set("session-id", sessionID) + } + } + if strings.TrimSpace(h.Get("thread-id")) == "" { + if current := strings.TrimSpace(h.Get("session-id")); current != "" { + h.Set("thread-id", current) + } + } + if strings.TrimSpace(h.Get("x-client-request-id")) == "" { + if current := strings.TrimSpace(h.Get("thread-id")); current != "" { + h.Set("x-client-request-id", current) + } + } +} diff --git a/backend/internal/service/openai_codex_account_identity_test.go b/backend/internal/service/openai_codex_account_identity_test.go index ffceba2d395e..2233790a7c64 100644 --- a/backend/internal/service/openai_codex_account_identity_test.go +++ b/backend/internal/service/openai_codex_account_identity_test.go @@ -114,7 +114,9 @@ func TestCodexAccountIdentitySourceResolvesShadowAndOverwritesFailoverContext(t "token", true, "client-session", true, ) require.NoError(t, err) - require.Equal(t, isolateOpenAIUpstreamSessionID(0, parent, "client-session"), req.Header.Get("session_id")) + require.Equal(t, resolveCodexOutboundIdentityForAccount(parent, "").userAgent, req.Header.Get("user-agent"), "影子账号使用凭据账号的身份") + require.Empty(t, req.Header.Get("session_id")) + require.Equal(t, "client-session", req.Header.Get("session-id"), "缺失连字符会话头时以最终 prompt_cache_key 补齐") next := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ "chatgpt_account_id": "other-account", @@ -133,6 +135,7 @@ func TestBuildOpenAIWSHeadersNamespacesCodexIdentityByOAuthAccount(t *testing.T) c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) c.Set("api_key_id", int64(77)) c.Request.Header.Set("x-codex-installation-id", "client-installation") + c.Request.Header.Set("session-id", "client-session") c.Request.Header.Set("thread-id", "client-thread") c.Request.Header.Set("x-codex-window-id", "client-window") c.Request.Header.Set("x-client-request-id", "client-request") @@ -153,19 +156,22 @@ func TestBuildOpenAIWSHeadersNamespacesCodexIdentityByOAuthAccount(t *testing.T) first := build(account11) firstAgain := build(account11) second := build(account19) - for _, header := range []string{"session_id", "x-codex-installation-id", "thread-id", "x-codex-window-id", "x-client-request-id"} { + for _, header := range []string{"session-id", "thread-id", "x-codex-window-id", "x-client-request-id"} { require.NotEmpty(t, first.Get(header), header) require.Equal(t, first.Get(header), firstAgain.Get(header), header) require.NotEqual(t, first.Get(header), second.Get(header), header) } + require.Empty(t, first.Get("x-codex-installation-id"), "installation id never travels as a header") + require.Empty(t, first.Get("session_id"), "no underscore session header on the Codex path") + scopedBody := []byte(`{"model":"gpt-5.6-codex","stream":true,"prompt_cache_key":"` + scopeCodexAccountIdentityValue(account11, 77, "client-session") + `"}`) httpRequest, err := service.buildUpstreamRequest( context.Background(), c, account11, - []byte(`{"model":"gpt-5.6-codex","stream":true,"prompt_cache_key":"client-session"}`), + scopedBody, "token", true, "client-session", true, ) require.NoError(t, err) - require.Equal(t, httpRequest.Header.Get("session_id"), first.Get("session_id"), "HTTP and WS must derive the same identity from the raw client key") + require.Equal(t, httpRequest.Header.Get("session-id"), first.Get("session-id"), "HTTP and WS must derive the same identity from the raw client key") } func TestBuildUpstreamRequestNamespacesCodexIdentityByOAuthAccount(t *testing.T) { diff --git a/backend/internal/service/openai_codex_client_identity.go b/backend/internal/service/openai_codex_client_identity.go new file mode 100644 index 000000000000..db0c6fd6da8b --- /dev/null +++ b/backend/internal/service/openai_codex_client_identity.go @@ -0,0 +1,371 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "log/slog" + "regexp" + "strings" + "sync" + "time" +) + +// CodexClientIdentityExtraKey 持久化在 accounts.extra 中的每账号 Codex 客户端身份。 +// OpenAI OAuth(及 setup_token)账号一账号一身份:由系统种子确定性派生后固定, +// 所有出站请求(HTTP / 透传 / WS 握手 / 探针 / token 刷新)共用,使上游看到的 +// 是同一台设备上运行的同一个真实 Codex TUI,而不是多个用户各自的客户端。 +const CodexClientIdentityExtraKey = "codex_client_identity" + +const codexClientIdentitySchemaVersion = 1 + +// codexClientIdentity 描述一台"运行 Codex TUI 的机器":操作系统、系统版本、架构、 +// 终端以及与该系统配套的 sandbox 标签。字符串形态严格对齐 codex-rs 的 +// os_info / terminal-detection / sandbox_tags 输出,保证 UA 与 turn metadata 自洽。 +type codexClientIdentity struct { + Version int `json:"v"` + OSType string `json:"os_type"` + OSVersion string `json:"os_version"` + Arch string `json:"arch"` + Terminal string `json:"terminal"` + Sandbox string `json:"sandbox"` + GeneratedAt string `json:"generated_at,omitempty"` +} + +const ( + codexClientOSMac = "Mac OS" + codexClientOSWindows = "Windows" + codexClientOSUbuntu = "Ubuntu" + + codexClientSandboxMac = "seatbelt" + codexClientSandboxWindows = "windows_sandbox" + codexClientSandboxLinux = "seccomp" +) + +var ( + codexClientMacVersions = []string{"15.5.0", "15.6.1", "15.7.0", "26.0.1", "26.1.0"} + codexClientWindowsVersions = []string{"10.0.26100", "10.0.22631", "10.0.19045"} + codexClientUbuntuVersions = []string{"22.4.0", "24.4.0"} + codexClientAppleTerminals = []string{"453", "455", "456"} +) + +var ( + codexClientOSVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) + codexClientTerminalPattern = regexp.MustCompile(`^[A-Za-z0-9_./-]{1,64}$`) +) + +// valid 做 schema 校验而不只是非空:系统/沙箱必须配对(seatbelt=Mac OS、 +// windows_sandbox=Windows、seccomp=Ubuntu),版本/架构/终端形态受限且不含空白或 +// CR/LF。持久化值或外部写入的坏值一律视为缺失并重新按种子派生,避免脏值进入 UA。 +func (id codexClientIdentity) valid() bool { + if id.Version != codexClientIdentitySchemaVersion { + return false + } + switch id.OSType { + case codexClientOSMac: + if id.Sandbox != codexClientSandboxMac || (id.Arch != "arm64" && id.Arch != "x86_64") { + return false + } + case codexClientOSWindows: + if id.Sandbox != codexClientSandboxWindows || id.Arch != "x86_64" { + return false + } + case codexClientOSUbuntu: + if id.Sandbox != codexClientSandboxLinux || id.Arch != "x86_64" { + return false + } + default: + return false + } + return codexClientOSVersionPattern.MatchString(id.OSVersion) && + codexClientTerminalPattern.MatchString(id.Terminal) +} + +// codexSandboxForUserAgent 从有效出站 UA 的 `( ; )` 段推出与之配套的 +// sandbox 标签;无法解析时返回空串(调用方不改写 sandbox)。turn metadata 声明的沙箱 +// 必须跟随最终发出的 UA,而不是账号身份本身:管理员显式 UA 覆盖时二者可能不同。 +var codexUserAgentPlatformPattern = regexp.MustCompile(`\(([^();]+?) [^ ();]+; [^();]+\)`) + +func codexSandboxForUserAgent(userAgent string) string { + m := codexUserAgentPlatformPattern.FindStringSubmatch(userAgent) + if m == nil { + return "" + } + osType := strings.ToLower(strings.TrimSpace(m[1])) + switch { + case osType == "": + return "" + case strings.HasPrefix(osType, "mac") || strings.HasPrefix(osType, "darwin"): + return codexClientSandboxMac + case strings.HasPrefix(osType, "windows"): + return codexClientSandboxWindows + default: + return codexClientSandboxLinux + } +} + +// UserAgent 拼出真实 Codex TUI 的 User-Agent: +// codex-tui/ ( ; ) (codex-tui; )。 +// 首段版本、尾部括号组版本与 version 头必须同源(codex-rs 三处都取同一个 +// CARGO_PKG_VERSION)。 +func (id codexClientIdentity) UserAgent(version string) string { + version = strings.TrimSpace(version) + return fmt.Sprintf("%s/%s (%s %s; %s) %s (%s; %s)", + codexTUIOriginator, version, id.OSType, id.OSVersion, id.Arch, id.Terminal, codexTUIOriginator, version) +} + +// codexTUIOriginator 是交互式 Codex TUI 的 originator(app-server initialize 的 +// clientInfo.name,codex-rs tui/src/lib.rs)。 +const codexTUIOriginator = "codex-tui" + +// deriveCodexClientIdentity 从种子确定性派生一份自洽身份。分布按真实 TUI 用户 +// 的大致构成加权:macOS 60%(arm64 为主),Windows 25%,Ubuntu 15%;终端与 +// 系统匹配,sandbox 标签与系统匹配。 +func deriveCodexClientIdentity(seed string) codexClientIdentity { + h := sha256.Sum256([]byte("sub2api:codex-client-identity:v1:" + seed)) + id := codexClientIdentity{Version: codexClientIdentitySchemaVersion} + switch osPick := h[0]; { + case osPick < 154: // 60% + id.OSType = codexClientOSMac + id.Sandbox = codexClientSandboxMac + if h[1] < 230 { // 90% + id.Arch = "arm64" + } else { + id.Arch = "x86_64" + } + id.OSVersion = codexClientMacVersions[int(h[2])%len(codexClientMacVersions)] + switch term := h[3]; { + case term < 102: // 40% + id.Terminal = fmt.Sprintf("iTerm.app/3.5.%d", 10+int(h[4])%6) + case term < 153: // 20% + id.Terminal = "Apple_Terminal/" + codexClientAppleTerminals[int(h[4])%len(codexClientAppleTerminals)] + case term < 191: // 15% + id.Terminal = fmt.Sprintf("ghostty/1.3.%d", int(h[4])%3) + case term < 230: // 15% + id.Terminal = codexClientVSCodeTerminal(h[4], h[5]) + default: // 10% + id.Terminal = fmt.Sprintf("WarpTerminal/v0.2026.%02d.%02d.08.12.stable_%02d", + 1+int(h[4])%9, 1+int(h[5])%28, 1+int(h[6])%3) + } + case osPick < 218: // 25% + id.OSType = codexClientOSWindows + id.Sandbox = codexClientSandboxWindows + id.Arch = "x86_64" + id.OSVersion = codexClientWindowsVersions[int(h[2])%len(codexClientWindowsVersions)] + switch term := h[3]; { + case term < 153: // 60% + id.Terminal = "WindowsTerminal" + case term < 217: // 25% + id.Terminal = codexClientVSCodeTerminal(h[4], h[5]) + default: // 15% + id.Terminal = "unknown" + } + default: // 15% + id.OSType = codexClientOSUbuntu + id.Sandbox = codexClientSandboxLinux + id.Arch = "x86_64" + id.OSVersion = codexClientUbuntuVersions[int(h[2])%len(codexClientUbuntuVersions)] + switch int(h[3]) % 3 { + case 0: + id.Terminal = "gnome-terminal" + case 1: + id.Terminal = "xterm-256color" + default: + id.Terminal = codexClientVSCodeTerminal(h[4], h[5]) + } + } + return id +} + +func codexClientVSCodeTerminal(major, minor byte) string { + return fmt.Sprintf("vscode/1.%d.%d", 103+int(major)%3, int(minor)%3) +} + +// codexClientIdentityFromExtra 读取持久化身份;缺失、非对象或字段不全时返回 false。 +func codexClientIdentityFromExtra(extra map[string]any) (codexClientIdentity, bool) { + if extra == nil { + return codexClientIdentity{}, false + } + raw, ok := extra[CodexClientIdentityExtraKey] + if !ok || raw == nil { + return codexClientIdentity{}, false + } + var encoded []byte + switch typed := raw.(type) { + case string: + encoded = []byte(typed) + case map[string]any: + data, err := json.Marshal(typed) + if err != nil { + return codexClientIdentity{}, false + } + encoded = data + default: + data, err := json.Marshal(typed) + if err != nil { + return codexClientIdentity{}, false + } + encoded = data + } + var id codexClientIdentity + if err := json.Unmarshal(encoded, &id); err != nil || !id.valid() { + return codexClientIdentity{}, false + } + return id, true +} + +// codexClientIdentitySeed 返回派生身份用的稳定种子:只认系统管理的 +// codex_fingerprint_seed(建/改账号与迁移 243 都保证 OAuth-like 账号有种子)。 +// 没有种子时返回空串,账号退回全局规范身份,避免先按其他来源派生、补种子后身份漂移。 +func codexClientIdentitySeed(account *Account) string { + if account == nil { + return "" + } + if seed, ok := codexFingerprintSeed(account.Extra); ok { + return seed + } + return "" +} + +// CodexClientIdentity 返回账号的 Codex 客户端身份。仅 OpenAI OAuth-like 账号有身份: +// 优先读持久化值,缺失时按种子即时派生(派生确定性,落库前后结果一致)。 +func (a *Account) CodexClientIdentity() (codexClientIdentity, bool) { + if a == nil || !a.IsOpenAIOAuthLike() { + return codexClientIdentity{}, false + } + if id, ok := codexClientIdentityFromExtra(a.Extra); ok { + return id, true + } + seed := codexClientIdentitySeed(a) + if seed == "" { + return codexClientIdentity{}, false + } + return deriveCodexClientIdentity(seed), true +} + +// codexClientIdentityExtraValue 把身份编码为可直接写入 extra 的 map。 +func codexClientIdentityExtraValue(id codexClientIdentity, now time.Time) map[string]any { + return map[string]any{ + "v": id.Version, + "os_type": id.OSType, + "os_version": id.OSVersion, + "arch": id.Arch, + "terminal": id.Terminal, + "sandbox": id.Sandbox, + "generated_at": now.UTC().Format(time.RFC3339), + } +} + +// ensureCodexClientIdentityExtra 在完整 extra 上补齐身份(已存在且合法则保持不变)。 +// 只对 OpenAI OAuth-like 账号生效;种子取 extra 中的 codex_fingerprint_seed, +// 调用方需先确保种子已就位(prepareCodexFingerprintExtraFor* 负责)。 +func ensureCodexClientIdentityExtra(platform, accountType string, extra map[string]any, now time.Time) map[string]any { + if platform != PlatformOpenAI || (accountType != AccountTypeOAuth && accountType != AccountTypeSetupToken) { + return extra + } + if _, ok := codexClientIdentityFromExtra(extra); ok { + return extra + } + seed, ok := codexFingerprintSeed(extra) + if !ok { + return extra + } + if extra == nil { + extra = make(map[string]any, 1) + } + extra[CodexClientIdentityExtraKey] = codexClientIdentityExtraValue(deriveCodexClientIdentity(seed), now) + return extra +} + +// CodexClientIdentityBackfillService 在启动时为存量 OpenAI OAuth-like 账号补齐并 +// 持久化 Codex 客户端身份。一次性、幂等、尽力而为:失败只记日志,不影响启动。 +type CodexClientIdentityBackfillService struct { + accountRepo AccountRepository + now func() time.Time + stopOnce sync.Once + stopCh chan struct{} + wg sync.WaitGroup +} + +func NewCodexClientIdentityBackfillService(accountRepo AccountRepository) *CodexClientIdentityBackfillService { + return &CodexClientIdentityBackfillService{ + accountRepo: accountRepo, + now: time.Now, + stopCh: make(chan struct{}), + } +} + +// Start 异步执行一次回填。 +func (s *CodexClientIdentityBackfillService) Start() { + if s == nil || s.accountRepo == nil { + return + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + go func() { + select { + case <-s.stopCh: + cancel() + case <-ctx.Done(): + } + }() + updated, failed, err := s.RunOnce(ctx) + if err != nil { + slog.Warn("codex_client_identity_backfill_failed", "error", err, "updated", updated, "failed", failed) + return + } + if updated > 0 || failed > 0 { + slog.Info("codex_client_identity_backfill_done", "updated", updated, "failed", failed) + } + }() +} + +// Stop 取消进行中的回填并等待退出。 +func (s *CodexClientIdentityBackfillService) Stop() { + if s == nil { + return + } + s.stopOnce.Do(func() { close(s.stopCh) }) + s.wg.Wait() +} + +// RunOnce 为所有缺少合法身份的 OpenAI OAuth-like 账号写入派生身份。 +// 返回成功更新数与失败数;列表查询失败时返回错误。 +func (s *CodexClientIdentityBackfillService) RunOnce(ctx context.Context) (int, int, error) { + if s == nil || s.accountRepo == nil { + return 0, 0, nil + } + accounts, err := s.accountRepo.ListByPlatform(ctx, PlatformOpenAI) + if err != nil { + return 0, 0, fmt.Errorf("list openai accounts: %w", err) + } + updated, failed := 0, 0 + for i := range accounts { + account := &accounts[i] + if !account.IsOpenAIOAuthLike() { + continue + } + if _, ok := codexClientIdentityFromExtra(account.Extra); ok { + continue + } + seed := codexClientIdentitySeed(account) + if seed == "" { + continue + } + if err := ctx.Err(); err != nil { + return updated, failed, err + } + value := codexClientIdentityExtraValue(deriveCodexClientIdentity(seed), s.now()) + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{CodexClientIdentityExtraKey: value}); err != nil { + failed++ + slog.Warn("codex_client_identity_backfill_account_failed", "account_id", account.ID, "error", err) + continue + } + updated++ + } + return updated, failed, nil +} diff --git a/backend/internal/service/openai_codex_client_identity_test.go b/backend/internal/service/openai_codex_client_identity_test.go new file mode 100644 index 000000000000..af8ec5feab13 --- /dev/null +++ b/backend/internal/service/openai_codex_client_identity_test.go @@ -0,0 +1,138 @@ +package service + +import ( + "context" + "net/http" + "regexp" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +type identityRefreshingOAuthClientStub struct { + userAgent string + originator string +} + +func (s *identityRefreshingOAuthClientStub) ExchangeCode(context.Context, string, string, string, string, string) (*openai.TokenResponse, error) { + return nil, nil +} + +func (s *identityRefreshingOAuthClientStub) RefreshToken(context.Context, string, string) (*openai.TokenResponse, error) { + return &openai.TokenResponse{AccessToken: "legacy"}, nil +} + +func (s *identityRefreshingOAuthClientStub) RefreshTokenWithClientID(context.Context, string, string, string) (*openai.TokenResponse, error) { + return &openai.TokenResponse{AccessToken: "legacy"}, nil +} + +func (s *identityRefreshingOAuthClientStub) RefreshTokenWithIdentity(_ context.Context, _, _, _, userAgent, originator string) (*openai.TokenResponse, error) { + s.userAgent, s.originator = userAgent, originator + return &openai.TokenResponse{AccessToken: "with-identity", ExpiresIn: 3600}, nil +} + +// 已有账号刷新 token 时使用账号级 Codex 身份,而不是全局规范身份。 +func TestRefreshAccountTokenUsesAccountCodexIdentity(t *testing.T) { + stub := &identityRefreshingOAuthClientStub{} + svc := NewOpenAIOAuthService(nil, stub) + account := &Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"refresh_token": "rt", "chatgpt_account_id": "acct-9"}, + Extra: map[string]any{codexFingerprintSeedExtraKey: "1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55"}} + + info, err := svc.RefreshAccountToken(context.Background(), account) + require.NoError(t, err) + require.Equal(t, "with-identity", info.AccessToken) + expected := resolveCodexOutboundIdentityForAccount(account, "") + require.Equal(t, expected.userAgent, stub.userAgent) + require.Equal(t, "codex-tui", stub.originator) +} + +// 账号级身份:由种子确定性派生,UA 三处版本同源,形态与 codex-rs 的 +// `codex-tui/ ( ; ) (codex-tui; )` 一致。 +func TestEnforceCodexIdentityHeadersForAccountUsesDerivedTUIIdentity(t *testing.T) { + seed := "6f2c1c7e-6e2d-4a4b-9f0b-2f5f0c8a1d33" + account := &Account{ID: 5, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"chatgpt_account_id": "acct-5"}, + Extra: map[string]any{codexFingerprintSeedExtraKey: seed}} + + first, ok := account.CodexClientIdentity() + require.True(t, ok) + second, ok := account.CodexClientIdentity() + require.True(t, ok) + require.Equal(t, first, second, "同一种子必须派生同一身份") + + h := http.Header{} + h.Set("originator", "codex_cli_rs") + h.Set("user-agent", "codex_cli_rs/0.150.0 (Windows 10.0.19045; x86_64) unknown") + enforceCodexIdentityHeadersForAccount(h, account, "") + + version := resolveCodexOutboundIdentity("").version + uaPattern := regexp.MustCompile(`^codex-tui/` + regexp.QuoteMeta(version) + + ` \((Mac OS|Windows|Ubuntu) [0-9]+\.[0-9]+\.[0-9]+; (arm64|x86_64)\) \S+ \(codex-tui; ` + regexp.QuoteMeta(version) + `\)$`) + require.Regexp(t, uaPattern, h.Get("user-agent")) + require.Equal(t, first.UserAgent(version), h.Get("user-agent")) + require.Equal(t, "codex-tui", h.Get("originator")) + require.Equal(t, version, h.Get("version")) + require.Contains(t, []string{"seatbelt", "windows_sandbox", "seccomp"}, first.Sandbox) +} + +// 身份是系统管理字段:表单带回的坏身份被忽略并按种子派生;schema 校验拒绝系统/沙箱不配对。 +func TestCodexClientIdentityIsSystemManagedAndSchemaChecked(t *testing.T) { + seed := "9d4b7a10-2c3e-4f5a-8b6c-1e2f3a4b5c6d" + account := &Account{ID: 3, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Extra: map[string]any{codexFingerprintSeedExtraKey: seed}} + crafted := map[string]any{"v": 1, "os_type": "Mac OS", "os_version": "15.5.0", "arch": "arm64", + "terminal": "iTerm.app/3.5.10\r\nx-evil: 1", "sandbox": "seatbelt"} + + prepared := prepareCodexFingerprintExtraForUpdate(account, map[string]any{CodexClientIdentityExtraKey: crafted}) + got, ok := codexClientIdentityFromExtra(prepared) + require.True(t, ok) + require.Equal(t, deriveCodexClientIdentity(seed), got.withoutGeneratedAt()) + require.NotContains(t, sanitizedCodexFingerprintExtraUpdates(map[string]any{CodexClientIdentityExtraKey: crafted}), CodexClientIdentityExtraKey) + + mismatched := deriveCodexClientIdentity(seed) + mismatched.Sandbox = "seccomp" + mismatched.OSType = "Windows" + require.False(t, mismatched.valid()) + require.Empty(t, codexClientIdentitySeed(&Account{ID: 4, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"chatgpt_account_id": "acct-4"}}), "没有系统种子就不派生身份") +} + +func (id codexClientIdentity) withoutGeneratedAt() codexClientIdentity { + id.GeneratedAt = "" + return id +} + +// turn metadata 的 sandbox 跟随最终出站 UA,而不是账号身份:管理员显式 UA 覆盖时二者可能不同。 +func TestFingerprintSandboxFollowsEffectiveUserAgent(t *testing.T) { + ids := &codexFingerprintIDs{sandbox: "seatbelt"} + ids.alignSandboxWithUserAgent("codex_cli_rs/0.150.0 (Windows 10.0.19045; x86_64) unknown") + require.Equal(t, "windows_sandbox", ids.sandbox) + ids.alignSandboxWithUserAgent("codex-tui/0.150.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.150.0)") + require.Equal(t, "seccomp", ids.sandbox) + ids.alignSandboxWithUserAgent("codex-tui/0.150.0 (Mac OS 15.5.0; arm64) ghostty/1.3.1 (codex-tui; 0.150.0)") + require.Equal(t, "seatbelt", ids.sandbox) + ids.alignSandboxWithUserAgent("curl/8.0") + require.Empty(t, ids.sandbox) +} + +// 账号作用域改写保留 UUIDv7 的时间位,且同一原始 UUID 在任何字段都映射到同一个值。 +func TestScopeCodexAccountIdentityValuePreservesUUIDv7TimestampAndEquality(t *testing.T) { + account := &Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"chatgpt_account_id": "acct-7"}} + raw := uuid.Must(uuid.NewV7()) + + scoped := scopeCodexAccountIdentityValue(account, 42, raw.String()) + parsed, err := uuid.Parse(scoped) + require.NoError(t, err) + require.Equal(t, uuid.Version(7), parsed.Version()) + require.Equal(t, raw[:6], parsed[:6], "48 位时间戳必须保留") + require.NotEqual(t, raw.String(), scoped) + + require.Equal(t, scoped, scopeCodexAccountIdentityValue(account, 42, raw.String()), "确定性") + require.Equal(t, scoped+":0", scopeCodexAccountIdentityValue(account, 42, raw.String()+":0"), "window id 只改写 uuid 前缀") + require.Equal(t, "compact:"+scoped, scopeCodexAccountIdentityValue(account, 42, "compact:"+raw.String()), "带来源前缀的 prompt_cache_key 只改写 uuid") + require.NotEqual(t, scoped, scopeCodexAccountIdentityValue(account, 43, raw.String()), "不同 API Key 不同作用域") +} diff --git a/backend/internal/service/openai_codex_fingerprint.go b/backend/internal/service/openai_codex_fingerprint.go index 1e19c07bdadb..fb9bb2aed774 100644 --- a/backend/internal/service/openai_codex_fingerprint.go +++ b/backend/internal/service/openai_codex_fingerprint.go @@ -110,17 +110,30 @@ func stripCodexFingerprintSeed(extra map[string]any) map[string]any { return stripped } +// codexFingerprintModeFromExtra 读取账号完整 extra 上的收敛模式。 +// 未设置、空值或非法值一律按 device 处理:多人共享同一 OAuth 账号时,上游只应看到 +// 一台设备;显式 off 仍然生效(原样透传客户端标识)。 func codexFingerprintModeFromExtra(extra map[string]any) codexFingerprintMode { + if mode, ok := codexFingerprintModeExplicit(extra); ok { + return mode + } + return codexFingerprintDevice +} + +// codexFingerprintModeExplicit 只识别显式写入的合法模式;缺失、空值、非法值返回 false。 +// 键级 JSONB 增量更新(UpdateExtra / BulkUpdate)必须用它判断"是否在启用收敛", +// 不能把缺失键当成 device 默认值。 +func codexFingerprintModeExplicit(extra map[string]any) (codexFingerprintMode, bool) { if extra == nil { - return codexFingerprintOff + return "", false } raw, _ := extra[codexFingerprintModeExtraKey].(string) - switch codexFingerprintMode(strings.TrimSpace(raw)) { + mode := codexFingerprintMode(strings.TrimSpace(raw)) + switch mode { case codexFingerprintOff, codexFingerprintDevice, codexFingerprintSession, codexFingerprintFull: - return codexFingerprintMode(strings.TrimSpace(raw)) - default: - return codexFingerprintOff + return mode, true } + return "", false } func codexFingerprintModeRequiresSeed(mode codexFingerprintMode) bool { @@ -139,37 +152,44 @@ func codexFingerprintSeed(extra map[string]any) (string, bool) { return canonicalCodexFingerprintSeed(extra[codexFingerprintSeedExtraKey]) } +// prepareCodexFingerprintExtraForCreate 为新建的 OpenAI OAuth-like 账号补齐系统种子和 +// Codex 客户端身份:种子始终由系统铸造(用户提交的值被剥离),身份缺失时按种子派生。 func prepareCodexFingerprintExtraForCreate(platform, accountType string, extra map[string]any) map[string]any { prepared := stripCodexFingerprintSeed(extra) - if platform != PlatformOpenAI || (accountType != AccountTypeOAuth && accountType != AccountTypeSetupToken) || !codexFingerprintModeRequiresSeed(codexFingerprintModeFromExtra(prepared)) { + if platform != PlatformOpenAI || (accountType != AccountTypeOAuth && accountType != AccountTypeSetupToken) { return prepared } if prepared == nil { - prepared = make(map[string]any, 1) + prepared = make(map[string]any, 2) } prepared[codexFingerprintSeedExtraKey] = newCodexFingerprintSeed() - return prepared + // 身份是系统管理字段:不接受请求带入的值,只按新种子派生。 + delete(prepared, CodexClientIdentityExtraKey) + return ensureCodexClientIdentityExtra(platform, accountType, prepared, time.Now()) } +// prepareCodexFingerprintExtraForUpdate 在整体覆盖 extra 时保留系统种子与已持久化的 +// Codex 客户端身份(两者都不由管理端表单提交),缺失时补齐。 func prepareCodexFingerprintExtraForUpdate(account *Account, extra map[string]any) map[string]any { prepared := stripCodexFingerprintSeed(extra) if account == nil || !account.IsOpenAIOAuthLike() { return prepared } + if prepared == nil { + prepared = make(map[string]any, 2) + } if seed, ok := codexFingerprintSeed(account.Extra); ok { - if prepared == nil { - prepared = make(map[string]any, 1) - } prepared[codexFingerprintSeedExtraKey] = seed - return prepared - } - if codexFingerprintModeRequiresSeed(codexFingerprintModeFromExtra(prepared)) { - if prepared == nil { - prepared = make(map[string]any, 1) - } + } else { prepared[codexFingerprintSeedExtraKey] = newCodexFingerprintSeed() } - return prepared + // 身份是系统管理字段:忽略表单带回的值(可能过期或被篡改),保留库中已持久化的 + // 合法身份;没有则按种子派生。派生确定性,与启动回填并发也只会写出同一个值。 + delete(prepared, CodexClientIdentityExtraKey) + if _, ok := codexClientIdentityFromExtra(account.Extra); ok { + prepared[CodexClientIdentityExtraKey] = account.Extra[CodexClientIdentityExtraKey] + } + return ensureCodexClientIdentityExtra(account.Platform, account.Type, prepared, time.Now()) } func sanitizedCodexFingerprintExtraUpdates(updates map[string]any) map[string]any { @@ -178,17 +198,23 @@ func sanitizedCodexFingerprintExtraUpdates(updates map[string]any) map[string]an } sanitized := maps.Clone(updates) delete(sanitized, codexFingerprintSeedExtraKey) + delete(sanitized, CodexClientIdentityExtraKey) return sanitized } // ShouldEnsureCodexFingerprintSeedForExtraUpdates reports whether a JSONB key-level // extra update is enabling Codex fingerprint convergence and therefore must atomically // preserve or create the system-managed per-account seed in the repository update. +// ShouldEnsureCodexFingerprintSeedForExtraUpdates reports whether a JSONB key-level +// extra update is enabling Codex fingerprint convergence and therefore must atomically +// preserve or create the system-managed per-account seed in the repository update. +// Only an explicit device / session / full value counts; a missing key is not "device". func ShouldEnsureCodexFingerprintSeedForExtraUpdates(updates map[string]any) bool { - if updates == nil { + mode, ok := codexFingerprintModeExplicit(updates) + if !ok { return false } - return codexFingerprintModeRequiresSeed(codexFingerprintModeFromExtra(updates)) + return codexFingerprintModeRequiresSeed(mode) } // GetCodexFingerprintMode 从账号 extra JSON 读取指纹收敛模式。 @@ -269,6 +295,7 @@ type codexFingerprintIDs struct { threadID string turnID string windowID string + sandbox string turnStartedAtUnixMs int64 originalBodySessionID string originalBodySessionIDCaptured bool @@ -293,6 +320,9 @@ func resolveCodexFingerprintIDs(account *Account, clientSessionID string, mode c mode: mode, turnStartedAtUnixMs: time.Now().UnixMilli(), } + if identity, ok := account.CodexClientIdentity(); ok { + ids.sandbox = identity.Sandbox + } ids.installationID = resolveConvergedInstallationID(account, seed) if ids.installationID == "" { @@ -359,32 +389,54 @@ func applyCodexFingerprintHeaders(h http.Header, ids *codexFingerprintIDs) { return } - // 所有非 off 模式都收敛 installation_id - h.Set("x-codex-installation-id", ids.installationID) + // 真实 Codex 从不以请求头形式发送 installation id(只在 body client_metadata 中), + // 收敛后的值写入 turn metadata / client_metadata,头上一律不带。 + h.Del("x-codex-installation-id") if ids.mode == codexFingerprintDevice { - rewriteCodexTurnMetadataFields(h, map[string]any{ + rewriteCodexTurnMetadataFields(h, ids.turnMetadataFields(map[string]any{ "installation_id": ids.installationID, - }) + })) return } - // session / full 模式:改写所有相关头 + // session / full 模式:改写所有相关头。真实 Codex 只发连字符形式;下划线形式 + // 只在调用方(legacy compact 桥接)已经写入时同步改写,不主动添加。 h.Set("x-codex-window-id", ids.windowID) h.Set("x-client-request-id", ids.threadID) - // 连字符形式和下划线形式都改写,保证一致 h.Set("session-id", ids.sessionID) - h.Set("session_id", ids.sessionID) h.Set("thread-id", ids.threadID) + if h.Get("session_id") != "" { + h.Set("session_id", ids.sessionID) + } - rewriteCodexTurnMetadataFields(h, map[string]any{ + rewriteCodexTurnMetadataFields(h, ids.turnMetadataFields(map[string]any{ "installation_id": ids.installationID, "session_id": ids.sessionID, "thread_id": ids.threadID, "turn_id": ids.turnID, "window_id": ids.windowID, "turn_started_at_unix_ms": ids.turnStartedAtUnixMs, - }) + })) +} + +// alignSandboxWithUserAgent 让 sandbox 跟随最终出站 UA 声明的系统(管理员显式 UA +// 覆盖时可能与账号身份不同)。UA 无法解析时不改写 sandbox。 +func (ids *codexFingerprintIDs) alignSandboxWithUserAgent(userAgent string) { + if ids == nil { + return + } + ids.sandbox = codexSandboxForUserAgent(userAgent) +} + +// turnMetadataFields 在身份字段之外补上与出站 UA 配套的 sandbox 标签,使 turn +// metadata 声明的沙箱与 User-Agent 声明的操作系统一致(seatbelt=macOS、 +// seccomp=Linux、windows_sandbox=Windows)。 +func (ids *codexFingerprintIDs) turnMetadataFields(fields map[string]any) map[string]any { + if ids != nil && ids.sandbox != "" { + fields["sandbox"] = ids.sandbox + } + return fields } // rewriteCodexTurnMetadataFields 解析 x-codex-turn-metadata 头中的 JSON, @@ -449,9 +501,9 @@ func applyCodexFingerprintToClientMetadataMap(existing map[string]any, ids *code } if ids.mode == codexFingerprintDevice { - rewriteClientMetadataEmbeddedTurnMetadata(existing, map[string]any{ + rewriteClientMetadataEmbeddedTurnMetadata(existing, ids.turnMetadataFields(map[string]any{ "installation_id": ids.installationID, - }) + })) return modified } @@ -461,14 +513,14 @@ func applyCodexFingerprintToClientMetadataMap(existing map[string]any, ids *code existing["turn_id"] = ids.turnID existing["x-codex-window-id"] = ids.windowID - rewriteClientMetadataEmbeddedTurnMetadata(existing, map[string]any{ + rewriteClientMetadataEmbeddedTurnMetadata(existing, ids.turnMetadataFields(map[string]any{ "installation_id": ids.installationID, "session_id": ids.sessionID, "thread_id": ids.threadID, "turn_id": ids.turnID, "window_id": ids.windowID, "turn_started_at_unix_ms": ids.turnStartedAtUnixMs, - }) + })) return true } diff --git a/backend/internal/service/openai_codex_fingerprint_test.go b/backend/internal/service/openai_codex_fingerprint_test.go index ce4c7cee114d..922d33f6ce8d 100644 --- a/backend/internal/service/openai_codex_fingerprint_test.go +++ b/backend/internal/service/openai_codex_fingerprint_test.go @@ -66,11 +66,11 @@ func TestGetCodexFingerprintMode(t *testing.T) { {"非 OAuth 账号", &Account{Platform: PlatformOpenAI, Type: "api_key"}, codexFingerprintOff}, {"OpenAI setup token", &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Extra: map[string]any{codexFingerprintModeExtraKey: "session"}}, codexFingerprintSession}, {"Anthropic setup token", &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken, Extra: map[string]any{codexFingerprintModeExtraKey: "session"}}, codexFingerprintOff}, - // 收敛是显式 opt-in:缺省/空/非法一律 off(#5610)。存量账号普遍没有这个 - // extra 键,升级不得把它们静默切进收敛。 - {"无 extra 默认 off", newTestOAuthAccount(1, nil), codexFingerprintOff}, - {"空值默认 off", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: ""}), codexFingerprintOff}, - {"非法值默认 off", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "invalid"}), codexFingerprintOff}, + // 缺省/空/非法一律 device:一个 OAuth 账号对上游只表现为一台设备; + // 显式 off 仍然生效。 + {"无 extra 默认 device", newTestOAuthAccount(1, nil), codexFingerprintDevice}, + {"空值默认 device", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: ""}), codexFingerprintDevice}, + {"非法值默认 device", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "invalid"}), codexFingerprintDevice}, {"显式 off", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "off"}), codexFingerprintOff}, {"device", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "device"}), codexFingerprintDevice}, {"session", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "session"}), codexFingerprintSession}, @@ -133,9 +133,12 @@ func TestResolveCodexFingerprintIDsFromRequest_ExplicitOff(t *testing.T) { // 未显式配置的存量账号不得被收敛(#5610):默认返回 nil,出站身份保持 // v0.1.175 之前的客户端原值。 -func TestResolveCodexFingerprintIDsFromRequest_DefaultIsOff(t *testing.T) { +func TestResolveCodexFingerprintIDsFromRequest_DefaultIsDevice(t *testing.T) { account := newTestOAuthAccount(1, nil) - assert.Nil(t, resolveCodexFingerprintIDsFromRequest(account, nil), "无 extra 应视为 off") + ids := resolveCodexFingerprintIDsFromRequest(account, nil) + require.NotNil(t, ids, "无 extra 应视为 device") + assert.Equal(t, codexFingerprintDevice, ids.mode) + assert.NotEmpty(t, ids.installationID) } // 管理员显式 opt-in 的账号行为不变。 @@ -199,14 +202,16 @@ func TestApplyCodexFingerprintHeaders_DeviceMode(t *testing.T) { ids := resolveCodexFingerprintIDsFromRequest(account, nil) applyCodexFingerprintHeaders(h, ids) - assert.Equal(t, "converged-device", h.Get("x-codex-installation-id"), "installation_id 应收敛") + assert.Empty(t, h.Get("x-codex-installation-id"), "installation id 只在 body client_metadata,不以头形式发送") assert.Equal(t, "user-window:0", h.Get("x-codex-window-id"), "device 模式不改写 window_id") + identity, ok := account.CodexClientIdentity() + require.True(t, ok) var meta map[string]any require.NoError(t, json.Unmarshal([]byte(h.Get("x-codex-turn-metadata")), &meta)) assert.Equal(t, "converged-device", meta["installation_id"]) assert.Equal(t, "user-session", meta["session_id"], "device 模式不改写 session_id") - assert.Equal(t, "seccomp", meta["sandbox"], "非指纹字段保留原样") + assert.Equal(t, identity.Sandbox, meta["sandbox"], "sandbox 跟随账号身份的操作系统") } // --- applyCodexFingerprintHeaders: session 模式 --- @@ -234,20 +239,22 @@ func TestApplyCodexFingerprintHeaders_SessionMode(t *testing.T) { convergedSession := resolveConvergedSessionID(seed) convergedThread := resolveConvergedThreadID(seed, "client-session-aaa") - assert.Equal(t, convergedInstall, h.Get("x-codex-installation-id")) + assert.Empty(t, h.Get("x-codex-installation-id"), "installation id 只在 body client_metadata,不以头形式发送") assert.Equal(t, convergedSession, h.Get("session-id")) - assert.Equal(t, convergedSession, h.Get("session_id"), "下划线形式也应被改写") + assert.Empty(t, h.Get("session_id"), "不主动添加下划线形式") assert.Equal(t, convergedThread, h.Get("thread-id")) assert.Equal(t, convergedThread, h.Get("x-client-request-id")) assert.Equal(t, convergedThread+":0", h.Get("x-codex-window-id")) + identity, ok := account.CodexClientIdentity() + require.True(t, ok) var meta map[string]any require.NoError(t, json.Unmarshal([]byte(h.Get("x-codex-turn-metadata")), &meta)) assert.Equal(t, convergedInstall, meta["installation_id"]) assert.Equal(t, convergedSession, meta["session_id"]) assert.Equal(t, convergedThread, meta["thread_id"]) assert.NotEqual(t, "user-turn", meta["turn_id"], "turn_id 应被新生成的值替换") - assert.Equal(t, "seccomp", meta["sandbox"], "sandbox 保留原样") + assert.Equal(t, identity.Sandbox, meta["sandbox"], "sandbox 跟随账号身份的操作系统") assert.Equal(t, "user", meta["thread_source"], "thread_source 保留原样") } @@ -445,7 +452,9 @@ func TestApplyCodexFingerprintClientMetadata_DeviceMode(t *testing.T) { var meta map[string]any require.NoError(t, json.Unmarshal([]byte(turnMetaStr), &meta)) assert.Equal(t, "converged-device", meta["installation_id"]) - assert.Equal(t, "seccomp", meta["sandbox"], "非指纹字段保留原样") + identity, ok := account.CodexClientIdentity() + require.True(t, ok) + assert.Equal(t, identity.Sandbox, meta["sandbox"], "sandbox 跟随账号身份的操作系统") } func TestApplyCodexFingerprintClientMetadata_SessionMode(t *testing.T) { @@ -489,7 +498,9 @@ func TestApplyCodexFingerprintClientMetadata_SessionMode(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(turnMetaStr), &meta)) assert.Equal(t, convergedInstall, meta["installation_id"]) assert.Equal(t, convergedSession, meta["session_id"]) - assert.Equal(t, "seccomp", meta["sandbox"], "非指纹字段保留原样") + identity, ok := account.CodexClientIdentity() + require.True(t, ok) + assert.Equal(t, identity.Sandbox, meta["sandbox"], "sandbox 跟随账号身份的操作系统") } func TestApplyCodexFingerprintClientMetadata_FullMode(t *testing.T) { @@ -886,14 +897,17 @@ func TestBuildUpstreamRequestOpenAIPassthrough_AppliesStagedFingerprint(t *testi req, err := svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, account, body, "test-token") require.NoError(t, err) - assert.Equal(t, ids.sessionID, req.Header.Get("session_id"), "session 模式下出站 session_id 应为账号级收敛值") - assert.Equal(t, ids.installationID, req.Header.Get("x-codex-installation-id")) + assert.Equal(t, ids.sessionID, req.Header.Get("session-id"), "session 模式下出站 session-id 应为账号级收敛值") + assert.Empty(t, req.Header.Get("session_id"), "真实 Codex 不发下划线 session_id") + assert.Empty(t, req.Header.Get("x-codex-installation-id"), "installation id 只在 body client_metadata") assert.Equal(t, ids.windowID, req.Header.Get("x-codex-window-id")) assert.Equal(t, ids.threadID, req.Header.Get("x-client-request-id")) turnMetadata := req.Header.Get("x-codex-turn-metadata") require.NotEmpty(t, turnMetadata) assert.Contains(t, turnMetadata, ids.sessionID, "turn-metadata JSON 中的 session_id 应被收敛") - assert.Contains(t, turnMetadata, `"sandbox":"seatbelt"`, "turn-metadata 未指定字段应原样保留") + identity, ok := account.CodexClientIdentity() + require.True(t, ok) + assert.Contains(t, turnMetadata, `"sandbox":"`+identity.Sandbox+`"`, "turn-metadata 的 sandbox 跟随账号身份") } func TestBuildUpstreamRequestOpenAIPassthrough_OffModeKeepsIsolatedSession(t *testing.T) { @@ -915,8 +929,8 @@ func TestBuildUpstreamRequestOpenAIPassthrough_OffModeKeepsIsolatedSession(t *te req, err := svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, account, body, "test-token") require.NoError(t, err) - assert.NotEmpty(t, req.Header.Get("session_id")) - assert.NotEqual(t, resolveConvergedSessionID(testCodexFingerprintSeed), req.Header.Get("session_id"), "off 模式不得收敛 session_id") + assert.Empty(t, req.Header.Get("session_id"), "真实 Codex 不发下划线 session_id") + assert.NotEqual(t, resolveConvergedSessionID(testCodexFingerprintSeed), req.Header.Get("session-id"), "off 模式不得收敛 session id") assert.Empty(t, req.Header.Get("x-codex-window-id")) } diff --git a/backend/internal/service/openai_codex_identity.go b/backend/internal/service/openai_codex_identity.go index 09d1717c9adf..b7c8325baf54 100644 --- a/backend/internal/service/openai_codex_identity.go +++ b/backend/internal/service/openai_codex_identity.go @@ -248,3 +248,43 @@ func pairCodexIdentityHeaders(h http.Header) { h.Set("version", resolveCodexOutboundIdentity("").version) } } + +// resolveCodexOutboundIdentityForAccount 返回某个 OAuth 账号的出站身份三元组。 +// 优先级:管理员显式配置的账号级 User-Agent(只贡献客户端名与 OS / 架构 / 终端指纹, +// 版本段仍由生效版本重建)> 账号持久化 / 种子派生的 Codex TUI 身份 > 全局规范身份。 +// 版本号三处同源:UA 首段、UA 尾部括号组与 version 头都取当前生效的官方版本。 +func resolveCodexOutboundIdentityForAccount(account *Account, overrideUA string) codexOutboundIdentity { + if strings.TrimSpace(overrideUA) != "" { + return resolveCodexOutboundIdentity(overrideUA) + } + canonical := resolveCodexOutboundIdentity("") + if account == nil { + return canonical + } + identity, ok := account.CodexClientIdentity() + if !ok { + return canonical + } + return codexOutboundIdentity{ + userAgent: identity.UserAgent(canonical.version), + originator: codexTUIOriginator, + version: canonical.version, + } +} + +// enforceCodexIdentityHeadersForAccount 与 enforceCodexIdentityHeadersWithUA 语义相同, +// 但强制统一时使用账号级身份而不是全局规范身份,使同一账号的所有出站请求 +// 表现为同一台机器上的同一个 Codex TUI。 +func enforceCodexIdentityHeadersForAccount(h http.Header, account *Account, overrideUA string) { + if h == nil || h.Get("originator") == "" { + return + } + if !codexIdentityEnforcement.Load() { + pairCodexIdentityHeaders(h) + return + } + identity := resolveCodexOutboundIdentityForAccount(account, overrideUA) + h.Set("user-agent", identity.userAgent) + h.Set("originator", identity.originator) + h.Set("version", identity.version) +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 83b6ce8ae868..8655ba4bf719 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -1769,10 +1769,11 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc } headers.Set("Accept", "application/json") overrideUA := "" + identity := resolveCodexOutboundIdentity(overrideUA) if !useAPIKeyUpstream { overrideUA = credAccount.GetOpenAIUserAgent() + identity = resolveCodexOutboundIdentityForAccount(credAccount, overrideUA) } - identity := resolveCodexOutboundIdentity(overrideUA) headers.Set("Originator", identity.originator) headers.Set("User-Agent", identity.userAgent) // Version 头优先与 client_version 查询参数同源:客户端自报版本合法且不低于上游 diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index 3652403cda9a..fbf374ab99c6 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -1101,7 +1101,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")) - requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, openai.CodexDefaultOriginator) + requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) 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() @@ -1115,7 +1115,7 @@ 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(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session_id")) require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) - requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, openai.CodexDefaultOriginator) + requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) } @@ -1166,7 +1166,7 @@ func TestForwardAsAnthropic_OAuthRestoresCodexIdentityHeaders(t *testing.T) { result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4") require.NoError(t, err) require.NotNil(t, result) - requireOpenAIMessagesCodexIdentity(t, upstream.lastReq, codexCLIUserAgent, openai.CodexDefaultOriginator) + requireOpenAIMessagesCodexIdentity(t, upstream.lastReq, resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) }) } } @@ -1209,7 +1209,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, openai.CodexDefaultOriginator) + requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) 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}`) @@ -1224,7 +1224,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, openai.CodexDefaultOriginator) + requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) } @@ -1379,7 +1379,7 @@ func TestForwardAsAnthropic_OAuthKeepsSystemAsDeveloperInput(t *testing.T) { instructions := gjson.GetBytes(upstream.lastBody, "instructions") require.True(t, instructions.Exists()) require.Empty(t, instructions.String()) - requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, openai.CodexDefaultOriginator) + requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) } func TestForwardAsAnthropic_OAuthAddsClaudeCodeTodoGuardForCompatModel(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 9951d6a47e23..c72d6ec52fa6 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -597,6 +597,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco clientHeaders = c.Request.Header } fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) + fpIDs.alignSandboxWithUserAgent(resolveCodexOutboundIdentityForAccount(account, s.codexIdentityOverrideUA(account)).userAgent) if fpIDs != nil { if applyCodexFingerprintClientMetadata(decoded, fpIDs) { markDecodedModified() @@ -1578,8 +1579,8 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. s.guardOpenAICodexTurnStateEcho(c, account, req.Header) if account.UsesOpenAICodexProtocol() { compatMessagesBridge := isOpenAICompatMessagesBridgeContext(c) || isOpenAICompatMessagesBridgeBody(body) - // 清除客户端透传的 session 头,后续用隔离后的值重新设置,防止跨用户会话碰撞。 - clientConversationID := strings.TrimSpace(req.Header.Get("conversation_id")) + // 真实 Codex 只发送连字符形式的 session-id / thread-id;下划线形式的 + // session_id / conversation_id 只在 legacy compact 桥接上保留,其余路径一律清除。 req.Header.Del("conversation_id") req.Header.Del("session_id") @@ -1600,13 +1601,6 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. } else { req.Header.Set("accept", "text/event-stream") } - if promptCacheKey != "" { - isolated := isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), promptCacheKey) - req.Header.Set("session_id", isolated) - if !compatMessagesBridge || clientConversationID != "" { - req.Header.Set("conversation_id", isolated) - } - } } else if isOpenAIResponsesCompactPath(c) { // compact 上游是 unary JSON 协议:API-key 账号也显式声明 Accept, // 避免 OpenAI 兼容网关按 SSE 返回(#3777 期望行为 4)。 @@ -1628,11 +1622,16 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) // 指纹收敛:使用 Forward() 中预计算的收敛 ID 改写出站头,与请求体使用同一份 IDs。 applyStagedCodexFingerprintHeaders(c, account, req.Header) + // 真实 Codex 必带的连字符会话头:入站缺失时以最终请求体的 prompt_cache_key + // (已作用域改写,与 client_metadata.session_id 同源)补齐,保持 root 线程不变式。 + if account.UsesOpenAICodexProtocol() && !isOpenAIResponsesCompactPath(c) { + ensureCodexSessionIdentityHeaders(req.Header, gjson.GetBytes(body, "prompt_cache_key").String()) + } - // 终态收口:强制统一 OAuth 出站身份(User-Agent / originator / version 同源自洽)。 - // 客户端自报身份不参与构造,浏览器型 UA 也因此不会再到达上游(原浏览器 UA 兜底已被吸收)。 + // 终态收口:强制统一 OAuth 出站身份(User-Agent / originator / version 同源自洽), + // 身份取自该 OAuth 凭据的账号级 Codex TUI 身份;客户端自报身份不参与构造。 if account.UsesOpenAICodexProtocol() { - enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) + enforceCodexIdentityHeadersForAccount(req.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)) } // Ensure required headers exist diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 69026f7fee22..b32b72d56d93 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -396,7 +396,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // 清除身份头。真正发送前恢复完整 Codex 身份,避免 ChatGPT Codex 上游因缺失 // originator/OpenAI-Beta 返回 404(issue #3901)。 ensureCodexIdentityHeaders(upstreamReq.Header) - enforceCodexIdentityHeaders(upstreamReq.Header) + enforceCodexIdentityHeadersForAccount(upstreamReq.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)) logger.L().Debug("openai messages: upstream identity restored", zap.Int64("account_id", account.ID), zap.String("upstream_model", upstreamModel), diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 8abd95ea1ab0..90613514a9ba 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -234,6 +234,16 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } reqStream = gjson.GetBytes(body, "stream").Bool() + // 账号作用域:body client_metadata / prompt_cache_key 与出站头共用同一映射, + // 否则头已按账号改写而 body 仍是客户端原值,同一请求会暴露两套身份。 + accountScopedBody, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(body, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + if scopeErr != nil { + return nil, scopeErr + } + if accountScoped { + body = accountScopedBody + } + stageCodexFingerprintIDs(c, nil) // 指纹收敛:与非透传路径同门控(仅 OAuth、legacy compact 形态跳过)。 // 一次性解析收敛 ID:请求体 client_metadata 在此改写(raw 字节外科 @@ -245,6 +255,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( clientHeaders = c.Request.Header } fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) + fpIDs.alignSandboxWithUserAgent(resolveCodexOutboundIdentityForAccount(account, s.codexIdentityOverrideUA(account)).userAgent) if fpIDs != nil { fpBody, fpChanged, fpErr := applyCodexFingerprintClientMetadataRaw(body, fpIDs) if fpErr != nil { @@ -859,11 +870,18 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if clientConversationID == "" { clientConversationID = promptCacheKey } - if clientSessionID != "" { - req.Header.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), clientSessionID)) - } - if clientConversationID != "" { - req.Header.Set("conversation_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), clientConversationID)) + if isOpenAIResponsesCompactPath(c) { + if clientSessionID != "" { + req.Header.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), clientSessionID)) + } + if clientConversationID != "" { + req.Header.Set("conversation_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), clientConversationID)) + } + } else { + // 真实 Codex 从不发送下划线形式的 session_id / conversation_id; + // 非 compact 路径只保留连字符身份头。 + req.Header.Del("session_id") + req.Header.Del("conversation_id") } } else if isOpenAIResponsesCompactPath(c) { // 透传白名单会放行客户端的 Accept: text/event-stream;compact 上游是 @@ -885,10 +903,13 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( // 与请求体 client_metadata 共享同一份 IDs(与非透传路径相同的相对位置: // 会话隔离之后、终态身份收口之前)。 applyStagedCodexFingerprintHeaders(c, account, req.Header) - // 终态收口:透传路径的 OAuth 与非透传完全一致,同样强制统一出站身份 + if account.UsesOpenAICodexProtocol() && !isOpenAIResponsesCompactPath(c) { + ensureCodexSessionIdentityHeaders(req.Header, gjson.GetBytes(body, "prompt_cache_key").String()) + } + // 终态收口:透传路径的 OAuth 与非透传完全一致,同样强制统一为该账号的 Codex TUI 身份 // (User-Agent / originator / version 同源自洽),客户端自报身份不会到达上游。 if account.UsesOpenAICodexProtocol() { - enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) + enforceCodexIdentityHeadersForAccount(req.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)) } if req.Header.Get("content-type") == "" { diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 6267c4fa11cb..1c4be9095a76 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -72,37 +72,41 @@ 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-installation-id": true, - "x-codex-turn-state": true, - "x-codex-turn-metadata": true, - "x-codex-window-id": true, - responsesLiteHeaderKey: true, + "accept-language": true, + "content-type": true, + "conversation_id": true, + "user-agent": true, + "originator": true, + "session_id": true, + "session-id": true, + "thread-id": true, + "x-client-request-id": true, + "x-codex-beta-features": 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-installation-id": true, - "x-codex-turn-state": true, - "x-codex-turn-metadata": true, - "x-codex-window-id": 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, + "session-id": true, + "thread-id": true, + "x-client-request-id": true, + "x-codex-beta-features": 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 516872b0b8f4..1945f9d11724 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -3151,7 +3151,7 @@ func TestOpenAIBuildUpstreamRequestOAuthMessagesBridgeUsesSessionOnly(t *testing req, err := svc.buildUpstreamRequest(c.Request.Context(), c, account, body, "token", true, "anthropic-metadata-session-1", false) require.NoError(t, err) - require.NotEmpty(t, req.Header.Get("Session_Id")) + require.Empty(t, req.Header.Get("Session_Id"), "真实 Codex 不发下划线 session_id") require.Empty(t, req.Header.Get("Conversation_Id")) require.Empty(t, req.Header.Get("OpenAI-Beta")) require.Empty(t, req.Header.Get("originator")) @@ -3200,7 +3200,7 @@ func TestOpenAIBuildUpstreamRequestPreservesCodexIdentityHeaders(t *testing.T) { 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-Codex-Installation-ID"), "installation id 只在 body client_metadata") require.Empty(t, req.Header.Get("X-Test")) require.True(t, openai.EvaluateEngineFingerprint(req.Header, body, openai.DefaultEngineFingerprintSignals)) } diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index edb018a93efa..e3f626943bfe 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -330,7 +330,7 @@ func TestOpenAIGatewayService_OAuthMessagesBridgeDoesNotInjectDefaultInstruction require.NotNil(t, upstream.lastReq) require.Equal(t, "", gjson.GetBytes(upstream.lastBody, "instructions").String()) require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").Exists()) - require.NotEmpty(t, upstream.lastReq.Header.Get("Session_Id")) + require.Empty(t, upstream.lastReq.Header.Get("Session_Id"), "真实 Codex 不发下划线 session_id") require.Empty(t, upstream.lastReq.Header.Get("Conversation_Id")) require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta")) require.Empty(t, upstream.lastReq.Header.Get("originator")) @@ -542,8 +542,8 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali // 2) only auth is replaced; inbound auth/cookie are not forwarded require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("Authorization")) - // 强制统一出口:客户端自报的 codex_cli_rs/0.1.0 不会到达上游。 - require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent")) + // 强制统一出口:客户端自报的 codex_cli_rs/0.1.0 不会到达上游,出站是该账号的 Codex TUI 身份。 + require.Equal(t, resolveCodexOutboundIdentityForAccount(account, "").userAgent, upstream.lastReq.Header.Get("User-Agent")) require.Empty(t, upstream.lastReq.Header.Get("Cookie")) require.Empty(t, upstream.lastReq.Header.Get("X-Api-Key")) require.Empty(t, upstream.lastReq.Header.Get("X-Goog-Api-Key")) @@ -1259,9 +1259,9 @@ func TestOpenAIGatewayService_OAuthLegacy_CompositeCodexUAUsesCodexOriginator(t _, err := svc.Forward(context.Background(), c, account, inputBody) require.NoError(t, err) require.NotNil(t, upstream.lastReq) - // 浏览器型复合 UA 被替换为默认 Codex TUI UA, + // 浏览器型复合 UA 被替换为该账号的 Codex TUI 身份 UA, // originator 随最终 UA 配套(issue #3901)。 - require.Equal(t, DefaultOpenAICodexUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, resolveCodexOutboundIdentityForAccount(account, "").userAgent, upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("originator")) require.NotEqual(t, "opencode", upstream.lastReq.Header.Get("originator")) } @@ -2515,7 +2515,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_NonCodexUAFallbackToCodexUA(t *te require.NoError(t, err) require.Equal(t, false, gjson.GetBytes(upstream.lastBody, "store").Bool()) require.Equal(t, true, gjson.GetBytes(upstream.lastBody, "stream").Bool()) - require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, resolveCodexOutboundIdentityForAccount(account, "").userAgent, upstream.lastReq.Header.Get("User-Agent")) } // 透传模式的 OAuth 与非透传一致:官方客户端身份同样被强制统一为网关规范身份, @@ -2563,7 +2563,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_OfficialIdentityUnified(t *testin _, err := svc.Forward(context.Background(), c, account, inputBody) require.NoError(t, err) require.NotNil(t, upstream.lastReq) - require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, resolveCodexOutboundIdentityForAccount(account, "").userAgent, upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("originator")) require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("version")) } @@ -2607,7 +2607,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_CodexTuiIdentityUnified(t *testin _, err := svc.Forward(context.Background(), c, account, inputBody) require.NoError(t, err) require.NotNil(t, upstream.lastReq) - require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, resolveCodexOutboundIdentityForAccount(account, "").userAgent, upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("originator")) require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("version")) } @@ -2652,9 +2652,10 @@ func TestOpenAIGatewayService_CodexFingerprintHTTPTransformedHeaderBodyParityAnd wantSession := resolveConvergedSessionID(seed) wantThread := resolveConvergedThreadID(seed, "header-session") - require.Equal(t, wantInstall, upstream.lastReq.Header.Get("x-codex-installation-id")) + require.Empty(t, upstream.lastReq.Header.Get("x-codex-installation-id"), "installation id 只在 body client_metadata") + require.Equal(t, wantInstall, gjson.GetBytes(upstream.lastBody, "client_metadata.x-codex-installation-id").String()) require.Equal(t, wantSession, upstream.lastReq.Header.Get("session-id")) - require.Equal(t, wantSession, upstream.lastReq.Header.Get("session_id")) + require.Empty(t, upstream.lastReq.Header.Get("session_id"), "真实 Codex 不发下划线 session_id") require.Equal(t, wantThread, upstream.lastReq.Header.Get("thread-id")) require.Equal(t, wantThread, upstream.lastReq.Header.Get("x-client-request-id")) require.Equal(t, wantThread+":0", upstream.lastReq.Header.Get("x-codex-window-id")) @@ -2714,9 +2715,10 @@ func TestOpenAIGatewayService_CodexFingerprintHTTPRawPassthroughHeaderBodyParity wantSession := resolveConvergedSessionID(seed) wantThread := resolveConvergedThreadID(seed, "header-session") - require.Equal(t, wantInstall, upstream.lastReq.Header.Get("x-codex-installation-id")) + require.Empty(t, upstream.lastReq.Header.Get("x-codex-installation-id"), "installation id 只在 body client_metadata") + require.Equal(t, wantInstall, gjson.GetBytes(upstream.lastBody, "client_metadata.x-codex-installation-id").String()) require.Equal(t, wantSession, upstream.lastReq.Header.Get("session-id")) - require.Equal(t, wantSession, upstream.lastReq.Header.Get("session_id")) + require.Empty(t, upstream.lastReq.Header.Get("session_id"), "真实 Codex 不发下划线 session_id") require.Equal(t, wantThread, upstream.lastReq.Header.Get("thread-id")) require.Equal(t, wantThread, upstream.lastReq.Header.Get("x-client-request-id")) require.Equal(t, wantThread+":0", upstream.lastReq.Header.Get("x-codex-window-id")) @@ -2815,7 +2817,8 @@ func TestOpenAIGatewayService_CodexFingerprintMessagesBridgeDoesNotInjectBodyPro wantSession := resolveConvergedSessionID(seed) require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").Exists()) require.Equal(t, wantSession, gjson.GetBytes(upstream.lastBody, "client_metadata.session_id").String()) - require.Equal(t, wantSession, upstream.lastReq.Header.Get("session_id")) + require.Equal(t, wantSession, upstream.lastReq.Header.Get("session-id")) + require.Empty(t, upstream.lastReq.Header.Get("session_id")) } func TestOpenAIGatewayService_CodexCLIOnly_RejectsNonCodexClient(t *testing.T) { @@ -3076,7 +3079,7 @@ func TestOpenAIGatewayService_APIKeyPassthrough_PreservesBodyAndUsesResponsesEnd 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-Codex-Installation-ID"), "installation id 只在 body client_metadata") require.Empty(t, upstream.lastReq.Header.Get("X-Test")) } diff --git a/backend/internal/service/openai_oauth_service.go b/backend/internal/service/openai_oauth_service.go index 5eef594e3334..574be080a3cb 100644 --- a/backend/internal/service/openai_oauth_service.go +++ b/backend/internal/service/openai_oauth_service.go @@ -214,7 +214,21 @@ func (s *OpenAIOAuthService) RefreshToken(ctx context.Context, refreshToken stri // RefreshTokenWithClientID refreshes an OpenAI OAuth token with optional client_id. func (s *OpenAIOAuthService) RefreshTokenWithClientID(ctx context.Context, refreshToken string, proxyURL string, clientID string) (*OpenAITokenInfo, error) { - tokenResp, err := s.oauthClient.RefreshTokenWithClientID(ctx, refreshToken, proxyURL, clientID) + return s.refreshTokenWithIdentity(ctx, refreshToken, proxyURL, clientID, codexOutboundIdentity{}) +} + +// refreshTokenWithIdentity 用给定的 Codex 出站身份刷新 token:已有账号刷新时使用与推理 +// 一致的账号级身份(真实 Codex 客户端以自己的 UA/originator 刷新);身份为空或客户端 +// 不支持时退回全局规范身份。 +func (s *OpenAIOAuthService) refreshTokenWithIdentity(ctx context.Context, refreshToken, proxyURL, clientID string, identity codexOutboundIdentity) (*OpenAITokenInfo, error) { + var tokenResp *openai.TokenResponse + var err error + refresher, ok := s.oauthClient.(OpenAIOAuthIdentityRefresher) + if ok && strings.TrimSpace(identity.userAgent) != "" && strings.TrimSpace(identity.originator) != "" { + tokenResp, err = refresher.RefreshTokenWithIdentity(ctx, refreshToken, proxyURL, clientID, identity.userAgent, identity.originator) + } else { + tokenResp, err = s.oauthClient.RefreshTokenWithClientID(ctx, refreshToken, proxyURL, clientID) + } if err != nil { return nil, err } @@ -386,7 +400,8 @@ func (s *OpenAIOAuthService) RefreshAccountToken(ctx context.Context, account *A } clientID := account.GetCredential("client_id") - return s.RefreshTokenWithClientID(ctx, refreshToken, proxyURL, clientID) + identity := resolveCodexOutboundIdentityForAccount(account, account.GetOpenAIUserAgent()) + return s.refreshTokenWithIdentity(ctx, refreshToken, proxyURL, clientID, identity) } // BuildAccountCredentials builds credentials map from token info diff --git a/backend/internal/service/openai_setup_token_compat_test.go b/backend/internal/service/openai_setup_token_compat_test.go index 09dd2dae787e..5dac97a26a15 100644 --- a/backend/internal/service/openai_setup_token_compat_test.go +++ b/backend/internal/service/openai_setup_token_compat_test.go @@ -212,7 +212,7 @@ func TestOpenAISetupTokenMessagesUsesCodexBridgeAndTurnState(t *testing.T) { require.Equal(t, chatgptCodexURL, upstream.requests[0].URL.String()) require.Equal(t, "Bearer setup-token-value", upstream.requests[0].Header.Get("Authorization")) require.Equal(t, "chatgpt-setup", upstream.requests[0].Header.Get("chatgpt-account-id")) - requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, "codex-tui") + requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], resolveCodexOutboundIdentityForAccount(account, "").userAgent, "codex-tui") require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state")) secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"next"}],"stream":false}`) @@ -229,7 +229,7 @@ func TestOpenAISetupTokenMessagesUsesCodexBridgeAndTurnState(t *testing.T) { require.Equal(t, "turn_state_setup", upstream.requests[1].Header.Get("x-codex-turn-state")) require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session_id")) require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) - requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, "codex-tui") + requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], resolveCodexOutboundIdentityForAccount(account, "").userAgent, "codex-tui") } func openAISetupTokenCompatAccount(id int64) *Account { diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go index 1f85864f7368..67f0b14275a1 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -1677,7 +1677,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade t.Fatal("等待 passthrough websocket 结束超时") } - require.Equal(t, isolateOpenAISessionID(0, "pcache_passthrough"), captureDialer.lastHeaders.Get("session_id")) + require.Empty(t, captureDialer.lastHeaders.Get("session_id"), "真实 Codex 不发下划线 session_id") + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "pcache_passthrough"), captureDialer.lastHeaders.Get("session-id")) require.Empty(t, captureDialer.lastHeaders.Get(openAIWSTurnStateHeader), "client turn state without account provenance must fail closed") require.Equal(t, "turn-meta-1", captureDialer.lastHeaders.Get(openAIWSTurnMetadataHeader)) require.Len(t, upstreamConn.writes, 1) diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index 437be02069cf..4c8670bb5b29 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -105,7 +105,6 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( } for _, name := range [...]string{ "x-codex-window-id", - "x-codex-installation-id", "session-id", "thread-id", "x-client-request-id", @@ -121,16 +120,9 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( // 之外:该头是账号/会话级属性,不依赖入站请求是否存在,也避免预热与 // 实际请求因头差异落进不同的连接池兼容分桶。 applyOpenAICodexBetaFeatures(c, account, headers) - // OAuth 账号:将 apiKeyID 混入 session 标识符,防止跨用户会话碰撞。 - if account != nil && account.UsesOpenAICodexProtocol() { - apiKeyID := getAPIKeyIDFromContext(c) - if sessionResolution.SessionID != "" { - headers.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionResolution.SessionID)) - } - if sessionResolution.ConversationID != "" { - headers.Set("conversation_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionResolution.ConversationID)) - } - } else { + // OAuth(Codex 协议)账号:真实 Codex 握手只带连字符 session-id / thread-id, + // 下划线形式的 session_id / conversation_id 不再发送;其他账号类型保持原样。 + if account == nil || !account.UsesOpenAICodexProtocol() { if sessionResolution.SessionID != "" { headers.Set("session_id", sessionResolution.SessionID) } @@ -146,6 +138,11 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( } applyCodexAccountIdentityHeaders(headers, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) applyStagedCodexFingerprintHeaders(c, account, headers) + if account != nil && account.UsesOpenAICodexProtocol() { + // 入站缺失连字符会话头时,用同一账号作用域映射从客户端会话标识补齐, + // 与 WS 帧 client_metadata 的改写结果同源。 + ensureCodexSessionIdentityHeaders(headers, scopeCodexAccountIdentityValue(codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c), sessionResolution.SessionID)) + } if account != nil && account.UsesOpenAICodexProtocol() { if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil { @@ -177,7 +174,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( // 终态收口:WS 握手与 HTTP 出站共用同一套身份语义,账号级自定义 UA 同样作为 // 管理员显式配置传入(上面写进 headers 的值只在强制统一被关闭时才参与配对)。 if account != nil && account.UsesOpenAICodexProtocol() { - enforceCodexIdentityHeadersWithUA(headers, s.codexIdentityOverrideUA(account)) + enforceCodexIdentityHeadersForAccount(headers, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)) } // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)。 diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index b6d1dfd4a8ec..339880187957 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -423,7 +423,7 @@ func TestOpenAIGatewayService_BuildOpenAIWSHeadersPreservesCodexIdentity(t *test 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-Codex-Installation-ID"), "installation id 只在 body client_metadata") require.Equal(t, "session-ws", headers.Get("session-id")) require.Equal(t, "thread-ws", headers.Get("thread-id")) require.Equal(t, "client-request-ws", headers.Get("x-client-request-id")) @@ -463,12 +463,11 @@ func TestOpenAIGatewayService_BuildOpenAIWSHeadersDeviceModePreservesNamespacedC ) require.NoError(t, err) - require.Equal(t, ids.installationID, headers.Get("x-codex-installation-id")) - require.NotEqual(t, "client-installation", headers.Get("x-codex-installation-id")) - require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "window", "client-window"), headers.Get("x-codex-window-id")) - require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "session", "client-session"), headers.Get("session-id")) - require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "thread", "client-thread"), headers.Get("thread-id")) - require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "request", "client-request"), headers.Get("x-client-request-id")) + require.Empty(t, headers.Get("x-codex-installation-id"), "installation id 只在 body client_metadata") + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "client-window"), headers.Get("x-codex-window-id")) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "client-session"), headers.Get("session-id")) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "client-thread"), headers.Get("thread-id")) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "client-request"), headers.Get("x-client-request-id")) } func TestLogOpenAIWSBindResponseAccountWarn(t *testing.T) { @@ -798,10 +797,11 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T require.Equal(t, "native-wsv2", gjson.Get(requestJSON, "input.0.namespace").String(), "OAuth WSv2 应保留原生 namespace") require.Equal(t, openAIWSBetaV2Value, captureDialer.lastHeaders.Get("OpenAI-Beta")) require.Equal(t, "remote_compaction_v2", captureDialer.lastHeaders.Get("x-codex-beta-features")) - // OAuth 账号的 session_id/conversation_id 应同时按 API key 和上游账号隔离, + // OAuth(Codex 协议)账号只发连字符 session-id,按 API key 和上游账号作用域改写; // 测试中未设置 api_key 到 context,apiKeyID=0。 - require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "sess-oauth-1"), captureDialer.lastHeaders.Get("session_id")) - require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "conv-oauth-1"), captureDialer.lastHeaders.Get("conversation_id")) + require.Empty(t, captureDialer.lastHeaders.Get("session_id")) + require.Empty(t, captureDialer.lastHeaders.Get("conversation_id")) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "sess-oauth-1"), captureDialer.lastHeaders.Get("session-id")) } func TestOpenAIGatewayService_Forward_WSv2_OAuthSanitizesInvalidNativeToolItemID(t *testing.T) { @@ -1090,8 +1090,9 @@ func TestOpenAIGatewayService_Forward_WSv2_HeaderSessionFallbackFromPromptCacheK require.NotNil(t, result) require.Equal(t, "resp_prompt_cache_key", result.RequestID) - // OAuth 账号的 session_id 应同时按 API key 和上游账号隔离(apiKeyID=0)。 - require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "pcache_123"), captureDialer.lastHeaders.Get("session_id")) + // OAuth 账号缺失会话头时以 prompt_cache_key 补齐连字符 session-id(apiKeyID=0)。 + require.Empty(t, captureDialer.lastHeaders.Get("session_id")) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "pcache_123"), captureDialer.lastHeaders.Get("session-id")) require.Empty(t, captureDialer.lastHeaders.Get("conversation_id")) require.NotNil(t, captureConn.lastWrite) require.True(t, gjson.Get(requestToJSONString(captureConn.lastWrite), "stream").Exists()) @@ -1160,9 +1161,10 @@ func TestOpenAIGatewayService_Forward_WSv2_CodexFingerprintHandshakeBodyParityAn wantThread := resolveConvergedThreadID(seed, "header-session") payloadJSON := requestToJSONString(captureConn.lastWrite) - require.Equal(t, wantInstall, captureDialer.lastHeaders.Get("x-codex-installation-id")) + require.Empty(t, captureDialer.lastHeaders.Get("x-codex-installation-id"), "installation id 只在 body client_metadata") + require.Equal(t, wantInstall, gjson.Get(payloadJSON, "client_metadata.x-codex-installation-id").String()) require.Equal(t, wantSession, captureDialer.lastHeaders.Get("session-id")) - require.Equal(t, wantSession, captureDialer.lastHeaders.Get("session_id")) + require.Empty(t, captureDialer.lastHeaders.Get("session_id")) require.Equal(t, wantThread, captureDialer.lastHeaders.Get("thread-id")) require.Equal(t, wantThread, captureDialer.lastHeaders.Get("x-client-request-id")) require.Equal(t, wantThread+":0", captureDialer.lastHeaders.Get("x-codex-window-id")) diff --git a/backend/internal/service/openai_ws_http_bridge_resume_test.go b/backend/internal/service/openai_ws_http_bridge_resume_test.go index 97c94211667a..3dc427919286 100644 --- a/backend/internal/service/openai_ws_http_bridge_resume_test.go +++ b/backend/internal/service/openai_ws_http_bridge_resume_test.go @@ -253,11 +253,11 @@ func TestOpenAIWSHTTPBridgeLaterTurn429RetriesCurrentTurnOnReplacementAccount(t } require.Len(t, upstream.bodies, 3) require.Contains(t, string(upstream.bodies[0]), "first") - require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "session", "client-session"), gjson.GetBytes(upstream.bodies[1], "client_metadata.session_id").String()) - require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "thread", "client-thread"), gjson.GetBytes(upstream.bodies[1], "client_metadata.thread_id").String()) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "client-session"), gjson.GetBytes(upstream.bodies[1], "client_metadata.session_id").String()) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "client-thread"), gjson.GetBytes(upstream.bodies[1], "client_metadata.thread_id").String()) require.NotContains(t, string(upstream.bodies[2]), "previous_response_id") require.Contains(t, string(upstream.bodies[2]), "second") - require.Equal(t, scopeCodexAccountIdentityValue(&nextAccount, 0, "session", "client-session"), gjson.GetBytes(upstream.bodies[2], "client_metadata.session_id").String()) - require.Equal(t, scopeCodexAccountIdentityValue(&nextAccount, 0, "thread", "client-thread"), gjson.GetBytes(upstream.bodies[2], "client_metadata.thread_id").String()) + require.Equal(t, scopeCodexAccountIdentityValue(&nextAccount, 0, "client-session"), gjson.GetBytes(upstream.bodies[2], "client_metadata.session_id").String()) + require.Equal(t, scopeCodexAccountIdentityValue(&nextAccount, 0, "client-thread"), gjson.GetBytes(upstream.bodies[2], "client_metadata.thread_id").String()) require.Empty(t, upstream.requests[2].Header.Get(openAIWSTurnStateHeader)) } diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 157dce053257..ce31b69407e4 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -835,6 +835,13 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2PassthroughAttempt( } else if changed { firstClientMessage = normalized } + accountScopedFirst, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(firstClientMessage, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + if scopeErr != nil { + return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", scopeErr) + } + if accountScoped { + firstClientMessage = accountScopedFirst + } usageMeta := newOpenAIWSPassthroughUsageMeta(initialRequestModel, firstClientMessage) updatedFirst, blocked, policyErr := s.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, capturedSessionModel, firstClientMessage) if policyErr != nil { diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 540d9115f8de..514765e41171 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -1160,14 +1160,14 @@ func (s *AccountTestService) buildOpenAIOAuthUpstreamModelsRequest(ctx context.C req.Header.Set("Authorization", "Bearer "+accessToken) } - identity := resolveCodexOutboundIdentity(credentialAccount.GetOpenAIUserAgent()) + identity := resolveCodexOutboundIdentityForAccount(credentialAccount, credentialAccount.GetOpenAIUserAgent()) req.Header.Set("Accept", "application/json") req.Header.Set("Originator", identity.originator) req.Header.Set("User-Agent", identity.userAgent) req.Header.Set("Version", identity.version) setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) credentialAccount.ApplyHeaderOverrides(req.Header) - enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent()) + enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, credentialAccount.GetOpenAIUserAgent()) return req, nil } diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 0e8211f2b0c4..11a6cbef518e 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -534,6 +534,14 @@ func ProvideOpenAICodexVersionSyncService( return svc } +// ProvideCodexClientIdentityBackfillService creates and starts the one-shot backfill that +// persists a Codex client identity for every existing OpenAI OAuth-like account. +func ProvideCodexClientIdentityBackfillService(accountRepo AccountRepository) *CodexClientIdentityBackfillService { + svc := NewCodexClientIdentityBackfillService(accountRepo) + svc.Start() + return svc +} + // ProvideProxyExpiryService creates and starts ProxyExpiryService. func ProvideProxyExpiryService(proxyRepo ProxyRepository) *ProxyExpiryService { svc := NewProxyExpiryService(proxyRepo, time.Minute) @@ -1049,6 +1057,7 @@ var ProviderSet = wire.NewSet( wire.Bind(new(GrokOAuthReconciler), new(*TokenRefreshService)), ProvideAccountExpiryService, ProvideOpenAICodexVersionSyncService, + ProvideCodexClientIdentityBackfillService, ProvideProxyExpiryService, ProvideSubscriptionExpiryService, ProvideTimingWheelService, diff --git a/backend/migrations/243_codex_fingerprint_device_default.sql b/backend/migrations/243_codex_fingerprint_device_default.sql new file mode 100644 index 000000000000..4ee97c1340fe --- /dev/null +++ b/backend/migrations/243_codex_fingerprint_device_default.sql @@ -0,0 +1,36 @@ +-- Codex fingerprint convergence now defaults to "device" for OpenAI OAuth-like accounts: +-- one ChatGPT subscription must present a single installation to the upstream even when +-- several downstream users share it. Accounts that never chose a mode are written as +-- "device" explicitly; an explicit "off" is preserved. Every such account also gets the +-- system-managed seed that device convergence and the per-account Codex TUI identity +-- derive from. Idempotent: valid canonical seeds are preserved on rerun. +UPDATE accounts +SET extra = jsonb_set( + COALESCE(extra, '{}'::jsonb), + '{codex_fingerprint_mode}', + '"device"'::jsonb, + true +) +WHERE deleted_at IS NULL + AND platform = 'openai' + AND type IN ('oauth', 'setup_token') + AND COALESCE(btrim(extra->>'codex_fingerprint_mode'), '') = ''; + +UPDATE accounts +SET extra = jsonb_set( + COALESCE(extra, '{}'::jsonb), + '{codex_fingerprint_seed}', + to_jsonb(gen_random_uuid()::text), + true +) +WHERE deleted_at IS NULL + AND platform = 'openai' + AND type IN ('oauth', 'setup_token') + AND ( + extra->>'codex_fingerprint_seed' IS NULL + OR btrim(extra->>'codex_fingerprint_seed') = '' + OR NOT ( + extra->>'codex_fingerprint_seed' ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + AND extra->>'codex_fingerprint_seed' <> '00000000-0000-0000-0000-000000000000' + ) + ); diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index fc04984f3b0c..8d4452fef50f 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -1733,7 +1733,7 @@ const codexCLIOnlyEnabled = ref(false) const codexCLIOnlyAppServerEnabled = ref(false) type CodexFingerprintMode = 'off' | 'device' | 'session' | 'full' const enableCodexFingerprintMode = ref(false) -const codexFingerprintMode = ref('off') +const codexFingerprintMode = ref('device') const codexFingerprintModeOptions = computed(() => [ { value: 'off' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintOff') }, { value: 'device' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintDevice') }, @@ -2131,10 +2131,9 @@ const buildUpdatePayload = (): Record | null => { // Create/Edit 那两个表单可以删键,是因为它们提交完整 extra 对象、后端整体 // SetExtra 覆盖;批量接口只合并增量键,两种持久化语义不能共用同一套写法。 // - // 显式 off 与不设置在读取侧完全等价:codexFingerprintModeFromExtra 对空值/ - // 非法值走 default 回落 off,对 "off" 命中同一分支,所以 #5610 定下的 - // "不显式 opt-in 就保持旧客户端身份" 不受影响;ShouldEnsureCodexFingerprintSeed- - // ForExtraUpdates 同样只在 device/session/full 时要种子,off 不会触发。 + // 读取侧对缺失键默认 device,因此显式 off 必须落键才能真正关闭收敛; + // ShouldEnsureCodexFingerprintSeedForExtraUpdates 只在显式 device/session/full + // 时要种子,off 不会触发。 // // 与本函数里其它"关闭/清除"字段的写法一致:codex_cli_only 直接落 false, // load_factor 落 0,proxy_id 落 0 —— 批量路径一律用显式哨兵值,不用省略。 @@ -2384,7 +2383,7 @@ watch( enableCodexCLIOnly.value = false enableCodexCLIOnlyAppServer.value = false enableCodexFingerprintMode.value = false - codexFingerprintMode.value = 'off' + codexFingerprintMode.value = 'device' enableOpenAICompactMode.value = false enableOpenAICompactModelMapping.value = false enableRpmLimit.value = false diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index bcfb06cba7c4..6271e14f641f 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -4625,7 +4625,7 @@ const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OF const codexCLIOnlyEnabled = ref(false) const codexCLIOnlyAppServerEnabled = ref(false) type CodexFingerprintMode = 'off' | 'device' | 'session' | 'full' -const codexFingerprintMode = ref('off') +const codexFingerprintMode = ref('device') const codexFingerprintModeOptions = computed(() => [ { value: 'off' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintOff') }, { value: 'device' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintDevice') }, @@ -5644,7 +5644,7 @@ const resetForm = () => { openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF codexCLIOnlyEnabled.value = false codexCLIOnlyAppServerEnabled.value = false - codexFingerprintMode.value = 'off' + codexFingerprintMode.value = 'device' anthropicPassthroughEnabled.value = false anthropicAPIKeyAuthScheme.value = 'x_api_key' webSearchEmulationMode.value = 'default' @@ -5745,13 +5745,8 @@ const buildOpenAIExtra = (base?: Record): Record(OPENAI_WS_MODE_OF const codexCLIOnlyEnabled = ref(false) const codexCLIOnlyAppServerEnabled = ref(false) type CodexFingerprintMode = 'off' | 'device' | 'session' | 'full' -const codexFingerprintMode = ref('off') +const codexFingerprintMode = ref('device') type CodexImageToolMode = 'inherit' | 'enabled' | 'disabled' | 'block' const codexImageToolMode = ref('inherit') type AnthropicAPIKeyAuthScheme = 'x_api_key' | 'authorization_bearer' @@ -4203,7 +4203,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF codexCLIOnlyEnabled.value = false codexCLIOnlyAppServerEnabled.value = false - codexFingerprintMode.value = 'off' + codexFingerprintMode.value = 'device' codexImageToolMode.value = 'inherit' anthropicPassthroughEnabled.value = false anthropicAPIKeyAuthScheme.value = 'x_api_key' @@ -4261,10 +4261,10 @@ const syncFormFromAccount = (newAccount: Account | null) => { } if (newAccount.type === 'oauth') { const fpMode = extra?.codex_fingerprint_mode as string | undefined - // 缺省/非法值按 off 呈现,与后端 GetCodexFingerprintMode 的 opt-in 语义一致(#5610) + // 缺省/非法值按 device 呈现,与后端 codexFingerprintModeFromExtra 的默认值一致 codexFingerprintMode.value = (['off', 'device', 'session', 'full'].includes(fpMode || '') ? fpMode as CodexFingerprintMode - : 'off') + : 'device') } const credentials = newAccount.credentials as Record | undefined const compactMappings = credentials?.compact_model_mapping as Record | undefined @@ -5907,14 +5907,10 @@ const handleSubmit = async () => { } } - // 指纹收敛模式:默认 off(不写入);device/session/full 是显式 opt-in, - // 必须落键,否则管理员的选择会被后端当作"未设置"而回落到 off(#5610)。 + // 指纹收敛模式:后端对缺失键默认按 device 处理;显式 off 必须落键, + // 否则会被当成默认 device。 if (props.account.type === 'oauth') { - if (codexFingerprintMode.value !== 'off') { - newExtra.codex_fingerprint_mode = codexFingerprintMode.value - } else { - delete newExtra.codex_fingerprint_mode - } + newExtra.codex_fingerprint_mode = codexFingerprintMode.value } updatePayload.extra = newExtra diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index 9c4e12bdb175..97f314238a09 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -964,8 +964,12 @@ describe('BulkEditAccountModal', () => { selectedTypes: ['oauth'] }) - // 下拉框默认就是 off,用户只勾选「编辑该项」即提交——正是 issue 描述的操作路径。 + // 下拉框默认是 device(后端对缺失键的默认值),显式选择 off 后提交—— + // 必须落显式 off,缺失键会被后端当作 device。 await wrapper.get('#bulk-edit-openai-codex-fingerprint-mode-enabled').setValue(true) + await wrapper + .get('[data-testid="bulk-codex-fingerprint-mode-select"]') + .setValue('off') await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') await flushPromises() diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 073730a348d8..893d569ad5dc 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -976,8 +976,8 @@ export default {accounts: { codexCLIOnlyAppServerDesc: "Effective only when the switch above is on. When enabled, this account also allows third-party clients that embed the Codex engine over the app-server protocol (e.g. Claude Code's codex plugin); they still pass the global engine-fingerprint gate. OR-combined with the global app-server toggle.", codexFingerprintMode: 'Codex fingerprint convergence', - codexFingerprintModeDesc: 'When multiple users share the same OAuth account, converge device/session identifiers to account-level stable values to reduce upstream-visible device and session count. Off by default (client identifiers pass through as-is); opt in explicitly when needed. Some accounts reported quota shrinkage after enabling convergence, so choose based on your own measurements.', - codexFingerprintOff: 'Off (passthrough, default)', + codexFingerprintModeDesc: 'When multiple users share the same OAuth account, converge device/session identifiers to account-level stable values to reduce upstream-visible device and session count. Defaults to "Device only": one account presents a single installation upstream, paired with the account-level Codex client identity. Choose Off to pass client identifiers through as-is.', + codexFingerprintOff: 'Off (passthrough)', codexFingerprintDevice: 'Device only', codexFingerprintSession: 'Device + Session', codexFingerprintFull: 'Full convergence', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index a5e3cb657ee8..532243568657 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -1054,8 +1054,8 @@ export default {accounts: { codexCLIOnlyAppServer: '允许 Codex app-server 客户端', codexCLIOnlyAppServerDesc: '仅在上方开关开启时生效。开启后本账号额外放行内嵌 Codex 引擎、经 app-server 协议接入的第三方客户端(如 Claude Code 的 codex 插件),仍需通过全局引擎指纹门;与全局 app-server 开关取 OR(任一开即放行)。', codexFingerprintMode: 'Codex 指纹收敛', - codexFingerprintModeDesc: '多人共享同一 OAuth 账号时,将各用户的设备/会话标识收敛为账号级恒定值,减少上游可见的设备数和会话数。默认关闭(原样透传客户端标识),需要时再显式开启;部分账号开启收敛后出现过额度缩水,请按自己的实测结果选择。', - codexFingerprintOff: '关闭(透传,默认)', + codexFingerprintModeDesc: '多人共享同一 OAuth 账号时,将各用户的设备/会话标识收敛为账号级恒定值,减少上游可见的设备数和会话数。默认"仅设备":一个账号对上游只表现为一台设备,并配合账号级的 Codex 客户端身份;选择关闭则原样透传客户端标识。', + codexFingerprintOff: '关闭(透传)', codexFingerprintDevice: '仅设备', codexFingerprintSession: '设备+会话', codexFingerprintFull: '完全收敛', From 59cb3bcce1c5938e2eaae0e16d5ee7033387e005 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 18 Sep 2026 04:28:17 +0800 Subject: [PATCH 2/3] fix(codex): unify the effective identity source across refresh, staging and native WS - share the ForceCodexCLI override policy between inference and token refresh via a process snapshot; staging derives sandbox from the same effective User-Agent the final builder emits (shadow-account parent source, enforcement-off pairing) - backfill persisted identities for OpenAI accounts of every status - Anthropic-bridge, chat-completions and alpha-search fallback paths now emit the hyphen session headers for Codex accounts instead of underscore session_id/conversation_id - WS handshake fallback uses the frame's final prompt_cache_key without a second scope - native WebSocket ingress and passthrough frames stage and apply fingerprint convergence --- .../openai_agent_identity_compat_test.go | 4 +- .../internal/service/openai_alpha_search.go | 6 +- .../service/openai_codex_account_identity.go | 20 +++++ .../service/openai_codex_client_identity.go | 4 +- .../openai_codex_client_identity_test.go | 78 +++++++++++++++++++ .../internal/service/openai_codex_identity.go | 19 +++++ .../service/openai_compat_model_test.go | 14 ++-- .../openai_gateway_chat_completions.go | 7 +- .../service/openai_gateway_forward.go | 66 +++++++++++++++- .../service/openai_gateway_messages.go | 11 ++- .../service/openai_gateway_passthrough.go | 3 +- .../service/openai_gateway_service.go | 1 + .../internal/service/openai_oauth_service.go | 2 +- .../service/openai_setup_token_compat_test.go | 2 +- .../service/openai_ws_forwarder_ingress.go | 10 ++- ...penai_ws_forwarder_ingress_session_test.go | 37 +++++++-- .../service/openai_ws_forwarder_payload.go | 11 ++- .../service/openai_ws_forwarder_v2.go | 3 +- .../openai_ws_v2_passthrough_adapter.go | 12 +++ 19 files changed, 274 insertions(+), 36 deletions(-) diff --git a/backend/internal/service/openai_agent_identity_compat_test.go b/backend/internal/service/openai_agent_identity_compat_test.go index ade25a947bbb..484d325057e3 100644 --- a/backend/internal/service/openai_agent_identity_compat_test.go +++ b/backend/internal/service/openai_agent_identity_compat_test.go @@ -482,8 +482,8 @@ 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")) + 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")) } func decodeAgentAssertionTask(t *testing.T, header string) string { diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index 14b51bb361ab..1bb8cae16249 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -768,9 +768,9 @@ 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 路径一致。 + setCodexSessionIdentityHeaders(req.Header, generateSessionUUID(sessionID)) } applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), apiKeyID) enforceCodexIdentityHeadersForAccount(req.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)) diff --git a/backend/internal/service/openai_codex_account_identity.go b/backend/internal/service/openai_codex_account_identity.go index d0f6ca036070..d7a1abbb7745 100644 --- a/backend/internal/service/openai_codex_account_identity.go +++ b/backend/internal/service/openai_codex_account_identity.go @@ -318,6 +318,26 @@ func applyCodexAccountIdentityHeaders(headers http.Header, account *Account, api // 连字符会话头:session-id 缺失时取最终(已按账号作用域改写的)会话标识, // thread-id 缺失时等于 session-id(root 线程不变式),x-client-request-id 缺失时等于 // thread-id。sessionID 必须已经是出站形态,这里不再二次改写。 +// setCodexSessionIdentityHeaders 用给定会话 ID 覆写非 compact Codex 路径的连字符会话头: +// session-id 总是覆写;thread-id / x-client-request-id 为空或仍跟随旧 session-id 时一并改写, +// 客户端显式给出的不同值保留;同时删除下划线 session_id / conversation_id(真实 Codex 不发送)。 +func setCodexSessionIdentityHeaders(h http.Header, sessionID string) { + sessionID = strings.TrimSpace(sessionID) + if h == nil || sessionID == "" { + return + } + previous := strings.TrimSpace(h.Get("session-id")) + h.Set("session-id", sessionID) + for _, name := range []string{"thread-id", "x-client-request-id"} { + current := strings.TrimSpace(h.Get(name)) + if current == "" || current == previous { + h.Set(name, sessionID) + } + } + h.Del("session_id") + h.Del("conversation_id") +} + func ensureCodexSessionIdentityHeaders(h http.Header, sessionID string) { if h == nil { return diff --git a/backend/internal/service/openai_codex_client_identity.go b/backend/internal/service/openai_codex_client_identity.go index db0c6fd6da8b..76f1e45fbd6d 100644 --- a/backend/internal/service/openai_codex_client_identity.go +++ b/backend/internal/service/openai_codex_client_identity.go @@ -339,7 +339,9 @@ func (s *CodexClientIdentityBackfillService) RunOnce(ctx context.Context) (int, if s == nil || s.accountRepo == nil { return 0, 0, nil } - accounts, err := s.accountRepo.ListByPlatform(ctx, PlatformOpenAI) + // 任务内取全部状态的 OpenAI 账号:ListByPlatform 只返回 active,而停用/异常账号恢复后 + // 同样要以持久化身份出站;不改动全局共享的 active 过滤,也不触碰账号状态。 + accounts, err := s.accountRepo.ListAllWithFilters(ctx, PlatformOpenAI, "", "", "", 0, "") if err != nil { return 0, 0, fmt.Errorf("list openai accounts: %w", err) } diff --git a/backend/internal/service/openai_codex_client_identity_test.go b/backend/internal/service/openai_codex_client_identity_test.go index af8ec5feab13..68fdd8ef750a 100644 --- a/backend/internal/service/openai_codex_client_identity_test.go +++ b/backend/internal/service/openai_codex_client_identity_test.go @@ -2,11 +2,15 @@ package service import ( "context" + "fmt" "net/http" + "net/http/httptest" "regexp" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/stretchr/testify/require" ) @@ -105,6 +109,80 @@ func (id codexClientIdentity) withoutGeneratedAt() codexClientIdentity { return id } +// 凭据面与推理面共用显式 UA 策略:ForceCodexCLI 开启时忽略账号自定义 UA,刷新 token 也用派生身份。 +func TestRefreshAccountTokenSharesForceCodexCLIOverridePolicy(t *testing.T) { + t.Cleanup(func() { SetCodexForceCLIEnabled(false) }) + customUA := "codex_cli_rs/0.150.0 (Windows 10.0.19045; x86_64) unknown" + account := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"refresh_token": "rt", "chatgpt_account_id": "acct-12", "user_agent": customUA}, + Extra: map[string]any{codexFingerprintSeedExtraKey: "3b7f1c2d-9e8a-4b6c-8d5e-2f1a0b9c8d7e"}} + + SetCodexForceCLIEnabled(false) + stub := &identityRefreshingOAuthClientStub{} + _, err := NewOpenAIOAuthService(nil, stub).RefreshAccountToken(context.Background(), account) + require.NoError(t, err) + require.Equal(t, resolveCodexOutboundIdentity(customUA).userAgent, stub.userAgent, "未强制时账号显式 UA 生效") + + SetCodexForceCLIEnabled(true) + stub = &identityRefreshingOAuthClientStub{} + _, err = NewOpenAIOAuthService(nil, stub).RefreshAccountToken(context.Background(), account) + require.NoError(t, err) + require.Equal(t, resolveCodexOutboundIdentityForAccount(account, "").userAgent, stub.userAgent, "ForceCodexCLI 时与推理面一样忽略显式 UA") +} + +// codexTestSeedForOS 返回一个派生出指定操作系统身份的合法种子(确定性搜索)。 +func codexTestSeedForOS(t *testing.T, osType string) string { + t.Helper() + for i := 0; i < 4096; i++ { + seed := fmt.Sprintf("00000000-0000-4000-8000-%012d", i) + if deriveCodexClientIdentity(seed).OSType == osType { + return seed + } + } + t.Fatalf("no seed derives %s", osType) + return "" +} + +// 影子账号:staging 的 sandbox 跟随构造器实际使用的父账号身份,而不是子账号自己的种子。 +func TestStagedFingerprintSandboxFollowsParentIdentitySource(t *testing.T) { + seedFor := func(osType string) string { return codexTestSeedForOS(t, osType) } + child := &Account{ID: 21, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"chatgpt_account_id": "acct-21"}, + Extra: map[string]any{codexFingerprintSeedExtraKey: seedFor(codexClientOSWindows)}} + parent := &Account{ID: 20, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"chatgpt_account_id": "acct-20"}, + Extra: map[string]any{codexFingerprintSeedExtraKey: seedFor(codexClientOSMac)}} + + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Set(codexAccountIdentitySourceContextKey, parent) + svc := &OpenAIGatewayService{cfg: &config.Config{}} + + ids := svc.resolveStagedCodexFingerprintIDs(c, child, c.Request.Header) + require.NotNil(t, ids) + require.Equal(t, codexClientSandboxMac, ids.sandbox, "sandbox 跟随父账号(最终 UA)的系统") + require.Equal(t, codexClientSandboxWindows, svc.resolveStagedCodexFingerprintIDs(nil, child, nil).sandbox, "无父账号时跟随自身身份") +} + +// 全局强制统一关闭时终态只做配对、保留合法客户端 UA,staging 的 sandbox 必须跟随该客户端 UA。 +func TestStagedFingerprintSandboxFollowsPairedClientUAWhenEnforcementOff(t *testing.T) { + t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) }) + account := &Account{ID: 22, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"chatgpt_account_id": "acct-22"}, + Extra: map[string]any{codexFingerprintSeedExtraKey: codexTestSeedForOS(t, codexClientOSMac)}} + clientHeaders := http.Header{} + clientHeaders.Set("user-agent", "codex_cli_rs/0.150.0 (Windows 10.0.19045; x86_64) unknown") + svc := &OpenAIGatewayService{cfg: &config.Config{}} + + SetCodexIdentityEnforcementEnabled(true) + require.Equal(t, codexClientSandboxMac, svc.resolveStagedCodexFingerprintIDs(nil, account, clientHeaders).sandbox, "强制统一开启:跟随账号身份") + SetCodexIdentityEnforcementEnabled(false) + require.Equal(t, codexClientSandboxWindows, svc.resolveStagedCodexFingerprintIDs(nil, account, clientHeaders).sandbox, "强制统一关闭:跟随配对后的客户端 UA") + clientHeaders.Set("user-agent", "curl/8.0") + require.Equal(t, codexSandboxForUserAgent(resolveCodexOutboundIdentity("").userAgent), svc.resolveStagedCodexFingerprintIDs(nil, account, clientHeaders).sandbox, "不合法客户端 UA 回落规范身份") +} + // turn metadata 的 sandbox 跟随最终出站 UA,而不是账号身份:管理员显式 UA 覆盖时二者可能不同。 func TestFingerprintSandboxFollowsEffectiveUserAgent(t *testing.T) { ids := &codexFingerprintIDs{sandbox: "seatbelt"} diff --git a/backend/internal/service/openai_codex_identity.go b/backend/internal/service/openai_codex_identity.go index b7c8325baf54..aa5d632e8715 100644 --- a/backend/internal/service/openai_codex_identity.go +++ b/backend/internal/service/openai_codex_identity.go @@ -59,6 +59,25 @@ func SetCodexIdentityEnforcementEnabled(enabled bool) { codexIdentityEnforcement.Store(enabled) } +// codexForceCLI 是 gateway.force_codex_cli 的进程级快照:凭据面(token 刷新)拿不到网关配置, +// 但账号显式 UA 是否生效必须与推理面同一套策略,否则同一账号会以两种 UA 出站。 +var codexForceCLI atomic.Bool + +// SetCodexForceCLIEnabled 由持有配置的网关服务在构造时发布。 +func SetCodexForceCLIEnabled(enabled bool) { + codexForceCLI.Store(enabled) +} + +// codexAccountIdentityOverrideUA 返回账号级显式配置的出站 User-Agent,供强制统一身份时作为 +// 覆写来源。ForceCodexCLI 语义是「强制使用 Codex 身份」,等价于忽略账号自定义 UA,故返回空串; +// 该优先级与历史行为一致(ForceCodexCLI 在账号自定义 UA 之后生效)。推理面与凭据面共用。 +func codexAccountIdentityOverrideUA(account *Account) string { + if codexForceCLI.Load() { + return "" + } + return account.GetOpenAIUserAgent() +} + // codexCanonicalUserAgentResolver 返回当前生效的规范 Codex User-Agent(后台设置 / 自动同步版本号)。 // 由 SettingService 在装配时注入;解析器内部自带 TTL 缓存,热路径不触库。 type codexCanonicalUserAgentResolver func() string diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index fbf374ab99c6..e49f8a86c257 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -1113,7 +1113,7 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) { require.NoError(t, err) require.NotNil(t, secondResult) require.Equal(t, "turn_state_first", upstream.requests[1].Header.Get("x-codex-turn-state")) - require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session_id")) + require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session-id")) require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) @@ -1206,7 +1206,7 @@ func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "", "gpt-5.4") require.NoError(t, err) require.NotNil(t, firstResult) - firstSessionID := upstream.requests[0].Header.Get("session_id") + 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], resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) @@ -1221,7 +1221,7 @@ func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "", "gpt-5.4") require.NoError(t, err) require.NotNil(t, secondResult) - require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session_id")) + 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], resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) @@ -1265,7 +1265,7 @@ func TestForwardAsAnthropic_OAuthMetadataSessionSurvivesDigestPrefixRewrite(t *t firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "", "gpt-5.5") require.NoError(t, err) require.NotNil(t, firstResult) - firstSessionID := upstream.requests[0].Header.Get("session_id") + firstSessionID := upstream.requests[0].Header.Get("session-id") require.NotEmpty(t, firstSessionID) require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state")) require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists()) @@ -1279,7 +1279,7 @@ func TestForwardAsAnthropic_OAuthMetadataSessionSurvivesDigestPrefixRewrite(t *t secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "", "gpt-5.5") require.NoError(t, err) require.NotNil(t, secondResult) - require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session_id")) + require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session-id")) require.Equal(t, "turn_state_metadata_first", upstream.requests[1].Header.Get("x-codex-turn-state")) require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) @@ -1322,7 +1322,7 @@ func TestForwardAsAnthropic_OAuthMetadataSessionSurvivesChangingCacheControlAnch firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "", "gpt-5.5") require.NoError(t, err) require.NotNil(t, firstResult) - firstSessionID := upstream.requests[0].Header.Get("session_id") + firstSessionID := upstream.requests[0].Header.Get("session-id") require.NotEmpty(t, firstSessionID) require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state")) require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists()) @@ -1336,7 +1336,7 @@ func TestForwardAsAnthropic_OAuthMetadataSessionSurvivesChangingCacheControlAnch secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "", "gpt-5.5") require.NoError(t, err) require.NotNil(t, secondResult) - require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session_id")) + require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session-id")) require.Equal(t, "turn_state_cache_anchor_first", upstream.requests[1].Header.Get("x-codex-turn-state")) require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index f191d3d7725f..3eca4e34fae4 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -430,7 +430,12 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( if !compatPromptCacheTenantIsolated { sessionKey = isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), upstreamPromptCacheKey) } - upstreamReq.Header.Set("session_id", generateSessionUUID(sessionKey)) + if account.UsesOpenAICodexProtocol() { + // Codex 协议账号:最终形态只有连字符会话头,不再发送下划线 session_id。 + setCodexSessionIdentityHeaders(upstreamReq.Header, generateSessionUUID(sessionKey)) + } else { + upstreamReq.Header.Set("session_id", generateSessionUUID(sessionKey)) + } } // 7. Send request diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index c72d6ec52fa6..6b14952843de 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -596,8 +596,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if c != nil && c.Request != nil { clientHeaders = c.Request.Header } - fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) - fpIDs.alignSandboxWithUserAgent(resolveCodexOutboundIdentityForAccount(account, s.codexIdentityOverrideUA(account)).userAgent) + fpIDs := s.resolveStagedCodexFingerprintIDs(c, account, clientHeaders) if fpIDs != nil { if applyCodexFingerprintClientMetadata(decoded, fpIDs) { markDecodedModified() @@ -1658,5 +1657,66 @@ func (s *OpenAIGatewayService) codexIdentityOverrideUA(account *Account) string if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { return "" } - return account.GetOpenAIUserAgent() + return codexAccountIdentityOverrideUA(account) +} + +// resolveStagedCodexFingerprintIDs 解析本次请求的收敛 ID,并让 turn metadata 的 sandbox 跟随 +// 最终出站 UA:UA 来源与构造器/收口点一致——影子账号取 staged 的父账号身份,账号显式 UA +// 按 ForceCodexCLI 策略生效——否则父/子系统不同或显式 UA 覆盖时 sandbox 会与 UA 分裂。 +func (s *OpenAIGatewayService) resolveStagedCodexFingerprintIDs(c *gin.Context, account *Account, clientHeaders http.Header) *codexFingerprintIDs { + ids := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) + if ids == nil { + return nil + } + ids.alignSandboxWithUserAgent(s.effectiveCodexOutboundUserAgent(c, account, clientHeaders)) + return ids +} + +// effectiveCodexOutboundUserAgent 预测构造器终态收口后实际发出的 User-Agent,与 +// enforceCodexIdentityHeadersForAccount 使用同一套来源与策略: +// - 强制统一开启:账号显式 UA(按 ForceCodexCLI 策略)> staged 父账号 / 自身派生身份 > 规范身份; +// - 强制统一关闭(gateway.disable_codex_identity_enforcement):终态只做 UA↔originator 配对, +// 保留构造器写入的 UA——ForceCodexCLI 时为规范 UA,否则账号显式 UA,否则客户端 UA; +// 不合法的 UA 回落规范身份。该全局开关与 fingerprint mode 是不同门控。 +func (s *OpenAIGatewayService) effectiveCodexOutboundUserAgent(c *gin.Context, account *Account, clientHeaders http.Header) string { + overrideUA := s.codexIdentityOverrideUA(account) + if codexIdentityEnforcement.Load() { + return resolveCodexOutboundIdentityForAccount(codexAccountIdentitySource(c, account), overrideUA).userAgent + } + candidate := overrideUA + if candidate == "" { + if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { + candidate = CodexCanonicalUserAgent() + } else if clientHeaders != nil { + candidate = clientHeaders.Get("user-agent") + } + } + if _, paired, ok := openai.PairCodexClientIdentity(candidate); ok { + return paired + } + return resolveCodexOutboundIdentity("").userAgent +} + +// stageCodexFingerprintForWSFrame 为原生 WS 入口的每一帧解析、暂存并应用收敛 ID:原生 +// Proxy 不经过 HTTP Forward,握手头(buildOpenAIWSHeaders → applyStagedCodexFingerprintHeaders) +// 与帧体 client_metadata 由此共用同一份 IDs;每帧重算使 session/full 模式的 turn_id 逐轮更新, +// device 模式只收敛 installation 与 sandbox。返回改写后的帧(未变化时原样返回)。 +func (s *OpenAIGatewayService) stageCodexFingerprintForWSFrame(c *gin.Context, account *Account, frame []byte) ([]byte, error) { + var clientHeaders http.Header + if c != nil && c.Request != nil { + clientHeaders = c.Request.Header + } + ids := s.resolveStagedCodexFingerprintIDs(c, account, clientHeaders) + stageCodexFingerprintIDs(c, ids) + if ids == nil { + return frame, nil + } + next, changed, err := applyCodexFingerprintClientMetadataRaw(frame, ids) + if err != nil { + return frame, err + } + if changed { + return next, nil + } + return frame, nil } diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index b32b72d56d93..dd702d83c866 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -386,9 +386,14 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // session key, ensuring different API keys produce different upstream sessions. if account.Platform != PlatformGrok && upstreamPromptCacheKey != "" { isolatedSessionID := generateSessionUUID(isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), upstreamPromptCacheKey)) - upstreamReq.Header.Set("session_id", isolatedSessionID) - if upstreamReq.Header.Get("conversation_id") != "" { - upstreamReq.Header.Set("conversation_id", isolatedSessionID) + if account.UsesOpenAICodexProtocol() { + // Codex 协议账号:最终形态只有连字符会话头,不再发送下划线 session_id / conversation_id。 + setCodexSessionIdentityHeaders(upstreamReq.Header, isolatedSessionID) + } else { + upstreamReq.Header.Set("session_id", isolatedSessionID) + if upstreamReq.Header.Get("conversation_id") != "" { + upstreamReq.Header.Set("conversation_id", isolatedSessionID) + } } } if account.UsesOpenAICodexProtocol() && account.Platform != PlatformGrok { diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 90613514a9ba..8699237a8c90 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -254,8 +254,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( if c != nil && c.Request != nil { clientHeaders = c.Request.Header } - fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) - fpIDs.alignSandboxWithUserAgent(resolveCodexOutboundIdentityForAccount(account, s.codexIdentityOverrideUA(account)).userAgent) + fpIDs := s.resolveStagedCodexFingerprintIDs(c, account, clientHeaders) if fpIDs != nil { fpBody, fpChanged, fpErr := applyCodexFingerprintClientMetadataRaw(body, fpIDs) if fpErr != nil { diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 1c4be9095a76..c1663630e224 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -586,6 +586,7 @@ func NewOpenAIGatewayService( // 拿不到配置,故在此发布进程级开关快照。配置取反义,零值即「强制统一出口开启」。 if cfg != nil { SetCodexIdentityEnforcementEnabled(!cfg.Gateway.DisableCodexIdentityEnforcement) + SetCodexForceCLIEnabled(cfg.Gateway.ForceCodexCLI) } svc := &OpenAIGatewayService{ accountRepo: accountRepo, diff --git a/backend/internal/service/openai_oauth_service.go b/backend/internal/service/openai_oauth_service.go index 574be080a3cb..0c79c12a5c48 100644 --- a/backend/internal/service/openai_oauth_service.go +++ b/backend/internal/service/openai_oauth_service.go @@ -400,7 +400,7 @@ func (s *OpenAIOAuthService) RefreshAccountToken(ctx context.Context, account *A } clientID := account.GetCredential("client_id") - identity := resolveCodexOutboundIdentityForAccount(account, account.GetOpenAIUserAgent()) + identity := resolveCodexOutboundIdentityForAccount(account, codexAccountIdentityOverrideUA(account)) return s.refreshTokenWithIdentity(ctx, refreshToken, proxyURL, clientID, identity) } diff --git a/backend/internal/service/openai_setup_token_compat_test.go b/backend/internal/service/openai_setup_token_compat_test.go index 5dac97a26a15..7aa52223b3e0 100644 --- a/backend/internal/service/openai_setup_token_compat_test.go +++ b/backend/internal/service/openai_setup_token_compat_test.go @@ -227,7 +227,7 @@ func TestOpenAISetupTokenMessagesUsesCodexBridgeAndTurnState(t *testing.T) { require.NotNil(t, secondResult) require.True(t, isOpenAICompatMessagesBridgeContext(secondCtx)) require.Equal(t, "turn_state_setup", upstream.requests[1].Header.Get("x-codex-turn-state")) - require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session_id")) + require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session-id")) require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], resolveCodexOutboundIdentityForAccount(account, "").userAgent, "codex-tui") } diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index df656453aaef..411fabdf15bc 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -385,6 +385,11 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if accountScoped { normalized = accountScopedPayload } + fingerprintPayload, fingerprintErr := s.stageCodexFingerprintForWSFrame(c, account, normalized) + if fingerprintErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", fingerprintErr) + } + normalized = fingerprintPayload if responsesLite { litePayload, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(normalized, account) if liteErr != nil { @@ -921,7 +926,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( isCodexCLI, turnState, strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)), - firstPayload.promptCacheKey, + // 握手回退使用最终帧体的 prompt_cache_key(已作用域改写),builder 不再二次映射。 + strings.TrimSpace(gjson.GetBytes(firstPayload.payloadRaw, "prompt_cache_key").String()), firstRoutingFields[0].String(), firstRoutingFields[1].String(), ) @@ -2084,7 +2090,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( isCodexCLI, turnState, strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)), - nextPayload.promptCacheKey, + strings.TrimSpace(gjson.GetBytes(nextPayload.payloadRaw, "prompt_cache_key").String()), nextRoutingFields[0].String(), nextRoutingFields[1].String(), ) diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go index 67f0b14275a1..d67fdc3155e8 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -989,6 +989,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge AllowImageGeneration: true, }, } + fingerprintSeed := codexTestSeedForOS(t, codexClientOSMac) account := &Account{ ID: 31, Name: "openai-codex-image-ws", @@ -998,11 +999,13 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge Schedulable: true, Concurrency: 1, Credentials: map[string]any{ - "access_token": "test-token", + "access_token": "test-token", + "chatgpt_account_id": "chatgpt-image-ws", }, Extra: map[string]any{ "openai_oauth_responses_websockets_v2_enabled": true, "codex_image_generation_bridge": true, + codexFingerprintSeedExtraKey: fingerprintSeed, }, } @@ -1052,7 +1055,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge }() writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) - err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.5","stream":false,"input":"draw a cat"}`)) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.5","stream":false,"input":"draw a cat","client_metadata":{"installation_id":"user-install-1","x-codex-turn-metadata":"{\"sandbox\":\"seccomp\"}"}}`)) cancelWrite() require.NoError(t, err) @@ -1089,7 +1092,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge require.Equal(t, "resp_codex_image_lite", gjson.GetBytes(message, "response.id").String()) writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second) - err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{ + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"client_metadata":{"installation_id":"user-install-3","x-codex-turn-metadata":"{\"sandbox\":\"seccomp\"}"}, "type":"response.create", "model":"gpt-5.5", "stream":false, @@ -1141,6 +1144,17 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge require.False(t, gjson.Get(functionPayload, `tools.#(type=="image_generation")`).Exists()) require.False(t, gjson.Get(functionPayload, "tool_choice").Exists()) require.NotContains(t, gjson.Get(functionPayload, "instructions").String(), codexImageGenerationBridgeMarker) + + // 原生 ctx_pool 入口:默认 device 收敛在首帧与后续帧都落地,sandbox 跟随账号身份(Mac→seatbelt), + // workspaces 不改写。 + expectedInstallation := resolveConvergedInstallationID(account, fingerprintSeed) + require.Equal(t, expectedInstallation, gjson.Get(nonLitePayload, "client_metadata.x-codex-installation-id").String(), "首帧 installation 收敛为账号级设备") + require.Equal(t, expectedInstallation, gjson.Get(functionPayload, "client_metadata.x-codex-installation-id").String(), "后续帧同一设备") + require.Equal(t, expectedInstallation, gjson.Get(gjson.Get(nonLitePayload, "client_metadata.x-codex-turn-metadata").String(), "installation_id").String()) + require.Equal(t, expectedInstallation, gjson.Get(gjson.Get(functionPayload, "client_metadata.x-codex-turn-metadata").String(), "installation_id").String()) + require.Equal(t, codexClientSandboxMac, gjson.Get(gjson.Get(nonLitePayload, "client_metadata.x-codex-turn-metadata").String(), "sandbox").String(), "首帧 sandbox 跟随最终 UA 的系统") + require.Equal(t, codexClientSandboxMac, gjson.Get(gjson.Get(functionPayload, "client_metadata.x-codex-turn-metadata").String(), "sandbox").String(), "后续帧 sandbox 同样改写") + require.False(t, gjson.Get(nonLitePayload, "client_metadata.workspaces").Exists(), "workspaces 保持原样(本例未提供)") } func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_DedicatedModeDoesNotReuseConnAcrossSessions(t *testing.T) { @@ -1593,10 +1607,12 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade Schedulable: true, Concurrency: 1, Credentials: map[string]any{ - "access_token": "oauth-token", + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-passthrough-headers", }, Extra: map[string]any{ "openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModePassthrough, + codexFingerprintSeedExtraKey: "5d2e8c1a-7b4f-4e3d-9a6c-0f1e2d3c4b5a", }, } @@ -1678,11 +1694,20 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade } require.Empty(t, captureDialer.lastHeaders.Get("session_id"), "真实 Codex 不发下划线 session_id") - require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "pcache_passthrough"), captureDialer.lastHeaders.Get("session-id")) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "pcache_passthrough"), captureDialer.lastHeaders.Get("session-id"), "只做一次账号作用域映射") require.Empty(t, captureDialer.lastHeaders.Get(openAIWSTurnStateHeader), "client turn state without account provenance must fail closed") - require.Equal(t, "turn-meta-1", captureDialer.lastHeaders.Get(openAIWSTurnMetadataHeader)) + // 握手 turn metadata 经指纹层重建:非 JSON 的客户端值被替换为最小合法 metadata, + // installation 收敛为账号级设备,sandbox 跟随最终 UA 的系统。 + handshakeTurnMetadata := captureDialer.lastHeaders.Get(openAIWSTurnMetadataHeader) + require.True(t, gjson.Valid(handshakeTurnMetadata), handshakeTurnMetadata) + require.Equal(t, resolveConvergedInstallationID(account, "5d2e8c1a-7b4f-4e3d-9a6c-0f1e2d3c4b5a"), gjson.Get(handshakeTurnMetadata, "installation_id").String()) + passthroughIdentity, ok := account.CodexClientIdentity() + require.True(t, ok) + require.Equal(t, passthroughIdentity.Sandbox, gjson.Get(handshakeTurnMetadata, "sandbox").String()) require.Len(t, upstreamConn.writes, 1) forwarded := requestToJSONString(upstreamConn.writes[0]) + require.Equal(t, gjson.Get(forwarded, "prompt_cache_key").String(), captureDialer.lastHeaders.Get("session-id"), "握手回退与首帧 prompt_cache_key 同源") + require.Equal(t, resolveConvergedInstallationID(account, "5d2e8c1a-7b4f-4e3d-9a6c-0f1e2d3c4b5a"), gjson.Get(forwarded, "client_metadata.x-codex-installation-id").String(), "passthrough 首帧默认 device 收敛") require.False(t, gjson.Get(forwarded, `tools.#(type=="namespace")`).Exists()) require.Equal(t, "collaboration", gjson.Get(forwarded, `input.#(type=="additional_tools").tools.0.name`).String()) require.Equal(t, "namespace", gjson.Get(forwarded, "tool_choice.type").String()) diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index 4c8670bb5b29..b8759b101e80 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -139,9 +139,14 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( applyCodexAccountIdentityHeaders(headers, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) applyStagedCodexFingerprintHeaders(c, account, headers) if account != nil && account.UsesOpenAICodexProtocol() { - // 入站缺失连字符会话头时,用同一账号作用域映射从客户端会话标识补齐, - // 与 WS 帧 client_metadata 的改写结果同源。 - ensureCodexSessionIdentityHeaders(headers, scopeCodexAccountIdentityValue(codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c), sessionResolution.SessionID)) + // 入站缺失连字符会话头时补齐:来自帧体 prompt_cache_key 的回退值由调用方传入最终线上值 + //(已随帧体做过账号作用域改写),不再二次映射;来自入站下划线头的原始值仍按账号作用域 + // 改写。两者都与 WS 帧 client_metadata 的改写结果同源。 + fallback := sessionResolution.SessionID + if sessionResolution.SessionSource != "prompt_cache_key" { + fallback = scopeCodexAccountIdentityValue(codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c), fallback) + } + ensureCodexSessionIdentityHeaders(headers, fallback) } if account != nil && account.UsesOpenAICodexProtocol() { diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 2efc672078b8..116bff53f925 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -191,7 +191,8 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2WithScope( isCodexCLI, turnState, turnMetadata, - promptCacheKey, + // 握手回退与即将发出的帧体 prompt_cache_key 同源(builder 不再二次映射)。 + openAIWSPayloadString(payload, "prompt_cache_key"), openAIWSPayloadString(payload, "model"), openAIWSPayloadString(payload, "service_tier"), ) diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index ce31b69407e4..a0ee7e1624f3 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -842,6 +842,11 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2PassthroughAttempt( if accountScoped { firstClientMessage = accountScopedFirst } + fingerprintFirst, fingerprintErr := s.stageCodexFingerprintForWSFrame(c, account, firstClientMessage) + if fingerprintErr != nil { + return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", fingerprintErr) + } + firstClientMessage = fingerprintFirst usageMeta := newOpenAIWSPassthroughUsageMeta(initialRequestModel, firstClientMessage) updatedFirst, blocked, policyErr := s.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, capturedSessionModel, firstClientMessage) if policyErr != nil { @@ -1106,6 +1111,13 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2PassthroughAttempt( if accountScoped { payload = accountScopedPayload } + if isResponseCreate { + fingerprintPayload, fingerprintErr := s.stageCodexFingerprintForWSFrame(c, account, payload) + if fingerprintErr != nil { + return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", fingerprintErr) + } + payload = fingerprintPayload + } } if isResponseCreate { if responsesLite { From 9a1e22dcf43ffeb83a8aeb36e0bb5c97f98ac3c2 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 18 Sep 2026 05:10:36 +0800 Subject: [PATCH 3/3] fix(codex): apply the shared override policy everywhere and keep builder-owned session headers - account test probe, usage probe, models manifest and upstream model listing use the shared ForceCodexCLI-aware override policy instead of the raw custom User-Agent - staging predicts the bridge contract: compat Messages bridge requests keep the builder's User-Agent, so sandbox follows that value - Messages/Chat/alpha-search fallbacks only fill missing hyphen session headers and drop the underscore ones; an inbound session-id is never overwritten by the cache-key fallback --- .../internal/service/account_test_service.go | 4 +- .../internal/service/account_usage_service.go | 2 +- .../openai_agent_identity_compat_test.go | 4 +- .../internal/service/openai_alpha_search.go | 6 +-- .../service/openai_codex_account_identity.go | 21 +++------ .../openai_codex_client_identity_test.go | 20 +++++++++ .../service/openai_codex_models_service.go | 2 +- .../service/openai_compat_model_test.go | 45 +++++++++++++++++++ .../openai_gateway_chat_completions.go | 5 ++- .../service/openai_gateway_forward.go | 15 +++++++ .../service/openai_gateway_messages.go | 5 ++- backend/internal/service/upstream_models.go | 4 +- 12 files changed, 105 insertions(+), 28 deletions(-) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 832c55da7532..1251d27faac5 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -866,13 +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 := resolveCodexOutboundIdentityForAccount(credentialAccount, credentialAccount.GetOpenAIUserAgent()) + canonical := resolveCodexOutboundIdentityForAccount(credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) req.Header.Set("Originator", canonical.originator) req.Header.Set("User-Agent", canonical.userAgent) setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) // 与真实转发一致:使用该账号的 Codex TUI 身份,账号级自定义 UA 同样作为管理员 // 显式配置传入,否则测试用的身份与该账号真实出站的身份不是同一个。 - enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, credentialAccount.GetOpenAIUserAgent()) + enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) } // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index a8d5988b96da..5665a62487ef 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -896,7 +896,7 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco // 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入。 // 上面写进 header 的指纹缓存 UA 只在强制统一被关闭时才参与配对(保持回滚后的历史语义); // 强制统一开启时客户端身份不参与构造,探针与真实转发用同一套规范身份出站。 - enforceCodexIdentityHeadersForAccount(req.Header, account, account.GetOpenAIUserAgent()) + enforceCodexIdentityHeadersForAccount(req.Header, account, codexAccountIdentityOverrideUA(account)) setOpenAIChatGPTAccountHeaders(req.Header, account) proxyURL := "" diff --git a/backend/internal/service/openai_agent_identity_compat_test.go b/backend/internal/service/openai_agent_identity_compat_test.go index 484d325057e3..1081f4298410 100644 --- a/backend/internal/service/openai_agent_identity_compat_test.go +++ b/backend/internal/service/openai_agent_identity_compat_test.go @@ -482,7 +482,9 @@ 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")) + // 构造器已用最终 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")) } diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index 1bb8cae16249..d3a0c55bbbfb 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -768,9 +768,9 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c } apiKeyID := getAPIKeyIDFromContext(c) if sessionID := strings.TrimSpace(gjson.GetBytes(alphaBody, "id").String()); sessionID != "" { - // 搜索会话 id 以连字符会话头(UUID 形态)出站;账号/Key 作用域改写由下面的 - // applyCodexAccountIdentityHeaders 统一完成,与其他 Codex 路径一致。 - setCodexSessionIdentityHeaders(req.Header, generateSessionUUID(sessionID)) + // 搜索会话 id 以连字符会话头(UUID 形态)补齐缺失的会话头;账号/Key 作用域改写由 + // 下面的 applyCodexAccountIdentityHeaders 统一完成,与其他 Codex 路径一致。 + fillCodexSessionIdentityHeaders(req.Header, generateSessionUUID(sessionID)) } applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), apiKeyID) enforceCodexIdentityHeadersForAccount(req.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)) diff --git a/backend/internal/service/openai_codex_account_identity.go b/backend/internal/service/openai_codex_account_identity.go index d7a1abbb7745..64d3ec0d932e 100644 --- a/backend/internal/service/openai_codex_account_identity.go +++ b/backend/internal/service/openai_codex_account_identity.go @@ -318,24 +318,17 @@ func applyCodexAccountIdentityHeaders(headers http.Header, account *Account, api // 连字符会话头:session-id 缺失时取最终(已按账号作用域改写的)会话标识, // thread-id 缺失时等于 session-id(root 线程不变式),x-client-request-id 缺失时等于 // thread-id。sessionID 必须已经是出站形态,这里不再二次改写。 -// setCodexSessionIdentityHeaders 用给定会话 ID 覆写非 compact Codex 路径的连字符会话头: -// session-id 总是覆写;thread-id / x-client-request-id 为空或仍跟随旧 session-id 时一并改写, -// 客户端显式给出的不同值保留;同时删除下划线 session_id / conversation_id(真实 Codex 不发送)。 -func setCodexSessionIdentityHeaders(h http.Header, sessionID string) { - sessionID = strings.TrimSpace(sessionID) - if h == nil || sessionID == "" { +// fillCodexSessionIdentityHeaders 是非 compact Codex 旁路(Messages / Chat 桥接、alpha search +// fallback)的最终会话头形态:删除下划线 session_id / conversation_id(真实 Codex 不发送), +// 已存在的连字符 session-id / thread-id / x-client-request-id(构造器已复制并作用域改写) +// 原样保留,只在缺失时用给定的会话回退值补齐。 +func fillCodexSessionIdentityHeaders(h http.Header, fallbackSessionID string) { + if h == nil { return } - previous := strings.TrimSpace(h.Get("session-id")) - h.Set("session-id", sessionID) - for _, name := range []string{"thread-id", "x-client-request-id"} { - current := strings.TrimSpace(h.Get(name)) - if current == "" || current == previous { - h.Set(name, sessionID) - } - } h.Del("session_id") h.Del("conversation_id") + ensureCodexSessionIdentityHeaders(h, fallbackSessionID) } func ensureCodexSessionIdentityHeaders(h http.Header, sessionID string) { diff --git a/backend/internal/service/openai_codex_client_identity_test.go b/backend/internal/service/openai_codex_client_identity_test.go index 68fdd8ef750a..e73c6a724b8f 100644 --- a/backend/internal/service/openai_codex_client_identity_test.go +++ b/backend/internal/service/openai_codex_client_identity_test.go @@ -165,6 +165,26 @@ func TestStagedFingerprintSandboxFollowsParentIdentitySource(t *testing.T) { require.Equal(t, codexClientSandboxWindows, svc.resolveStagedCodexFingerprintIDs(nil, child, nil).sandbox, "无父账号时跟随自身身份") } +// 兼容 Messages 桥接删除 originator 后不做 ForAccount 收口:sandbox 跟随构造器实际写入的 UA +// (客户端 UA / 账号显式 UA / ForceCodexCLI 规范 UA),而不是账号 profile。 +func TestEffectiveCodexOutboundUserAgentFollowsBridgeContract(t *testing.T) { + account := &Account{ID: 23, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"chatgpt_account_id": "acct-23"}, + Extra: map[string]any{codexFingerprintSeedExtraKey: codexTestSeedForOS(t, codexClientOSMac)}} + clientHeaders := http.Header{} + clientHeaders.Set("user-agent", "codex_cli_rs/0.150.0 (Windows 10.0.19045; x86_64) unknown") + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + svc := &OpenAIGatewayService{cfg: &config.Config{}} + + require.Equal(t, codexClientSandboxMac, svc.resolveStagedCodexFingerprintIDs(c, account, clientHeaders).sandbox, "非桥接:跟随账号身份") + setOpenAICompatMessagesBridgeContext(c, true) + require.Equal(t, codexClientSandboxWindows, svc.resolveStagedCodexFingerprintIDs(c, account, clientHeaders).sandbox, "桥接:跟随构造器保留的客户端 UA") + svc.cfg.Gateway.ForceCodexCLI = true + require.Equal(t, codexSandboxForUserAgent(CodexCanonicalUserAgent()), svc.resolveStagedCodexFingerprintIDs(c, account, clientHeaders).sandbox, "桥接 + ForceCodexCLI:跟随规范 UA") +} + // 全局强制统一关闭时终态只做配对、保留合法客户端 UA,staging 的 sandbox 必须跟随该客户端 UA。 func TestStagedFingerprintSandboxFollowsPairedClientUAWhenEnforcementOff(t *testing.T) { t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) }) diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 8655ba4bf719..e2b342eb8c1c 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -1771,7 +1771,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc overrideUA := "" identity := resolveCodexOutboundIdentity(overrideUA) if !useAPIKeyUpstream { - overrideUA = credAccount.GetOpenAIUserAgent() + overrideUA = codexAccountIdentityOverrideUA(credAccount) identity = resolveCodexOutboundIdentityForAccount(credAccount, overrideUA) } headers.Set("Originator", identity.originator) diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index e49f8a86c257..8efa0c5d3ad3 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -20,6 +20,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) @@ -1065,6 +1066,50 @@ func TestForwardAsAnthropic_DoesNotAttachPreviousResponseIDForOAuthCompat(t *tes require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists()) } +// 显式入站连字符会话头 R 与 prompt_cache_key K 不同:构造器保留并作用域改写 R,后置会话回退 +// 不得再用 K 派生值覆盖;下划线头不出站。 +func TestForwardAsAnthropic_KeepsInboundHyphenSessionHeaderOverCacheKeyFallback(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{responses: []*http.Response{openAICompatSSECompletedResponse("resp_oauth_hyphen", "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", + }, + } + inboundSession := uuid.Must(uuid.NewV7()).String() + + body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"}],"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("session-id", inboundSession) + c.Request.Header.Set("thread-id", inboundSession) + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "stable-cache-key", "gpt-5.4") + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.requests, 1) + scoped := scopeCodexAccountIdentityValue(account, 0, inboundSession) + require.Equal(t, scoped, upstream.requests[0].Header.Get("session-id"), "入站 R 只作用域改写一次,不被 K 回退覆盖") + require.Equal(t, scoped, upstream.requests[0].Header.Get("thread-id")) + require.NotEqual(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[0].Header.Get("session-id")) + require.Empty(t, upstream.requests[0].Header.Get("session_id")) + require.Empty(t, upstream.requests[0].Header.Get("conversation_id")) +} + func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 3eca4e34fae4..4c50656968a3 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -431,8 +431,9 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( sessionKey = isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), upstreamPromptCacheKey) } if account.UsesOpenAICodexProtocol() { - // Codex 协议账号:最终形态只有连字符会话头,不再发送下划线 session_id。 - setCodexSessionIdentityHeaders(upstreamReq.Header, generateSessionUUID(sessionKey)) + // Codex 协议账号:最终形态只有连字符会话头;构造器已就位的连字符头保留, + // 缺失时才用隔离会话 ID 补齐。 + fillCodexSessionIdentityHeaders(upstreamReq.Header, generateSessionUUID(sessionKey)) } else { upstreamReq.Header.Set("session_id", generateSessionUUID(sessionKey)) } diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 6b14952843de..1423ac337253 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -1679,6 +1679,21 @@ func (s *OpenAIGatewayService) resolveStagedCodexFingerprintIDs(c *gin.Context, // 保留构造器写入的 UA——ForceCodexCLI 时为规范 UA,否则账号显式 UA,否则客户端 UA; // 不合法的 UA 回落规范身份。该全局开关与 fingerprint mode 是不同门控。 func (s *OpenAIGatewayService) effectiveCodexOutboundUserAgent(c *gin.Context, account *Account, clientHeaders http.Header) string { + if isOpenAICompatMessagesBridgeContext(c) { + // 兼容 Messages 桥接:构造器删除 originator,终态收口早退,不做 ForAccount 统一; + // 线上 UA 就是构造器写入的值(ForceCodexCLI 规范 UA > 账号显式 UA > 客户端 UA), + // 保持"不补回桥接 originator"的既有契约,只让 metadata 跟随该实际 UA。 + if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { + return CodexCanonicalUserAgent() + } + if custom := account.GetOpenAIUserAgent(); custom != "" { + return custom + } + if clientHeaders != nil { + return clientHeaders.Get("user-agent") + } + return "" + } overrideUA := s.codexIdentityOverrideUA(account) if codexIdentityEnforcement.Load() { return resolveCodexOutboundIdentityForAccount(codexAccountIdentitySource(c, account), overrideUA).userAgent diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index dd702d83c866..2e4670c73a74 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -387,8 +387,9 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if account.Platform != PlatformGrok && upstreamPromptCacheKey != "" { isolatedSessionID := generateSessionUUID(isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), upstreamPromptCacheKey)) if account.UsesOpenAICodexProtocol() { - // Codex 协议账号:最终形态只有连字符会话头,不再发送下划线 session_id / conversation_id。 - setCodexSessionIdentityHeaders(upstreamReq.Header, isolatedSessionID) + // Codex 协议账号:最终形态只有连字符会话头;构造器已就位的连字符头保留, + // 缺失时才用按 API Key 隔离的会话 ID 补齐。 + fillCodexSessionIdentityHeaders(upstreamReq.Header, isolatedSessionID) } else { upstreamReq.Header.Set("session_id", isolatedSessionID) if upstreamReq.Header.Get("conversation_id") != "" { diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 514765e41171..a8f0fa02c19b 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -1160,14 +1160,14 @@ func (s *AccountTestService) buildOpenAIOAuthUpstreamModelsRequest(ctx context.C req.Header.Set("Authorization", "Bearer "+accessToken) } - identity := resolveCodexOutboundIdentityForAccount(credentialAccount, credentialAccount.GetOpenAIUserAgent()) + identity := resolveCodexOutboundIdentityForAccount(credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) req.Header.Set("Accept", "application/json") req.Header.Set("Originator", identity.originator) req.Header.Set("User-Agent", identity.userAgent) req.Header.Set("Version", identity.version) setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) credentialAccount.ApplyHeaderOverrides(req.Header) - enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, credentialAccount.GetOpenAIUserAgent()) + enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) return req, nil }