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..1251d27faac5 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, codexAccountIdentityOverrideUA(credentialAccount)) req.Header.Set("Originator", canonical.originator) - if customUA := strings.TrimSpace(credentialAccount.GetOpenAIUserAgent()); customUA != "" { - req.Header.Set("User-Agent", customUA) - } else { - req.Header.Set("User-Agent", canonical.userAgent) - } + req.Header.Set("User-Agent", canonical.userAgent) setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) - // 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入,否则测试用的身份 - // 与该账号真实出站的身份不是同一个(issue #3901 的配对不变式由收口保证)。 - enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent()) + // 与真实转发一致:使用该账号的 Codex TUI 身份,账号级自定义 UA 同样作为管理员 + // 显式配置传入,否则测试用的身份与该账号真实出站的身份不是同一个。 + enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) } // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 @@ -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..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 只在强制统一被关闭时才参与配对(保持回滚后的历史语义); // 强制统一开启时客户端身份不参与构造,探针与真实转发用同一套规范身份出站。 - enforceCodexIdentityHeadersWithUA(req.Header, account.GetOpenAIUserAgent()) + enforceCodexIdentityHeadersForAccount(req.Header, account, codexAccountIdentityOverrideUA(account)) 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..1081f4298410 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"`) @@ -483,8 +482,10 @@ func TestOpenAIAgentIdentityChatRecoveryKeepsAutoDerivedSessionIsolationStable(t secondKey := gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String() require.NotEmpty(t, firstKey) require.Equal(t, firstKey, secondKey) - require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(99, codexAccountIdentitySource(c, account), firstKey)), upstream.requests[0].Header.Get("session_id")) - require.Equal(t, upstream.requests[0].Header.Get("session_id"), upstream.requests[1].Header.Get("session_id")) + // 构造器已用最终 body prompt_cache_key 补出连字符 session-id,后置回退不再覆盖它。 + require.Equal(t, firstKey, upstream.requests[0].Header.Get("session-id")) + require.Empty(t, upstream.requests[0].Header.Get("session_id")) + require.Equal(t, upstream.requests[0].Header.Get("session-id"), upstream.requests[1].Header.Get("session-id")) } func decodeAgentAssertionTask(t *testing.T, header string) string { diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index 686b75753590..d3a0c55bbbfb 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -768,12 +768,12 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c } apiKeyID := getAPIKeyIDFromContext(c) if sessionID := strings.TrimSpace(gjson.GetBytes(alphaBody, "id").String()); sessionID != "" { - isolated := isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionID) - req.Header.Set("Session_ID", isolated) - req.Header.Set("Conversation_ID", isolated) + // 搜索会话 id 以连字符会话头(UUID 形态)补齐缺失的会话头;账号/Key 作用域改写由 + // 下面的 applyCodexAccountIdentityHeaders 统一完成,与其他 Codex 路径一致。 + fillCodexSessionIdentityHeaders(req.Header, generateSessionUUID(sessionID)) } applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), apiKeyID) - enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) + enforceCodexIdentityHeadersForAccount(req.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)) account.ApplyHeaderOverrides(req.Header) return req, nil } @@ -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..64d3ec0d932e 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, - )) + ) } -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"}, +// 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() +} + +// 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,41 @@ func applyCodexAccountIdentityHeaders(headers http.Header, account *Account, api } } } + +// ensureCodexSessionIdentityHeaders 补齐真实 Codex 客户端必带、但入站请求可能缺失的 +// 连字符会话头:session-id 缺失时取最终(已按账号作用域改写的)会话标识, +// thread-id 缺失时等于 session-id(root 线程不变式),x-client-request-id 缺失时等于 +// thread-id。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 + } + h.Del("session_id") + h.Del("conversation_id") + ensureCodexSessionIdentityHeaders(h, fallbackSessionID) +} + +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..76f1e45fbd6d --- /dev/null +++ b/backend/internal/service/openai_codex_client_identity.go @@ -0,0 +1,373 @@ +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 + } + // 任务内取全部状态的 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) + } + 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..e73c6a724b8f --- /dev/null +++ b/backend/internal/service/openai_codex_client_identity_test.go @@ -0,0 +1,236 @@ +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" +) + +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 +} + +// 凭据面与推理面共用显式 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, "无父账号时跟随自身身份") +} + +// 兼容 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) }) + 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"} + 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..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 @@ -248,3 +267,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..e2b342eb8c1c 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() + overrideUA = codexAccountIdentityOverrideUA(credAccount) + 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..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) @@ -1101,7 +1146,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() @@ -1113,9 +1158,9 @@ 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], 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 +1211,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) }) } } @@ -1206,10 +1251,10 @@ 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], 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}`) @@ -1221,10 +1266,10 @@ 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], 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()) } @@ -1265,7 +1310,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 +1324,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 +1367,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 +1381,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()) @@ -1379,7 +1424,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_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index f191d3d7725f..4c50656968a3 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -430,7 +430,13 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( if !compatPromptCacheTenantIsolated { sessionKey = isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), upstreamPromptCacheKey) } - upstreamReq.Header.Set("session_id", generateSessionUUID(sessionKey)) + if account.UsesOpenAICodexProtocol() { + // Codex 协议账号:最终形态只有连字符会话头;构造器已就位的连字符头保留, + // 缺失时才用隔离会话 ID 补齐。 + fillCodexSessionIdentityHeaders(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 9951d6a47e23..1423ac337253 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -596,7 +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 := s.resolveStagedCodexFingerprintIDs(c, account, clientHeaders) if fpIDs != nil { if applyCodexFingerprintClientMetadata(decoded, fpIDs) { markDecodedModified() @@ -1578,8 +1578,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 +1600,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 +1621,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 @@ -1659,5 +1657,81 @@ 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 { + 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 + } + 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 69026f7fee22..2e4670c73a74 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -386,9 +386,15 @@ 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 协议账号:最终形态只有连字符会话头;构造器已就位的连字符头保留, + // 缺失时才用按 API Key 隔离的会话 ID 补齐。 + fillCodexSessionIdentityHeaders(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 { @@ -396,7 +402,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..8699237a8c90 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 字节外科 @@ -244,7 +254,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( if c != nil && c.Request != nil { clientHeaders = c.Request.Header } - fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) + fpIDs := s.resolveStagedCodexFingerprintIDs(c, account, clientHeaders) if fpIDs != nil { fpBody, fpChanged, fpErr := applyCodexFingerprintClientMetadataRaw(body, fpIDs) if fpErr != nil { @@ -859,11 +869,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 +902,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..c1663630e224 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 拒绝时记录的请求头白名单(仅用于诊断日志,不参与上游透传) @@ -582,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_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..0c79c12a5c48 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, codexAccountIdentityOverrideUA(account)) + 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..7aa52223b3e0 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}`) @@ -227,9 +227,9 @@ 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], 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.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 1f85864f7368..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", }, } @@ -1677,11 +1693,21 @@ 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)) + // 握手 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 437be02069cf..b8759b101e80 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,16 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( } applyCodexAccountIdentityHeaders(headers, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) applyStagedCodexFingerprintHeaders(c, account, headers) + if account != nil && account.UsesOpenAICodexProtocol() { + // 入站缺失连字符会话头时补齐:来自帧体 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() { if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil { @@ -177,7 +179,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_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_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..a0ee7e1624f3 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -835,6 +835,18 @@ 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 + } + 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 { @@ -1099,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 { diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 540d9115f8de..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 := resolveCodexOutboundIdentity(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) - enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent()) + enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) 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: '完全收敛',