diff --git a/.downstream/native-domains.md b/.downstream/native-domains.md index 9e3bf1934061..8a940543b523 100644 --- a/.downstream/native-domains.md +++ b/.downstream/native-domains.md @@ -17,6 +17,49 @@ other key or a non-boolean value (including `null`), returns HTTP 400 and stores nothing. While no Codex runtime row is stored, `gateway.openai_codex_request_zstd` decides the compression switch. +## Codex fingerprint simulation + +Extensions owns `/admin/codex-fingerprint`. General settings and account +create/edit/bulk-edit forms no longer submit fingerprint configuration. +`GET/PUT /admin/settings/codex-fingerprint` uses `enabled`, `user_agent`, +`client_version` and `version_auto_sync_enabled`; all four fields are required +on PUT. GET also returns the synced and effective simulation versions and the +default UA. Existing UA/version/sync setting keys remain authoritative; only +`codex_fingerprint_enabled` is new. Without that key the startup identity flag +provides the default. Writes use existing admin authentication and audit, with +no additional step-up. Legacy settings APIs use the same keys. + +`GET/PUT /admin/accounts/:id/codex-fingerprint` applies only to OpenAI OAuth and +setup-token accounts. GET computes effective identity through the forwarding +selector, including credential shadows. PUT accepts only `mode`; bulk changes +use ordinary account jobs with explicit frozen IDs and only that extra key. +General account edits preserve the latest stored mode when it is omitted. +Existing seeds and persisted TUI identities are retained, and new accounts still +default to `device`. + +Enabled simulation applies a fixed account TUI identity to HTTP, passthrough, +WS, account tests and credential refresh. Disabled simulation preserves caller +identity and identifiers, filling only missing protocol identity headers. +Mode `off` preserves device/session identifiers while simulation stays enabled; +`device` changes only device identifiers; `session` keeps separate real threads +and maps their parent references; `full` combines threads and removes self-parent +references. Real turn IDs, parent/root turn IDs, window indices and timestamps +are retained. Sandbox `none`, `external`, permission modes and unknown tags are +preserved; known platform backends are aligned without inventing a sandbox for +an absent tag. Explicit custom cache keys are not rewritten. + +The built-in protocol/version baseline is official Codex `rust-v0.161.0`. +The existing six-hour stable-version sync and manual-version precedence remain. +Successful saves publish an immutable policy; failed saves retain the old one. +Each attempt shares it across headers/body. WS reuse includes identity and +policy revision. Setting changes let the current turn finish, then request a +retryable reconnect before another turn is sent through an old handshake. +Account mode changes invalidate only that account's next handshake. + +Account connection tests exclude and reject `ultra` across every capability +source before dispatch; stale UI selections reset to default. Codex client +orchestration catalogs retain their existing capabilities. + ## Codex gateway borrowing The Extensions sidebar has separate Codex Borrowing and Pelican Tests entries. diff --git a/backend/internal/handler/admin/codex_fingerprint_handler.go b/backend/internal/handler/admin/codex_fingerprint_handler.go new file mode 100644 index 000000000000..c44d1462edc0 --- /dev/null +++ b/backend/internal/handler/admin/codex_fingerprint_handler.go @@ -0,0 +1,95 @@ +package admin + +import ( + "strconv" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +func (h *SettingHandler) GetCodexFingerprintSettings(c *gin.Context) { + view, err := h.settingService.GetCodexFingerprintSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + c.Header("Cache-Control", "no-store") + response.Success(c, view) +} + +func (h *SettingHandler) UpdateCodexFingerprintSettings(c *gin.Context) { + raw, err := c.GetRawData() + var config service.CodexFingerprintSettings + if err == nil { + config, err = service.DecodeCodexFingerprintSettings(raw) + } + if err != nil { + response.BadRequest(c, "Invalid Codex fingerprint settings: "+err.Error()) + return + } + view, err := h.settingService.UpdateCodexFingerprintSettings(c.Request.Context(), config) + if err != nil { + response.ErrorFrom(c, err) + return + } + middleware.SetAuditExtra(c, map[string]any{"enabled": config.Enabled, "client_version": config.ClientVersion, "version_auto_sync_enabled": config.VersionAutoSyncEnabled}) + response.Success(c, view) +} + +func (h *AccountHandler) codexFingerprintAccount(c *gin.Context) (*service.Account, *service.Account, error) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + return nil, nil, infraerrors.BadRequest("INVALID_ACCOUNT_ID", "invalid account ID") + } + account, err := h.adminService.GetAccount(c.Request.Context(), id) + if err != nil { + return nil, nil, err + } + if !account.IsOpenAIOAuthLike() { + return nil, nil, infraerrors.BadRequest("CODEX_FINGERPRINT_UNSUPPORTED_ACCOUNT", "Codex fingerprint settings require an OpenAI OAuth or setup-token account") + } + source := account + if account.IsShadow() { + source, err = h.adminService.GetAccount(c.Request.Context(), *account.ParentAccountID) + if err != nil { + return nil, nil, err + } + } + return account, source, nil +} + +func (h *AccountHandler) GetCodexFingerprint(c *gin.Context) { + account, source, err := h.codexFingerprintAccount(c) + if err != nil { + response.ErrorFrom(c, err) + return + } + c.Header("Cache-Control", "no-store") + response.Success(c, service.DescribeCodexFingerprintAccount(account, source)) +} + +func (h *AccountHandler) UpdateCodexFingerprint(c *gin.Context) { + raw, err := c.GetRawData() + var mode string + if err == nil { + mode, err = service.DecodeCodexFingerprintMode(raw) + } + if err != nil { + response.BadRequest(c, "Invalid Codex fingerprint mode: "+err.Error()) + return + } + account, _, err := h.codexFingerprintAccount(c) + if err != nil { + response.ErrorFrom(c, err) + return + } + if err := h.adminService.UpdateAccountExtra(c.Request.Context(), account.ID, map[string]any{"codex_fingerprint_mode": mode}); err != nil { + response.ErrorFrom(c, err) + return + } + middleware.SetAuditExtra(c, map[string]any{"account_id": account.ID, "mode": mode}) + h.GetCodexFingerprint(c) +} diff --git a/backend/internal/handler/admin/codex_fingerprint_handler_test.go b/backend/internal/handler/admin/codex_fingerprint_handler_test.go new file mode 100644 index 000000000000..6e1adb9706a0 --- /dev/null +++ b/backend/internal/handler/admin/codex_fingerprint_handler_test.go @@ -0,0 +1,64 @@ +package admin + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type fingerprintAccountAdmin struct { + service.AdminService + accounts map[int64]*service.Account + updates map[string]any +} + +func (s *fingerprintAccountAdmin) GetAccount(_ context.Context, id int64) (*service.Account, error) { + return s.accounts[id], nil +} +func (s *fingerprintAccountAdmin) UpdateAccountExtra(_ context.Context, id int64, updates map[string]any) error { + s.updates = updates + for key, value := range updates { + s.accounts[id].Extra[key] = value + } + return nil +} + +func TestCodexFingerprintAccountHandlerOnlyChangesModeAndUsesParentIdentity(t *testing.T) { + parentID := int64(1) + parent := &service.Account{ID: 1, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Extra: map[string]any{"codex_fingerprint_seed": "ac6a7159-d158-41e8-bd52-3e66fd5c6515", "codex_fingerprint_mode": "device"}} + shadow := &service.Account{ID: 2, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, ParentAccountID: &parentID, Extra: map[string]any{"codex_fingerprint_mode": "full", "preserved": "value"}} + admin := &fingerprintAccountAdmin{accounts: map[int64]*service.Account{1: parent, 2: shadow}} + h := &AccountHandler{adminService: admin} + call := func(method, body string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(method, "/api/v1/admin/accounts/2/codex-fingerprint", strings.NewReader(body)) + c.Params = gin.Params{{Key: "id", Value: "2"}} + if method == http.MethodGet { + h.GetCodexFingerprint(c) + } else { + h.UpdateCodexFingerprint(c) + } + return rec + } + read := call(http.MethodGet, "") + require.Equal(t, http.StatusOK, read.Code) + require.Equal(t, int64(1), gjson.Get(read.Body.String(), "data.identity.identity_account_id").Int()) + saved := call(http.MethodPut, `{"mode":"off"}`) + require.Equal(t, http.StatusOK, saved.Code) + require.Equal(t, map[string]any{"codex_fingerprint_mode": "off"}, admin.updates) + require.Equal(t, "value", shadow.Extra["preserved"]) + require.Equal(t, "device", parent.Extra["codex_fingerprint_mode"]) + for _, body := range []string{`{"mode":"invalid"}`, `{"mode":"full","codex_fingerprint_seed":"injected"}`, `{"mode":null}`} { + rejected := call(http.MethodPut, body) + require.Equal(t, http.StatusBadRequest, rejected.Code) + require.Equal(t, "off", shadow.Extra["codex_fingerprint_mode"]) + } +} diff --git a/backend/internal/server/middleware/audit_log.go b/backend/internal/server/middleware/audit_log.go index 35d399eb5a2c..cb0dd453f970 100644 --- a/backend/internal/server/middleware/audit_log.go +++ b/backend/internal/server/middleware/audit_log.go @@ -147,6 +147,8 @@ var auditActionOverrides = map[string]string{ "PUT /api/v1/admin/system-prompts": "admin.system_prompts.update", "PUT /api/v1/admin/system-prompts/bindings": "admin.system_prompts.bindings.update", "PUT /api/v1/admin/reasoning-recovery": "admin.reasoning_recovery.update", + "PUT /api/v1/admin/settings/codex-fingerprint": "admin.codex_fingerprint.update", + "PUT /api/v1/admin/accounts/:id/codex-fingerprint": "admin.account.codex_fingerprint.update", } // auditBodyOmittedRoutes 请求体几乎整体由凭证构成的路由(如整块粘贴 auth JSON 的导入接口)。 diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 4e2007bd58e5..397eaafb90f9 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -445,6 +445,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS) accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS) accounts.PUT("/:id", h.Admin.Account.Update) + accounts.GET("/:id/codex-fingerprint", h.Admin.Account.GetCodexFingerprint) + accounts.PUT("/:id/codex-fingerprint", h.Admin.Account.UpdateCodexFingerprint) accounts.GET("/:id/grok-media-eligibility", h.Admin.Account.GetGrokMediaEligibility) accounts.PUT("/:id/grok-media-eligibility", h.Admin.Account.UpdateGrokMediaEligibility) accounts.PUT("/:id/upstream-billing-probe", h.Admin.Account.SetUpstreamBillingProbeEnabled) @@ -645,6 +647,8 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpA adminSettings.GET("/observability", h.Admin.Setting.GetObservabilitySettings) adminSettings.PUT("/observability", gin.HandlerFunc(stepUpAuth), h.Admin.Setting.UpdateObservabilitySettings) adminSettings.GET("/codex-runtime", h.Admin.Setting.GetCodexRuntimeSettings) + adminSettings.GET("/codex-fingerprint", h.Admin.Setting.GetCodexFingerprintSettings) + adminSettings.PUT("/codex-fingerprint", h.Admin.Setting.UpdateCodexFingerprintSettings) adminSettings.PUT("/codex-runtime", gin.HandlerFunc(stepUpAuth), h.Admin.Setting.UpdateCodexRuntimeSettings) adminSettings.POST("/test-smtp", h.Admin.Setting.TestSMTPConnection) adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail) diff --git a/backend/internal/service/account_test_reasoning.go b/backend/internal/service/account_test_reasoning.go index 7c8c4adfe46d..9fe7acf40f73 100644 --- a/backend/internal/service/account_test_reasoning.go +++ b/backend/internal/service/account_test_reasoning.go @@ -17,7 +17,17 @@ const accountTestReasoningContextKey = "account_test_reasoning_effort" // AccountTestReasoningOptions uses the same account mapping and capability // sources as the model catalog. Unknown model capabilities stay unknown. -func AccountTestReasoningOptions(account *Account, model string) ([]string, string) { +func AccountTestReasoningOptions(account *Account, model string) (efforts []string, defaultEffort string) { + // Ultra coordinates client-side work; it is never a single inference effort. + // Filter every capability source, including aliases and upstream metadata. + defer func() { + efforts = slices.DeleteFunc(slices.Clone(efforts), func(effort string) bool { + return normalizeReasoningLevel(effort) == "ultra" + }) + if normalizeReasoningLevel(defaultEffort) == "ultra" { + defaultEffort = "" + } + }() if account == nil || strings.TrimSpace(model) == "" { return nil, "" } @@ -56,10 +66,6 @@ func AccountTestReasoningOptions(account *Account, model string) ([]string, stri } out := make([]string, 0, len(levels)) for _, level := range levels { - // Ultra is client orchestration, never a native inference effort. - if level.Effort == "ultra" && isOpenAIGPT6SolOrLunaModel(model) { - continue - } out = append(out, level.Effort) } return out, "" @@ -109,6 +115,9 @@ func ValidateAccountTestReasoning(account *Account, model, mode, effort string) if effort == "" { return nil } + if normalizeReasoningLevel(effort) == "ultra" { + return errors.New("reasoning effort is not supported by the selected account model") + } if effort != strings.TrimSpace(effort) || len(effort) > 32 || (mode != "" && mode != AccountTestModeDefault && mode != AccountTestModeGrokText) { return errors.New("reasoning effort is unsupported for this test mode") } diff --git a/backend/internal/service/account_test_reasoning_test.go b/backend/internal/service/account_test_reasoning_test.go index 6b5451f4efa9..771133572681 100644 --- a/backend/internal/service/account_test_reasoning_test.go +++ b/backend/internal/service/account_test_reasoning_test.go @@ -13,11 +13,14 @@ import ( func TestAccountTestReasoningValidatedAgainstMappedModel(t *testing.T) { a := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"model_mapping": map[string]any{"friendly": "gpt-6-astra"}}} levels, _ := AccountTestReasoningOptions(a, "friendly") - require.Contains(t, levels, "ultra") - require.NoError(t, ValidateAccountTestReasoning(a, "friendly", "default", "ultra")) - require.NoError(t, ValidateAccountTestReasoning(a, "friendly", "text", "ultra")) - require.EqualError(t, ValidateAccountTestReasoning(a, "friendly", "compact", "ultra"), "reasoning effort is unsupported for this test mode") - require.EqualError(t, ValidateAccountTestReasoning(a, "friendly", "default", " ultra "), "reasoning effort is unsupported for this test mode") + require.NotContains(t, levels, "ultra") + require.NoError(t, ValidateAccountTestReasoning(a, "friendly", "default", "max")) + require.NoError(t, ValidateAccountTestReasoning(a, "friendly", "text", "max")) + require.EqualError(t, ValidateAccountTestReasoning(a, "friendly", "compact", "max"), "reasoning effort is unsupported for this test mode") + for _, mode := range []string{"default", "text", "compact"} { + require.EqualError(t, ValidateAccountTestReasoning(a, "friendly", mode, "ultra"), "reasoning effort is not supported by the selected account model") + } + require.Error(t, ValidateAccountTestReasoning(a, "friendly", "default", " ultra ")) require.EqualError(t, ValidateAccountTestReasoning(a, "friendly", "default", "invented"), "reasoning effort is not supported by the selected account model") require.Error(t, ValidateAccountTestReasoning(a, "gpt-image-2", "default", "high")) require.NoError(t, ValidateAccountTestReasoning(a, "unknown-model", "default", "")) diff --git a/backend/internal/service/account_test_reasoning_wire_test.go b/backend/internal/service/account_test_reasoning_wire_test.go index f35dedb034c9..53db38bd1105 100644 --- a/backend/internal/service/account_test_reasoning_wire_test.go +++ b/backend/internal/service/account_test_reasoning_wire_test.go @@ -27,22 +27,26 @@ func TestAccountTestReasoningSurvivesMappingAndWireCompression(t *testing.T) { svc := &AccountTestService{accountRepo: &reasoningTestRepo{account: a}, httpUpstream: upstream} c, rec := newTestContext() enableCodexRequestZstd(t) - require.NoError(t, svc.TestAccountConnection(c, a.ID, "friendly", "test", AccountTestModeDefault, AccountTestOptions{ReasoningEffort: "ultra"})) + require.NoError(t, svc.TestAccountConnection(c, a.ID, "friendly", "test", AccountTestModeDefault, AccountTestOptions{ReasoningEffort: "max"})) require.Len(t, upstream.requests, 1) raw, err := io.ReadAll(upstream.requests[0].Body) require.NoError(t, err) body := zstdDecodeForTest(t, raw) require.Equal(t, "gpt-6-astra", gjson.GetBytes(body, "model").String()) - require.Equal(t, "ultra", gjson.GetBytes(body, "reasoning.effort").String()) - require.Contains(t, rec.Body.String(), `"effective_reasoning_effort":"ultra"`) + require.Equal(t, "max", gjson.GetBytes(body, "reasoning.effort").String()) + require.Contains(t, rec.Body.String(), `"effective_reasoning_effort":"max"`) c, rec = newTestContext() require.Error(t, svc.TestAccountConnection(c, a.ID, "friendly", "test", AccountTestModeCompact, AccountTestOptions{ReasoningEffort: "ultra"})) - require.Contains(t, rec.Body.String(), `"error":"reasoning effort is unsupported for this test mode"`) + require.Contains(t, rec.Body.String(), `"error":"reasoning effort is not supported by the selected account model"`) require.Len(t, upstream.requests, 1, "unsupported mode must fail before any upstream request") c, rec = newTestContext() require.Error(t, svc.TestAccountConnection(c, a.ID, "friendly", "test", AccountTestModeDefault, AccountTestOptions{ReasoningEffort: "invented"})) require.Contains(t, rec.Body.String(), `"error":"reasoning effort is not supported by the selected account model"`) require.Len(t, upstream.requests, 1, "unsupported effort must fail before any upstream request") + c, rec = newTestContext() + require.Error(t, svc.TestAccountConnection(c, a.ID, "friendly", "test", AccountTestModeDefault, AccountTestOptions{ReasoningEffort: "ultra"})) + require.Contains(t, rec.Body.String(), `"error":"reasoning effort is not supported by the selected account model"`) + require.Len(t, upstream.requests, 1, "ultra must fail before any upstream request") } func TestAccountTestReasoningOpenCodeGoSerializesNativeProtocol(t *testing.T) { diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 980025e7fe67..a0b47c38d852 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -996,7 +996,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) // 与真实转发一致:使用该账号的 Codex TUI 身份,账号级自定义 UA 同样作为管理员 // 显式配置传入,否则测试用的身份与该账号真实出站的身份不是同一个。 - enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) + enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount), codexFingerprintPolicyForContext(c, account)) } // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 @@ -1017,7 +1017,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account } else if req, err = prepareOpenAICodexWireRequest(req, account); err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) } - base := resolveCodexIdentitySnapshot(account, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) + base := resolveCodexIdentitySnapshot(account, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount), codexFingerprintPolicyForContext(c, account)) snapshot := base.withWire(transport, req.Header) s.sendEvent(c, TestEvent{Type: "status", Text: snapshot.summary(), Data: snapshot}) s.sendEvent(c, TestEvent{Type: "status", Text: "Codex wire headers", Data: accountTestWireHeaders(req.Header)}) @@ -2347,7 +2347,7 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account if isOAuth { // 与真实转发一致:使用该账号的 Codex TUI 身份,账号级自定义 UA 经 ForceCodexCLI // 策略过滤后作为管理员显式配置传入(同普通 OAuth 连接测试)。 - enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) + enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount), codexFingerprintPolicyForContext(c, account)) } probeSessionID := compactProbeSessionID(account.ID) req.Header.Set("Session_ID", probeSessionID) @@ -2359,7 +2359,7 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account // 指纹收敛:探测与真实转发走同一个 /responses 端点,身份也必须同构, // 否则探测流量会以「缺 x-codex-installation-id + 非收敛 session」的 // 形态暴露在上游眼里。账号关闭收敛(off)时返回 nil,探测保持原样。 - if fpIDs := resolveCodexFingerprintIDsFromRequest(account, req.Header); fpIDs != nil { + if fpIDs := resolveCodexFingerprintIDsFromRequest(account, req.Header, codexFingerprintPolicyForContext(c, account)); fpIDs != nil { applyCodexFingerprintHeaders(req.Header, fpIDs) } } @@ -3043,7 +3043,7 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) // 与真实转发一致(同普通 OAuth 连接测试):使用该账号的 Codex TUI 身份,账号级自定义 UA // 经 ForceCodexCLI 策略过滤后作为管理员显式配置传入。 - enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) + enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount), codexFingerprintPolicyForContext(c, account)) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index 183ec71e52d6..813e47a2b6e8 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -1015,7 +1015,13 @@ func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, upd if len(updates) == 0 { return nil } - return s.accountRepo.UpdateExtra(ctx, id, updates) + if err := s.accountRepo.UpdateExtra(ctx, id, updates); err != nil { + return err + } + if _, changed := updates[codexFingerprintModeExtraKey]; changed { + notifyCodexFingerprintAccountChanged(id) + } + return nil } // BulkUpdateAccounts updates multiple accounts in one request. diff --git a/backend/internal/service/codex_fingerprint_policy_test.go b/backend/internal/service/codex_fingerprint_policy_test.go new file mode 100644 index 000000000000..83b12fabc0a8 --- /dev/null +++ b/backend/internal/service/codex_fingerprint_policy_test.go @@ -0,0 +1,229 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "maps" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type codexFingerprintSettingsRepo struct { + SettingRepository + values map[string]string + fail error + writes int +} + +func (r *codexFingerprintSettingsRepo) GetMultiple(context.Context, []string) (map[string]string, error) { + return maps.Clone(r.values), nil +} +func (r *codexFingerprintSettingsRepo) SetMultiple(_ context.Context, updates map[string]string) error { + if r.fail != nil { + return r.fail + } + r.writes++ + for key, value := range updates { + r.values[key] = value + } + return nil +} + +func fingerprintPolicyTestState(t *testing.T) { + t.Helper() + previous := publishedCodexFingerprintPolicy.Load() + t.Cleanup(func() { publishedCodexFingerprintPolicy.Store(previous) }) + publishedCodexFingerprintPolicy.Store(nil) +} + +func fingerprintTestPolicy(enabled bool, version string) *codexFingerprintPolicy { + ua := buildCodexCLIUserAgent(version) + return &codexFingerprintPolicy{enabled: enabled, userAgent: ua, revision: codexFingerprintPolicyRevision(enabled, ua)} +} + +func fingerprintTestAccount(mode string) *Account { + return &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{ + codexFingerprintModeExtraKey: mode, codexFingerprintSeedExtraKey: "ac6a7159-d158-41e8-bd52-3e66fd5c6515", + }} +} + +func TestCodexFingerprintSettingsPreserveStorageAndPublishOnlyAfterSave(t *testing.T) { + fingerprintPolicyTestState(t) + repo := &codexFingerprintSettingsRepo{values: map[string]string{SettingKeyOpenAICodexClientVersionSynced: "0.161.0"}} + svc := NewSettingService(repo, &config.Config{}) + require.NoError(t, svc.LoadCodexFingerprintSettings(context.Background())) + before := currentCodexFingerprintPolicy() + account := fingerprintTestAccount("session") + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + frozen := stageCodexFingerprintPolicy(c, account) + sent := CodexFingerprintSettings{Enabled: false, ClientVersion: "0.162.0", VersionAutoSyncEnabled: false} + repo.fail = errors.New("storage unavailable") + _, err := svc.UpdateCodexFingerprintSettings(context.Background(), sent) + require.Error(t, err) + require.Same(t, before, currentCodexFingerprintPolicy()) + require.Zero(t, repo.writes) + repo.fail = nil + view, err := svc.UpdateCodexFingerprintSettings(context.Background(), sent) + require.NoError(t, err) + require.False(t, view.Enabled) + require.Equal(t, "0.162.0", view.EffectiveVersion) + require.Equal(t, "0.161.0", repo.values[SettingKeyOpenAICodexClientVersionSynced]) + require.False(t, currentCodexFingerprintPolicy().enabled) + require.Same(t, frozen, codexFingerprintPolicyForContext(c, account), "an active attempt keeps its policy") + other := fingerprintTestAccount("device") + other.ID = 43 + require.False(t, codexFingerprintPolicyForContext(c, other).enabled, "a new account attempt cannot inherit stale policy") +} + +func TestCodexFingerprintSettingsInitialFlagAndValidation(t *testing.T) { + fingerprintPolicyTestState(t) + repo := &codexFingerprintSettingsRepo{values: map[string]string{}} + cfg := &config.Config{} + cfg.Gateway.DisableCodexIdentityEnforcement = true + view, err := NewSettingService(repo, cfg).GetCodexFingerprintSettings(context.Background()) + require.NoError(t, err) + require.False(t, view.Enabled) + for _, raw := range []string{`null`, `[]`, `{}`, `{"enabled":null,"user_agent":"","client_version":"","version_auto_sync_enabled":true}`, `{"enabled":true,"user_agent":"bad\r\nUA","client_version":"","version_auto_sync_enabled":true}`, `{"enabled":true,"user_agent":"","client_version":"bad","version_auto_sync_enabled":true}`} { + _, err := DecodeCodexFingerprintSettings([]byte(raw)) + require.Error(t, err) + } + _, err = DecodeCodexFingerprintSettings([]byte(`{"enabled":true,"user_agent":"","client_version":"0.161.0","version_auto_sync_enabled":true}`)) + require.NoError(t, err) + for _, raw := range []string{`{"mode":"invented"}`, `{"mode":"device","codex_fingerprint_seed":"injected"}`, `{"mode":null}`} { + _, err := DecodeCodexFingerprintMode([]byte(raw)) + require.Error(t, err) + } +} + +func TestCodexFingerprintLogicalTurnReferencesAndPermissions(t *testing.T) { + policy := fingerprintTestPolicy(true, "0.161.0") + for _, mode := range []string{"device", "session", "full"} { + t.Run(mode, func(t *testing.T) { + account := fingerprintTestAccount(mode) + headers := http.Header{"Session-Id": {"client-session"}, "Thread-Id": {"child-thread"}, "X-Codex-Window-Id": {"child-thread:8"}, "X-Codex-Parent-Thread-Id": {"parent-thread"}} + metadata := map[string]any{"installation_id": "client-device", "session_id": "client-session", "thread_id": "child-thread", "parent_thread_id": "parent-thread", "parent_turn_id": "parent-turn", "root_turn_id": "root-turn", "turn_id": "real-turn", "turn_started_at_unix_ms": float64(12345), "window_id": "child-thread:8", "window_number": float64(8), "sandbox": "none", "sandbox_mode": "danger-full-access"} + raw, err := json.Marshal(metadata) + require.NoError(t, err) + headers.Set("x-codex-turn-metadata", string(raw)) + body := map[string]any{"prompt_cache_key": "custom-affinity", "client_metadata": map[string]any{"session_id": "client-session", "thread_id": "child-thread", "turn_id": "real-turn", "parent_turn_id": "parent-turn", "root_turn_id": "root-turn", "x-codex-parent-thread-id": "parent-thread", "x-codex-window-id": "child-thread:8", "x-codex-turn-metadata": string(raw)}} + ids := resolveCodexFingerprintIDsFromRequest(account, headers, policy) + ids.alignSandboxWithUserAgent("codex-tui/0.161.0 (Ubuntu 24.4.0; x86_64) xterm-256color") + require.True(t, applyCodexFingerprintClientMetadata(body, ids)) + applyCodexFingerprintHeaders(headers, ids) + wire, err := json.Marshal(body) + require.NoError(t, err) + require.Equal(t, "custom-affinity", gjson.GetBytes(wire, "prompt_cache_key").String()) + require.Equal(t, "real-turn", gjson.GetBytes(wire, "client_metadata.turn_id").String()) + var embedded map[string]any + require.NoError(t, json.Unmarshal([]byte(gjson.GetBytes(wire, "client_metadata.x-codex-turn-metadata").String()), &embedded)) + require.Equal(t, "real-turn", embedded["turn_id"]) + require.Equal(t, "parent-turn", embedded["parent_turn_id"]) + require.Equal(t, "root-turn", embedded["root_turn_id"]) + require.Equal(t, float64(12345), embedded["turn_started_at_unix_ms"]) + require.Equal(t, "none", embedded["sandbox"]) + require.Equal(t, float64(8), embedded["window_number"]) + if mode == "device" { + require.Equal(t, "child-thread", embedded["thread_id"]) + } else { + require.Equal(t, headers.Get("thread-id"), embedded["thread_id"]) + require.Equal(t, headers.Get("x-codex-window-id"), embedded["window_id"]) + require.Contains(t, embedded["window_id"], ":8") + if mode == "full" { + require.NotContains(t, embedded, "parent_thread_id") + require.Empty(t, headers.Get("x-codex-parent-thread-id")) + } else { + require.Equal(t, ids.mapThread("parent-thread"), embedded["parent_thread_id"]) + } + } + }) + } +} + +func TestCodexFingerprintMasterOffKeepsClientAndPreventsWSReuse(t *testing.T) { + fingerprintPolicyTestState(t) + account := fingerprintTestAccount("full") + on, off := fingerprintTestPolicy(true, "0.161.0"), fingerprintTestPolicy(false, "0.162.0") + publishedCodexFingerprintPolicy.Store(off) + client := http.Header{"User-Agent": {"codex-tui/0.150.0 (Ubuntu 24.4.0; x86_64) xterm-256color"}, "Originator": {"codex-tui"}, "Version": {"0.150.0"}, "Session-Id": {"client-session"}, "Thread-Id": {"client-thread"}} + raw := []byte(`{"client_metadata":{"session_id":"client-session","thread_id":"client-thread","turn_id":"real-turn"},"prompt_cache_key":"custom"}`) + result, changed, err := applyCodexAccountIdentityClientMetadataRaw(raw, account, 7, off) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, raw, result) + require.Nil(t, resolveCodexFingerprintIDsFromRequest(account, client, off)) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/responses", nil) + c.Request.Header = client + stageCodexFingerprintPolicy(c, account) + upstream := http.Header{"User-Agent": {codexCLIUserAgent}, "Originator": {"codex_cli_rs"}, "Version": {codexCLIVersion}, "Session-Id": {"scoped-session"}} + svc := &OpenAIGatewayService{} + require.NoError(t, svc.finalizeCodexOutboundHeaders(context.Background(), c, account, upstream, "gpt-6-astra", "")) + for _, name := range []string{"user-agent", "originator", "version", "session-id", "thread-id"} { + require.Equal(t, client.Get(name), upstream.Get(name)) + } + require.NotEqual(t, normalizeOpenAIWSHandshakeCompatibility(account, client, on), normalizeOpenAIWSHandshakeCompatibility(account, client, off)) +} + +func TestCodexFingerprintTestEffortsExcludeUltraFromEverySource(t *testing.T) { + for _, typ := range []string{AccountTypeOAuth, AccountTypeAPIKey} { + for _, model := range []string{"gpt-6-astra", "gpt-6-sol", "gpt-6-luna", "gpt-6.1-sol", "upstream-only-model"} { + t.Run(typ+"/"+model, func(t *testing.T) { + account := &Account{Platform: PlatformOpenAI, Type: typ, Credentials: map[string]any{"model_mapping": map[string]any{"alias": model}}} + supported := true + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{model: {Reasoning: &supported, SupportedReasoningLevels: []string{"high", "ultra"}, DefaultReasoningLevel: "ultra"}}}) + levels, defaultEffort := AccountTestReasoningOptions(account, "alias") + require.Equal(t, []string{"high"}, levels) + require.Empty(t, defaultEffort) + require.Error(t, ValidateAccountTestReasoning(account, "alias", "default", "ultra")) + require.NoError(t, ValidateAccountTestReasoning(account, "alias", "default", "high")) + }) + } + } +} + +func TestCodexFingerprintAccountModeEpochAndGeneralEditPreservation(t *testing.T) { + fingerprintPolicyTestState(t) + account := fingerprintTestAccount("full") + previous, existed := codexFingerprintAccountEpochs.Load(account.ID) + t.Cleanup(func() { + if existed { + codexFingerprintAccountEpochs.Store(account.ID, previous) + } else { + codexFingerprintAccountEpochs.Delete(account.ID) + } + }) + before := currentCodexFingerprintPolicyForAccount(account) + other := fingerprintTestAccount("device") + other.ID = 99 + unaffected := currentCodexFingerprintPolicyForAccount(other) + notifyCodexFingerprintAccountChanged(account.ID) + require.NotEqual(t, before.revision, currentCodexFingerprintPolicyForAccount(account).revision) + require.Equal(t, unaffected.revision, currentCodexFingerprintPolicyForAccount(other).revision) + seed := account.Extra[codexFingerprintSeedExtraKey] + updated := prepareCodexFingerprintExtraForUpdate(account, map[string]any{"unrelated": true}) + require.Equal(t, "full", updated[codexFingerprintModeExtraKey]) + require.Equal(t, seed, updated[codexFingerprintSeedExtraKey]) + require.Equal(t, true, updated["unrelated"]) +} + +func TestCodexFingerprintSandboxAlignmentPreservesActualPolicy(t *testing.T) { + for _, backend := range []string{"none", "external", "custom-sandbox", ""} { + metadata := map[string]any{"sandbox": backend, "sandbox_mode": "read-only"} + require.False(t, alignCodexSandboxMetadata(metadata, "seatbelt")) + require.Equal(t, backend, metadata["sandbox"]) + } + metadata := map[string]any{"sandbox": "windows_mxc", "sandbox_mode": "workspace-write"} + require.False(t, alignCodexSandboxMetadata(metadata, "windows_sandbox")) + metadata = map[string]any{"sandbox": "seccomp", "sandbox_mode": "workspace-write", "thread_id": "original", "turn_id": "original-turn"} + require.True(t, alignCodexSandboxMetadata(metadata, "seatbelt")) + require.Equal(t, "workspace-write", metadata["sandbox_mode"]) + require.Equal(t, "original", metadata["thread_id"]) + require.Equal(t, "original-turn", metadata["turn_id"]) +} diff --git a/backend/internal/service/codex_outbound_contract.go b/backend/internal/service/codex_outbound_contract.go index 4cae91e4eb2c..ba830139c6ad 100644 --- a/backend/internal/service/codex_outbound_contract.go +++ b/backend/internal/service/codex_outbound_contract.go @@ -16,9 +16,35 @@ func (s *OpenAIGatewayService) finalizeCodexOutboundHeaders(ctx context.Context, if account == nil || !account.UsesOpenAICodexProtocol() { return nil } + policy := codexFingerprintPolicyForContext(c, account) if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil { return err } - enforceCodexIdentityHeadersForAccount(headers, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)) + if !policy.enabled && c != nil && c.Request != nil { + // Earlier builders provide protocol defaults and account UA overrides. + // With simulation off, restore the caller's identity before filling gaps. + for _, name := range []string{"user-agent", "originator", "version"} { + headers.Del(name) + if value := c.Request.Header.Get(name); value != "" { + headers.Set(name, value) + } + } + for _, name := range []string{"session-id", "thread-id", "x-client-request-id", "x-codex-window-id", "x-codex-parent-thread-id", "x-codex-turn-metadata"} { + if value := c.Request.Header.Get(name); value != "" { + headers.Set(name, value) + } + } + if !isOpenAICompatMessagesBridgeContext(c) { + ensureCodexIdentityHeaders(headers, policy) + } else { + headers.Del("originator") + } + } + enforceCodexIdentityHeadersForAccount(headers, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account), policy) + if policy.enabled { + if aligned, changed := alignCodexSandboxJSON(headers.Get("x-codex-turn-metadata"), codexSandboxForUserAgent(headers.Get("user-agent"))); changed { + headers.Set("x-codex-turn-metadata", aligned) + } + } return nil } diff --git a/backend/internal/service/openai_alpha_search_test.go b/backend/internal/service/openai_alpha_search_test.go index b677fe665d86..e3cb2dd6a8ee 100644 --- a/backend/internal/service/openai_alpha_search_test.go +++ b/backend/internal/service/openai_alpha_search_test.go @@ -95,11 +95,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, "search-session"), + "search-session", gjson.Get(upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"), "session_id").String(), ) require.Equal(t, - scopeCodexAccountIdentityValue(account, 0, "search-turn"), + "search-turn", gjson.Get(upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"), "turn_id").String(), ) require.JSONEq(t, string(body), string(upstream.lastBody)) @@ -168,7 +168,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-1"), + "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 4a45becefdc0..2dea0b27d86e 100644 --- a/backend/internal/service/openai_codex_account_identity.go +++ b/backend/internal/service/openai_codex_account_identity.go @@ -27,6 +27,7 @@ const codexAccountIdentitySourceContextKey = "openai_codex_account_identity_sour // attempt. The handler reuses gin.Context across failover attempts, so every entry // point overwrites the staged source before projecting outbound identity. func (s *OpenAIGatewayService) prepareCodexAccountIdentitySource(ctx context.Context, c *gin.Context, account *Account) (*Account, error) { + stageCodexFingerprintPolicy(c, account) source := account if account != nil && account.IsShadow() { resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account) @@ -162,19 +163,31 @@ var codexAccountIdentityFields = []string{ "session-id", "thread_id", "thread-id", - "turn_id", - "turn-id", "window_id", "x-codex-window-id", "x-client-request-id", + "parent_thread_id", + "x-codex-parent-thread-id", + "forked_from_thread_id", } -func applyCodexAccountIdentityFields(values map[string]any, account *Account, apiKeyID int64) bool { - if values == nil || codexAccountIdentityNamespace(account) == "" { +func codexAccountIdentityProjectionFields(account *Account, policy *codexFingerprintPolicy) []string { + if account == nil || !policy.enabled || account.GetCodexFingerprintMode() == codexFingerprintOff { + return nil + } + if account.GetCodexFingerprintMode() == codexFingerprintDevice { + return []string{"installation_id", "x-codex-installation-id"} + } + return codexAccountIdentityFields +} + +func applyCodexAccountIdentityFields(values map[string]any, account *Account, apiKeyID int64, policies ...*codexFingerprintPolicy) bool { + policy := selectCodexFingerprintPolicy(policies) + if values == nil || !policy.enabled || codexAccountIdentityNamespace(account) == "" { return false } changed := false - for _, name := range codexAccountIdentityFields { + for _, name := range codexAccountIdentityProjectionFields(account, policy) { raw, ok := values[name].(string) if !ok || strings.TrimSpace(raw) == "" { continue @@ -188,7 +201,7 @@ func applyCodexAccountIdentityFields(values map[string]any, account *Account, ap return changed } -func applyCodexAccountIdentityEmbeddedMetadata(values map[string]any, account *Account, apiKeyID int64) bool { +func applyCodexAccountIdentityEmbeddedMetadata(values map[string]any, account *Account, apiKeyID int64, policies ...*codexFingerprintPolicy) bool { raw, ok := values[openAIWSTurnMetadataHeader].(string) if !ok || strings.TrimSpace(raw) == "" { return false @@ -197,7 +210,7 @@ func applyCodexAccountIdentityEmbeddedMetadata(values map[string]any, account *A if err := json.Unmarshal([]byte(raw), &metadata); err != nil || metadata == nil { return false } - if !applyCodexAccountIdentityFields(metadata, account, apiKeyID) { + if !applyCodexAccountIdentityFields(metadata, account, apiKeyID, policies...) { return false } rebuilt, err := marshalCodexTurnMetadata(metadata) @@ -208,21 +221,23 @@ func applyCodexAccountIdentityEmbeddedMetadata(values map[string]any, account *A return true } -func applyCodexAccountIdentityClientMetadataMap(requestBody map[string]any, account *Account, apiKeyID int64) bool { - if requestBody == nil || codexAccountIdentityNamespace(account) == "" { +func applyCodexAccountIdentityClientMetadataMap(requestBody map[string]any, account *Account, apiKeyID int64, policies ...*codexFingerprintPolicy) bool { + policy := selectCodexFingerprintPolicy(policies) + if requestBody == nil || !policy.enabled || codexAccountIdentityNamespace(account) == "" { return false } changed := false clientMetadata, _ := requestBody["client_metadata"].(map[string]any) + originalSessionID := stringMetadataValue(clientMetadata, "session_id") if clientMetadata != nil { - if applyCodexAccountIdentityFields(clientMetadata, account, apiKeyID) { + if applyCodexAccountIdentityFields(clientMetadata, account, apiKeyID, policy) { changed = true } - if applyCodexAccountIdentityEmbeddedMetadata(clientMetadata, account, apiKeyID) { + if applyCodexAccountIdentityEmbeddedMetadata(clientMetadata, account, apiKeyID, policy) { changed = true } } - if raw, ok := requestBody["prompt_cache_key"].(string); ok && strings.TrimSpace(raw) != "" { + if raw, ok := requestBody["prompt_cache_key"].(string); ok && raw != "" && raw == originalSessionID && (account.GetCodexFingerprintMode() == codexFingerprintSession || account.GetCodexFingerprintMode() == codexFingerprintFull) { next := scopeCodexAccountIdentityValue(account, apiKeyID, raw) if next != raw { requestBody["prompt_cache_key"] = next @@ -235,8 +250,9 @@ func applyCodexAccountIdentityClientMetadataMap(requestBody map[string]any, acco // applyCodexAccountIdentityClientMetadataRaw scopes only the small identity // subobjects with gjson/sjson. The passthrough hot path never unmarshals the // potentially multi-megabyte request body. -func applyCodexAccountIdentityClientMetadataRaw(body []byte, account *Account, apiKeyID int64) ([]byte, bool, error) { - if len(body) == 0 || codexAccountIdentityNamespace(account) == "" { +func applyCodexAccountIdentityClientMetadataRaw(body []byte, account *Account, apiKeyID int64, policies ...*codexFingerprintPolicy) ([]byte, bool, error) { + policy := selectCodexFingerprintPolicy(policies) + if len(body) == 0 || !policy.enabled || codexAccountIdentityNamespace(account) == "" { return body, false, nil } root := gjson.ParseBytes(body) @@ -251,8 +267,8 @@ func applyCodexAccountIdentityClientMetadataRaw(body []byte, account *Account, a if err := json.Unmarshal([]byte(cm.Raw), &clientMetadata); err != nil { return body, false, fmt.Errorf("decode client_metadata for account identity: %w", err) } - metadataChanged := applyCodexAccountIdentityFields(clientMetadata, account, apiKeyID) - if applyCodexAccountIdentityEmbeddedMetadata(clientMetadata, account, apiKeyID) { + metadataChanged := applyCodexAccountIdentityFields(clientMetadata, account, apiKeyID, policy) + if applyCodexAccountIdentityEmbeddedMetadata(clientMetadata, account, apiKeyID, policy) { metadataChanged = true } if metadataChanged { @@ -268,7 +284,7 @@ func applyCodexAccountIdentityClientMetadataRaw(body []byte, account *Account, a changed = true } } - if promptCacheKey := gjson.GetBytes(body, "prompt_cache_key"); promptCacheKey.Type == gjson.String && strings.TrimSpace(promptCacheKey.String()) != "" { + if promptCacheKey := gjson.GetBytes(body, "prompt_cache_key"); promptCacheKey.Type == gjson.String && promptCacheKey.String() != "" && promptCacheKey.String() == gjson.GetBytes(body, "client_metadata.session_id").String() && (account.GetCodexFingerprintMode() == codexFingerprintSession || account.GetCodexFingerprintMode() == codexFingerprintFull) { raw := promptCacheKey.String() scoped := scopeCodexAccountIdentityValue(account, apiKeyID, raw) if scoped != raw { @@ -283,11 +299,12 @@ func applyCodexAccountIdentityClientMetadataRaw(body []byte, account *Account, a return next, changed, nil } -func applyCodexAccountIdentityHeaders(headers http.Header, account *Account, apiKeyID int64) { - if headers == nil || codexAccountIdentityNamespace(account) == "" { +func applyCodexAccountIdentityHeaders(headers http.Header, account *Account, apiKeyID int64, policies ...*codexFingerprintPolicy) { + policy := selectCodexFingerprintPolicy(policies) + if headers == nil || !policy.enabled || codexAccountIdentityNamespace(account) == "" { return } - for _, name := range codexAccountIdentityFields { + for _, name := range codexAccountIdentityProjectionFields(account, policy) { switch name { case "session_id": // Underscore session/conversation headers are rebuilt separately by the @@ -306,7 +323,7 @@ func applyCodexAccountIdentityHeaders(headers http.Header, account *Account, api } if raw := strings.TrimSpace(headers.Get(openAIWSTurnMetadataHeader)); raw != "" { metadata := map[string]any{} - if err := json.Unmarshal([]byte(raw), &metadata); err == nil && metadata != nil && applyCodexAccountIdentityFields(metadata, account, apiKeyID) { + if err := json.Unmarshal([]byte(raw), &metadata); err == nil && metadata != nil && applyCodexAccountIdentityFields(metadata, account, apiKeyID, policy) { if rebuilt, err := marshalCodexTurnMetadata(metadata); err == nil { headers.Set(openAIWSTurnMetadataHeader, string(rebuilt)) } diff --git a/backend/internal/service/openai_codex_account_identity_test.go b/backend/internal/service/openai_codex_account_identity_test.go index 2233790a7c64..d592ddb5e8b4 100644 --- a/backend/internal/service/openai_codex_account_identity_test.go +++ b/backend/internal/service/openai_codex_account_identity_test.go @@ -24,8 +24,8 @@ func (s *codexAccountIdentityRepoStub) GetByID(_ context.Context, _ int64) (*Acc func TestCodexRequestBodyIdentityNamespaceIsStablePerOAuthAccount(t *testing.T) { body := []byte(`{"model":"gpt-5.6-codex","prompt_cache_key":"client-session","client_metadata":{"x-codex-installation-id":"client-installation","session_id":"client-session","thread_id":"client-thread","x-codex-window-id":"client-window","x-codex-turn-metadata":"{\"installation_id\":\"client-installation\",\"session_id\":\"client-session\",\"thread_id\":\"client-thread\",\"turn_id\":\"client-turn\",\"window_id\":\"client-window\"}"}}`) - account11 := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-11"}} - account19 := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-19"}} + account11 := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{codexFingerprintModeExtraKey: "session"}, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-11"}} + account19 := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{codexFingerprintModeExtraKey: "session"}, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-19"}} first, changed, err := applyCodexAccountIdentityClientMetadataRaw(body, account11, 77) require.NoError(t, err) @@ -55,7 +55,9 @@ func TestCodexRequestBodyIdentityNamespaceIsStablePerOAuthAccount(t *testing.T) var embeddedSecond map[string]any require.NoError(t, json.Unmarshal([]byte(gjson.GetBytes(first, "client_metadata.x-codex-turn-metadata").String()), &embeddedFirst)) require.NoError(t, json.Unmarshal([]byte(gjson.GetBytes(second, "client_metadata.x-codex-turn-metadata").String()), &embeddedSecond)) - for _, field := range []string{"installation_id", "session_id", "thread_id", "turn_id", "window_id"} { + require.Equal(t, "client-turn", embeddedFirst["turn_id"], "logical turns are preserved") + require.Equal(t, "client-turn", embeddedSecond["turn_id"]) + for _, field := range []string{"installation_id", "session_id", "thread_id", "window_id"} { require.NotEqual(t, embeddedFirst[field], embeddedSecond[field], field) } } @@ -140,8 +142,8 @@ func TestBuildOpenAIWSHeadersNamespacesCodexIdentityByOAuthAccount(t *testing.T) c.Request.Header.Set("x-codex-window-id", "client-window") c.Request.Header.Set("x-client-request-id", "client-request") - account11 := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-11"}} - account19 := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-19"}} + account11 := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{codexFingerprintModeExtraKey: "session"}, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-11"}} + account19 := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{codexFingerprintModeExtraKey: "session"}, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-19"}} service := &OpenAIGatewayService{} build := func(account *Account) http.Header { headers, _, err := service.buildOpenAIWSHeaders( @@ -197,6 +199,7 @@ func TestBuildUpstreamRequestNamespacesCodexIdentityByOAuthAccount(t *testing.T) ID: accountID, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Extra: map[string]any{codexFingerprintModeExtraKey: "session"}, Credentials: map[string]any{ "chatgpt_account_id": chatgptAccountID, }, diff --git a/backend/internal/service/openai_codex_client_identity_test.go b/backend/internal/service/openai_codex_client_identity_test.go index 4b7f4dec773f..f074aa4f14d8 100644 --- a/backend/internal/service/openai_codex_client_identity_test.go +++ b/backend/internal/service/openai_codex_client_identity_test.go @@ -334,7 +334,7 @@ func TestEffectiveCodexOutboundUserAgentFollowsBridgeContract(t *testing.T) { require.Equal(t, codexSandboxForUserAgent(CodexCanonicalUserAgent()), svc.resolveStagedCodexFingerprintIDs(c, account, clientHeaders).sandbox, "桥接 + ForceCodexCLI:跟随规范 UA") } -// 全局强制统一关闭时终态只做配对、保留合法客户端 UA,staging 的 sandbox 必须跟随该客户端 UA。 +// 关闭整个模拟后不暂存收敛身份,保留客户端 UA 和实际 sandbox 元数据。 func TestStagedFingerprintSandboxFollowsPairedClientUAWhenEnforcementOff(t *testing.T) { t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) }) account := &Account{ID: 22, Platform: PlatformOpenAI, Type: AccountTypeOAuth, @@ -347,9 +347,11 @@ func TestStagedFingerprintSandboxFollowsPairedClientUAWhenEnforcementOff(t *test 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") + require.Nil(t, svc.resolveStagedCodexFingerprintIDs(nil, account, clientHeaders)) + require.Equal(t, clientHeaders.Get("user-agent"), svc.effectiveCodexOutboundUserAgent(nil, account, clientHeaders)) clientHeaders.Set("user-agent", "curl/8.0") - require.Equal(t, codexSandboxForUserAgent(resolveCodexOutboundIdentity("").userAgent), svc.resolveStagedCodexFingerprintIDs(nil, account, clientHeaders).sandbox, "不合法客户端 UA 回落规范身份") + require.Nil(t, svc.resolveStagedCodexFingerprintIDs(nil, account, clientHeaders)) + require.Equal(t, "curl/8.0", svc.effectiveCodexOutboundUserAgent(nil, account, clientHeaders)) } // turn metadata 的 sandbox 跟随最终出站 UA,而不是账号身份:管理员显式 UA 覆盖时二者可能不同。 diff --git a/backend/internal/service/openai_codex_fingerprint.go b/backend/internal/service/openai_codex_fingerprint.go index 1719ebc8ba49..ead5a63019ae 100644 --- a/backend/internal/service/openai_codex_fingerprint.go +++ b/backend/internal/service/openai_codex_fingerprint.go @@ -18,8 +18,7 @@ import ( // codexFingerprintIDsContextKey 是暂存在 gin context 的收敛 ID 集合键。 // 由 Forward(非透传)或 forwardOpenAIPassthrough(透传)解析后写入,请求 -// 构造器读取用于出站头改写——请求体与出站头必须共享同一份 IDs,保证 -// turn_id 等随机字段一致。 +// 构造器读取用于出站头改写——请求体与出站头必须共享同一份 IDs,保证头与体的设备、线程和窗口标识一致。 const codexFingerprintIDsContextKey = "codex_fingerprint_ids" // stageCodexFingerprintIDs 将本 attempt 解析出的收敛 ID 暂存到 gin context。 @@ -179,6 +178,13 @@ func prepareCodexFingerprintExtraForUpdate(account *Account, extra map[string]an if prepared == nil { prepared = make(map[string]any, 2) } + // General account edits omit the extension-owned mode. Preserve the latest + // stored value instead of resetting it or replaying a stale editor draft. + if _, submitted := prepared[codexFingerprintModeExtraKey]; !submitted { + if existing, exists := account.Extra[codexFingerprintModeExtraKey]; exists { + prepared[codexFingerprintModeExtraKey] = existing + } + } if seed, ok := codexFingerprintSeed(account.Extra); ok { prepared[codexFingerprintSeedExtraKey] = seed } else { @@ -280,18 +286,19 @@ func resolveConvergedThreadID(seed, clientSessionID string) string { // codexFingerprintIDs 收敛后的完整 ID 集合。 // 由 resolveCodexFingerprintIDs 一次性生成,同一个实例在头改写和体改写之间共享, -// 确保所有载体中的 turn_id 等随机字段一致。体改写时还会补记原始 +// 确保所有载体中的设备、线程和窗口标识一致。体改写时还会补记原始 // client_metadata.session_id,用于识别 root prompt_cache_key 的默认值。 type codexFingerprintIDs struct { + seed string accountID int64 mode codexFingerprintMode installationID string sessionID string threadID string - turnID string windowID string sandbox string - turnStartedAtUnixMs int64 + logicalMetadata map[string]any + bodyIdentityCaptured bool originalBodySessionID string originalBodySessionIDCaptured bool } @@ -300,7 +307,7 @@ type codexFingerprintIDs struct { // clientSessionID 是客户端原始的 session-id 头值(连字符形式),用于 session 模式下 // 的 thread_id 派生——每个真实 Codex 会话得到一个独立线程。 // 返回 nil 表示 off 模式,不需要改写。 -// 注意:包含随机生成的 turn_id,调用方必须只调用一次并共享结果给头改写和体改写。 +// 调用方共享同一份结果给头改写和体改写;真实回合 ID 与开始时间不生成、不改写。 func resolveCodexFingerprintIDs(account *Account, clientSessionID string, mode codexFingerprintMode) *codexFingerprintIDs { if account == nil || mode == codexFingerprintOff { return nil @@ -311,9 +318,9 @@ func resolveCodexFingerprintIDs(account *Account, clientSessionID string, mode c } ids := &codexFingerprintIDs{ - accountID: account.ID, - mode: mode, - turnStartedAtUnixMs: time.Now().UnixMilli(), + seed: seed, + accountID: account.ID, + mode: mode, } if identity, ok := account.CodexClientIdentity(); ok { ids.sandbox = identity.Sandbox @@ -334,14 +341,12 @@ func resolveCodexFingerprintIDs(account *Account, clientSessionID string, mode c if ids.threadID == "" { ids.threadID = ids.sessionID } - ids.turnID = uuid.Must(uuid.NewV7()).String() ids.windowID = ids.threadID + ":0" return ids case codexFingerprintFull: ids.sessionID = resolveConvergedSessionID(seed) ids.threadID = ids.sessionID - ids.turnID = uuid.Must(uuid.NewV7()).String() ids.windowID = ids.threadID + ":0" return ids } @@ -362,8 +367,9 @@ func extractClientSessionID(h http.Header) string { // resolveCodexFingerprintIDsFromRequest 从客户端原始请求头中提取 session-id, // 结合账号配置一次性解析收敛 ID 集合。调用方应将返回的 ids 同时传给 // applyCodexFingerprintHeaders 和 applyCodexFingerprintClientMetadata。 -func resolveCodexFingerprintIDsFromRequest(account *Account, clientHeaders http.Header) *codexFingerprintIDs { - if account == nil { +func resolveCodexFingerprintIDsFromRequest(account *Account, clientHeaders http.Header, policies ...*codexFingerprintPolicy) *codexFingerprintIDs { + policy := selectCodexFingerprintPolicy(policies) + if account == nil || !policy.enabled { return nil } mode := account.GetCodexFingerprintMode() @@ -374,7 +380,11 @@ func resolveCodexFingerprintIDsFromRequest(account *Account, clientHeaders http. if clientHeaders != nil { clientSessionID = extractClientSessionID(clientHeaders) } - return resolveCodexFingerprintIDs(account, clientSessionID, mode) + ids := resolveCodexFingerprintIDs(account, clientSessionID, mode) + if ids != nil { + ids.captureThreadAndWindow(clientHeaders.Get("thread-id"), clientHeaders.Get("x-codex-window-id")) + } + return ids } // applyCodexFingerprintHeaders 按预计算的收敛 ID 改写出站 HTTP 头中的设备指纹。 @@ -389,9 +399,7 @@ func applyCodexFingerprintHeaders(h http.Header, ids *codexFingerprintIDs) { h.Del("x-codex-installation-id") if ids.mode == codexFingerprintDevice { - rewriteCodexTurnMetadataFields(h, ids.turnMetadataFields(map[string]any{ - "installation_id": ids.installationID, - })) + ids.rewriteHeaderTurnMetadata(h) return } @@ -405,14 +413,14 @@ func applyCodexFingerprintHeaders(h http.Header, ids *codexFingerprintIDs) { h.Set("session_id", ids.sessionID) } - 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, - })) + if parent := h.Get("x-codex-parent-thread-id"); parent != "" { + if ids.mode == codexFingerprintFull { + h.Del("x-codex-parent-thread-id") + } else { + h.Set("x-codex-parent-thread-id", ids.mapThread(parent)) + } + } + ids.rewriteHeaderTurnMetadata(h) } // alignSandboxWithUserAgent 让 sandbox 跟随最终出站 UA 声明的系统(管理员显式 UA @@ -424,38 +432,6 @@ func (ids *codexFingerprintIDs) alignSandboxWithUserAgent(userAgent string) { 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, -// 替换指定字段后回写。合法对象保留未指定字段(如 sandbox、thread_source); -// 非法/非对象值重建为最小合法 metadata,避免 flat 与 embedded identity 分裂。 -func rewriteCodexTurnMetadataFields(h http.Header, fields map[string]any) { - raw := strings.TrimSpace(h.Get("x-codex-turn-metadata")) - if raw == "" { - return - } - var metadata map[string]any - if err := json.Unmarshal([]byte(raw), &metadata); err != nil || metadata == nil { - metadata = make(map[string]any, len(fields)) - } - for k, v := range fields { - metadata[k] = v - } - rebuilt, err := marshalCodexTurnMetadata(metadata) - if err != nil { - return - } - h.Set("x-codex-turn-metadata", string(rebuilt)) -} - // applyCodexFingerprintClientMetadata 按预计算的收敛 ID 改写请求体中的 client_metadata。 // 使用与头改写相同的 ids 实例,确保 turn_id 等随机字段一致。 func applyCodexFingerprintClientMetadata(reqBody map[string]any, ids *codexFingerprintIDs) bool { @@ -489,36 +465,233 @@ func applyCodexFingerprintToClientMetadataMap(existing map[string]any, ids *code } modified := false + // Body metadata describes the current logical turn. Use it consistently in + // the outgoing body and header without manufacturing a new turn or time. + ids.captureLogicalMetadata(existing) if ids.installationID != "" { existing["x-codex-installation-id"] = ids.installationID + if _, exists := existing["installation_id"]; exists { + existing["installation_id"] = ids.installationID + } modified = true } if ids.mode == codexFingerprintDevice { - rewriteClientMetadataEmbeddedTurnMetadata(existing, ids.turnMetadataFields(map[string]any{ - "installation_id": ids.installationID, - })) + ids.rewriteEmbeddedTurnMetadata(existing) return modified } // session / full 模式 existing["session_id"] = ids.sessionID existing["thread_id"] = ids.threadID - existing["turn_id"] = ids.turnID existing["x-codex-window-id"] = ids.windowID - - 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, - })) + ids.rewriteThreadReferences(existing) + ids.rewriteEmbeddedTurnMetadata(existing) return true } +func stringMetadataValue(metadata map[string]any, key string) string { + value, _ := metadata[key].(string) + return value +} + +func (ids *codexFingerprintIDs) mapThread(original string) string { + if ids.mode == codexFingerprintFull { + return ids.threadID + } + return resolveConvergedThreadID(ids.seed, original) +} + +func (ids *codexFingerprintIDs) captureThreadAndWindow(thread, window string) { + if ids.mode == codexFingerprintDevice { + return + } + if ids.mode == codexFingerprintSession && strings.TrimSpace(thread) != "" { + ids.threadID = ids.mapThread(thread) + } + suffix := "0" + if index := strings.LastIndexByte(window, ':'); index >= 0 && index < len(window)-1 { + suffix = window[index+1:] + } else if index := strings.LastIndexByte(ids.windowID, ':'); index >= 0 { + suffix = ids.windowID[index+1:] + } + ids.windowID = ids.threadID + ":" + suffix +} + +func (ids *codexFingerprintIDs) rewriteThreadReferences(metadata map[string]any) { + if ids.mode == codexFingerprintDevice { + return + } + for _, key := range []string{"parent_thread_id", "x-codex-parent-thread-id", "forked_from_thread_id"} { + if original := stringMetadataValue(metadata, key); original != "" { + if ids.mode == codexFingerprintFull { + delete(metadata, key) + if key == "forked_from_thread_id" { + delete(metadata, "forked_from_ordinal_exclusive") + } + } else { + metadata[key] = ids.mapThread(original) + } + } + } +} + +func (ids *codexFingerprintIDs) rewriteTurnMetadata(metadata map[string]any) { + for key, value := range ids.logicalMetadata { + metadata[key] = value + } + metadata["installation_id"] = ids.installationID + if ids.mode != codexFingerprintDevice { + metadata["session_id"] = ids.sessionID + metadata["thread_id"] = ids.threadID + metadata["window_id"] = ids.windowID + ids.rewriteThreadReferences(metadata) + } + alignCodexSandboxMetadata(metadata, ids.sandbox) +} + +func (ids *codexFingerprintIDs) captureLogicalMetadata(clientMetadata map[string]any) { + if ids.bodyIdentityCaptured { + return + } + ids.bodyIdentityCaptured = true + embedded := map[string]any{} + if raw := stringMetadataValue(clientMetadata, "x-codex-turn-metadata"); raw != "" { + _ = json.Unmarshal([]byte(raw), &embedded) + } + if ids.logicalMetadata == nil { + ids.logicalMetadata = map[string]any{} + } + for _, key := range []string{"turn_id", "turn_started_at_unix_ms", "parent_turn_id", "root_turn_id", "window_number", "context_window_id"} { + if value, ok := embedded[key]; ok { + ids.logicalMetadata[key] = value + } + if value, ok := clientMetadata[key]; ok { + ids.logicalMetadata[key] = value + } + } + thread, window := stringMetadataValue(clientMetadata, "thread_id"), stringMetadataValue(clientMetadata, "x-codex-window-id") + if thread == "" { + thread = stringMetadataValue(embedded, "thread_id") + } + if window == "" { + window = stringMetadataValue(embedded, "window_id") + } + ids.captureThreadAndWindow(thread, window) +} + +func alignCodexSandboxMetadata(metadata map[string]any, target string) bool { + // These describe actual execution permissions, not an OS-only fingerprint. + mode := stringMetadataValue(metadata, "sandbox_mode") + sandbox := stringMetadataValue(metadata, "sandbox") + if mode == "external-sandbox" { + metadata["sandbox"] = "external" + return sandbox != "external" + } + if sandbox == "none" || sandbox == "external" { + return false + } + if mode == "danger-full-access" && sandbox == "" { + return false + } + switch sandbox { + case "seatbelt", "seccomp", "windows_sandbox", "windows_elevated", "windows_mxc": + if strings.HasPrefix(sandbox, "windows_") && strings.HasPrefix(target, "windows_") { + return false + } + if target != "" && target != sandbox { + metadata["sandbox"] = target + return true + } + } + return false +} + +func alignCodexSandboxJSON(raw, target string) (string, bool) { + var metadata map[string]any + if json.Unmarshal([]byte(raw), &metadata) != nil || metadata == nil || !alignCodexSandboxMetadata(metadata, target) { + return raw, false + } + encoded, err := marshalCodexTurnMetadata(metadata) + if err != nil { + return raw, false + } + return string(encoded), true +} + +func alignCodexSandboxClientMetadata(body map[string]any, target string) bool { + metadata, _ := body["client_metadata"].(map[string]any) + if metadata == nil { + return false + } + if raw := stringMetadataValue(metadata, "x-codex-turn-metadata"); raw != "" { + if aligned, changed := alignCodexSandboxJSON(raw, target); changed { + metadata["x-codex-turn-metadata"] = aligned + return true + } + } + return false +} + +func alignCodexSandboxClientMetadataRaw(body []byte, target string) ([]byte, error) { + raw := gjson.GetBytes(body, "client_metadata.x-codex-turn-metadata") + if raw.Type != gjson.String { + return body, nil + } + if aligned, changed := alignCodexSandboxJSON(raw.String(), target); changed { + return sjson.SetBytes(body, "client_metadata.x-codex-turn-metadata", aligned) + } + return body, nil +} + +func resolveCodexFingerprintIDsWithSource(routed, source *Account, headers http.Header, policy *codexFingerprintPolicy) *codexFingerprintIDs { + if routed == nil || source == nil { + return nil + } + account := *source + account.Extra = maps.Clone(source.Extra) + if account.Extra == nil { + account.Extra = map[string]any{} + } + account.Extra[codexFingerprintModeExtraKey] = string(routed.GetCodexFingerprintMode()) + ids := resolveCodexFingerprintIDsFromRequest(&account, headers, policy) + if ids != nil { + ids.accountID = routed.ID + } + return ids +} + +func (ids *codexFingerprintIDs) rewriteHeaderTurnMetadata(headers http.Header) { + raw := headers.Get("x-codex-turn-metadata") + if raw == "" { + return + } + var metadata map[string]any + if json.Unmarshal([]byte(raw), &metadata) != nil || metadata == nil { + metadata = map[string]any{} + } + ids.rewriteTurnMetadata(metadata) + if encoded, err := marshalCodexTurnMetadata(metadata); err == nil { + headers.Set("x-codex-turn-metadata", string(encoded)) + } +} + +func (ids *codexFingerprintIDs) rewriteEmbeddedTurnMetadata(clientMetadata map[string]any) { + raw := stringMetadataValue(clientMetadata, "x-codex-turn-metadata") + if raw == "" { + return + } + var metadata map[string]any + if json.Unmarshal([]byte(raw), &metadata) != nil || metadata == nil { + metadata = map[string]any{} + } + ids.rewriteTurnMetadata(metadata) + if encoded, err := marshalCodexTurnMetadata(metadata); err == nil { + clientMetadata["x-codex-turn-metadata"] = string(encoded) + } +} + func captureCodexFingerprintOriginalBodySessionID(ids *codexFingerprintIDs, clientMetadata any) { if ids == nil || ids.originalBodySessionIDCaptured { return @@ -626,23 +799,3 @@ func applyCodexFingerprintClientMetadataRaw(body []byte, ids *codexFingerprintID } return next, modified, nil } - -// rewriteClientMetadataEmbeddedTurnMetadata 改写 client_metadata 中内嵌的 -// x-codex-turn-metadata JSON 字符串里的指定字段。非法/非对象值会重建, -// 避免 flat client_metadata 与 embedded metadata 暴露两套身份。 -func rewriteClientMetadataEmbeddedTurnMetadata(clientMetadata map[string]any, fields map[string]any) { - raw, ok := clientMetadata["x-codex-turn-metadata"].(string) - if !ok || raw == "" { - return - } - var metadata map[string]any - if err := json.Unmarshal([]byte(raw), &metadata); err != nil || metadata == nil { - metadata = make(map[string]any, len(fields)) - } - for k, v := range fields { - metadata[k] = v - } - if rebuilt, err := marshalCodexTurnMetadata(metadata); err == nil { - clientMetadata["x-codex-turn-metadata"] = string(rebuilt) - } -} diff --git a/backend/internal/service/openai_codex_fingerprint_test.go b/backend/internal/service/openai_codex_fingerprint_test.go index 922d33f6ce8d..906a5bde0f8c 100644 --- a/backend/internal/service/openai_codex_fingerprint_test.go +++ b/backend/internal/service/openai_codex_fingerprint_test.go @@ -253,7 +253,7 @@ func TestApplyCodexFingerprintHeaders_SessionMode(t *testing.T) { 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, "user-turn", meta["turn_id"], "真实回合标识保持原样") assert.Equal(t, identity.Sandbox, meta["sandbox"], "sandbox 跟随账号身份的操作系统") assert.Equal(t, "user", meta["thread_source"], "thread_source 保留原样") } @@ -332,7 +332,7 @@ func TestFingerprintIDs_HeaderAndBody_TurnID_Consistent(t *testing.T) { // 头改写 h := http.Header{} - h.Set("x-codex-turn-metadata", `{"installation_id":"x","session_id":"x","thread_id":"x","turn_id":"x","window_id":"x:0"}`) + h.Set("x-codex-turn-metadata", `{"installation_id":"x","session_id":"x","thread_id":"x","turn_id":"x","turn_started_at_unix_ms":12345,"window_id":"x:0"}`) applyCodexFingerprintHeaders(h, ids) // 体改写(使用同一份 ids) @@ -341,7 +341,7 @@ func TestFingerprintIDs_HeaderAndBody_TurnID_Consistent(t *testing.T) { "x-codex-installation-id": "x", "session_id": "x", "turn_id": "x", - "x-codex-turn-metadata": `{"installation_id":"x","session_id":"x","thread_id":"x","turn_id":"x","window_id":"x:0"}`, + "x-codex-turn-metadata": `{"installation_id":"x","session_id":"x","thread_id":"x","turn_id":"x","turn_started_at_unix_ms":12345,"window_id":"x:0"}`, }, } applyCodexFingerprintClientMetadata(reqBody, ids) @@ -372,9 +372,9 @@ func TestFingerprintIDs_HeaderAndBody_TurnID_Consistent(t *testing.T) { assert.Equal(t, headerTurnID, bodyTurnID, "头和体的 turn_id 必须一致") assert.Equal(t, headerTurnID, bodyEmbeddedTurnID, "头和体内嵌 turn-metadata 的 turn_id 必须一致") - assert.Equal(t, ids.turnID, headerTurnID, "所有 turn_id 都应来自同一份 ids") + assert.Equal(t, "x", headerTurnID, "真实 turn_id 保持原样") assert.Equal(t, headerTurnStartedAt, bodyTurnStartedAt, "头和体内嵌 turn-metadata 的开始时间必须一致") - assert.Equal(t, ids.turnStartedAtUnixMs, int64(headerTurnStartedAt), "所有开始时间都应来自同一份 ids") + assert.Equal(t, float64(12345), headerTurnStartedAt, "真实开始时间保持原样") } func TestFingerprintIDs_MalformedEmbeddedMetadataRebuiltConsistently(t *testing.T) { @@ -485,7 +485,7 @@ func TestApplyCodexFingerprintClientMetadata_SessionMode(t *testing.T) { require.True(t, ok) convergedInstall := resolveConvergedInstallationID(account, seed) convergedSession := resolveConvergedSessionID(seed) - convergedThread := resolveConvergedThreadID(seed, "client-session-aaa") + convergedThread := resolveConvergedThreadID(seed, "x") assert.Equal(t, convergedInstall, cm["x-codex-installation-id"]) assert.Equal(t, convergedSession, cm["session_id"]) @@ -797,7 +797,7 @@ func TestApplyCodexFingerprintClientMetadataRaw_PreservesUnrelatedFields(t *test cm, _ := decoded["client_metadata"].(map[string]any) require.NotNil(t, cm) assert.Equal(t, ids.sessionID, cm["session_id"]) - assert.Equal(t, ids.turnID, cm["turn_id"]) + assert.NotContains(t, cm, "turn_id", "未提供的回合标识不应被伪造") } func TestApplyCodexFingerprintClientMetadataRaw_Noop(t *testing.T) { diff --git a/backend/internal/service/openai_codex_identity.go b/backend/internal/service/openai_codex_identity.go index 6fcd6ef567e0..2d612e10b3ef 100644 --- a/backend/internal/service/openai_codex_identity.go +++ b/backend/internal/service/openai_codex_identity.go @@ -108,7 +108,11 @@ func CodexCanonicalUserAgent() string { // (codex-rs login/default_client.rs 的 default_headers()),version 门槛 // (issue #3901)只存在于 /backend-api/codex 推理面。 func CodexCanonicalAuthIdentity() (userAgent, originator string) { - identity := resolveCodexOutboundIdentity("") + policy := currentCodexFingerprintPolicy() + if !policy.enabled { + return codexCLIUserAgent, openai.CodexDefaultOriginator + } + identity := resolveCodexOutboundIdentity("", policy) return identity.userAgent, identity.originator } @@ -156,8 +160,12 @@ type codexOutboundIdentity struct { // 其自带的版本段一律用当前生效版本重建:一条填写于某个历史版本的 UA 否则会把出站身份 // 永久钉死在陈旧版本上,绕过版本自动同步,落回上游优先降载的那一侧。 // 需要固定版本请填「Codex 客户端版本号」并关闭自动同步。 -func resolveCodexOutboundIdentity(candidateUA string) codexOutboundIdentity { - canonical := codexCanonicalUserAgent() +func resolveCodexOutboundIdentity(candidateUA string, policies ...*codexFingerprintPolicy) codexOutboundIdentity { + policy := selectCodexFingerprintPolicy(policies) + canonical := policy.userAgent + if !policy.enabled { + canonical = codexCLIUserAgent + } if _, _, ok := openai.PairCodexClientIdentity(canonical); !ok { canonical = codexCLIUserAgent } @@ -192,11 +200,21 @@ func codexClientVersionFromUA(ua string) string { // ensureCodexIdentityHeaders 补齐 OAuth(ChatGPT 内部接口)出站请求所需的 Codex 身份头。 // 已有 User-Agent 与 version 保持不变,交给紧随其后的 enforceCodexIdentityHeaders 收口。 -func ensureCodexIdentityHeaders(h http.Header) { +func ensureCodexIdentityHeaders(h http.Header, policies ...*codexFingerprintPolicy) { if h == nil { return } - identity := resolveCodexOutboundIdentity("") + policy := selectCodexFingerprintPolicy(policies) + identity := resolveCodexOutboundIdentity("", policy) + if !policy.enabled { + // Fill only missing headers, deriving them from the actual caller's UA. + if originator, _, ok := openai.PairCodexClientIdentity(h.Get("user-agent")); ok { + identity.originator = originator + if version := NormalizeCodexClientVersion(openai.CodexUserAgentVersion(h.Get("user-agent"))); version != "" { + identity.version = version + } + } + } if strings.TrimSpace(h.Get("user-agent")) == "" { h.Set("user-agent", identity.userAgent) } @@ -238,42 +256,30 @@ func enforceCodexIdentityHeaders(h http.Header) { // 仅对携带 originator 的请求生效:compat 桥接等非 ChatGPT 内部接口路径会显式删除 originator, // 不应被补回。需要从缺失身份头恢复的调用方应先调用 ensureCodexIdentityHeaders。 // 必须在所有 User-Agent 改写之后调用。 -func enforceCodexIdentityHeadersWithUA(h http.Header, overrideUA string) { +func enforceCodexIdentityHeadersWithUA(h http.Header, overrideUA string, policies ...*codexFingerprintPolicy) { if h == nil || h.Get("originator") == "" { return } - if !codexIdentityEnforcement.Load() { - pairCodexIdentityHeaders(h) + policy := selectCodexFingerprintPolicy(policies) + if !policy.enabled { return } - identity := resolveCodexOutboundIdentity(overrideUA) + identity := resolveCodexOutboundIdentity(overrideUA, policy) h.Set("user-agent", identity.userAgent) h.Set("originator", identity.originator) h.Set("version", identity.version) } -// pairCodexIdentityHeaders 是关闭强制统一后的兜底收口:保留客户端真实身份, -// 仅保证 originator 与最终 User-Agent 首段配套、version 不低于上游门槛(issue #3901)。 -func pairCodexIdentityHeaders(h http.Header) { - originator, pairedUA, ok := openai.PairCodexClientIdentity(h.Get("user-agent")) - if !ok { - identity := resolveCodexOutboundIdentity("") - originator, pairedUA = identity.originator, identity.userAgent - h.Set("version", identity.version) - } - h.Set("user-agent", pairedUA) - h.Set("originator", originator) - if v := strings.TrimSpace(h.Get("version")); v != "" && CompareVersions(v, codexUpstreamMinVersion) < 0 { - h.Set("version", resolveCodexOutboundIdentity("").version) - } -} - // resolveCodexOutboundIdentityForAccount 返回某个 OAuth 账号的出站身份三元组。 // 优先级:管理员显式配置的账号级 User-Agent(只贡献客户端名与 OS / 架构 / 终端指纹, // 版本段仍由生效版本重建)> 账号持久化 / 种子派生的 Codex TUI 身份 > 全局规范身份。 // 版本号三处同源:UA 首段、UA 尾部括号组与 version 头都取当前生效的官方版本。 -func resolveCodexOutboundIdentityForAccount(account *Account, overrideUA string) codexOutboundIdentity { - canonical := resolveCodexOutboundIdentity(overrideUA) +func resolveCodexOutboundIdentityForAccount(account *Account, overrideUA string, policies ...*codexFingerprintPolicy) codexOutboundIdentity { + policy := selectCodexFingerprintPolicy(policies) + canonical := resolveCodexOutboundIdentity(overrideUA, policy) + if !policy.enabled { + return resolveCodexOutboundIdentity("", policy) + } if overrideUA != "" { return canonical } @@ -291,15 +297,15 @@ func resolveCodexOutboundIdentityForAccount(account *Account, overrideUA string) // enforceCodexIdentityHeadersForAccount 与 enforceCodexIdentityHeadersWithUA 语义相同, // 但强制统一时使用账号级身份而不是全局规范身份,使同一账号的所有出站请求 // 表现为同一台机器上的同一个 Codex TUI。 -func enforceCodexIdentityHeadersForAccount(h http.Header, account *Account, overrideUA string) { +func enforceCodexIdentityHeadersForAccount(h http.Header, account *Account, overrideUA string, policies ...*codexFingerprintPolicy) { if h == nil || h.Get("originator") == "" { return } - if !codexIdentityEnforcement.Load() { - pairCodexIdentityHeaders(h) + policy := selectCodexFingerprintPolicy(policies) + if !policy.enabled { return } - identity := resolveCodexOutboundIdentityForAccount(account, overrideUA) + identity := resolveCodexOutboundIdentityForAccount(account, overrideUA, policy) h.Set("user-agent", identity.userAgent) h.Set("originator", identity.originator) h.Set("version", identity.version) diff --git a/backend/internal/service/openai_codex_identity_snapshot.go b/backend/internal/service/openai_codex_identity_snapshot.go index e500bcd7fd95..7a74361704ad 100644 --- a/backend/internal/service/openai_codex_identity_snapshot.go +++ b/backend/internal/service/openai_codex_identity_snapshot.go @@ -43,14 +43,15 @@ type codexIdentitySnapshot struct { // resolveCodexIdentitySnapshot computes the identity and fingerprint state // from the routed account and the credential source used for this attempt. // It intentionally contains no credential, seed, cookie, or proxy URL data. -func resolveCodexIdentitySnapshot(routed, source *Account, overrideUA string) codexIdentitySnapshot { +func resolveCodexIdentitySnapshot(routed, source *Account, overrideUA string, policies ...*codexFingerprintPolicy) codexIdentitySnapshot { + policy := selectCodexFingerprintPolicy(policies) if source == nil { source = routed } snapshot := codexIdentitySnapshot{ SchemaVersion: 1, - EnforcementEnabled: codexIdentityEnforcement.Load(), + EnforcementEnabled: policy.enabled, ForceCodexCLI: codexForceCLI.Load(), UAOverridePresent: strings.TrimSpace(overrideUA) != "", } @@ -69,10 +70,13 @@ func resolveCodexIdentitySnapshot(routed, source *Account, overrideUA string) co if !routed.IsOpenAIOAuthLike() { snapshot.FingerprintModeEffective = string(codexFingerprintOff) snapshot.FingerprintReason = "non_oauth" + } else if !policy.enabled { + snapshot.FingerprintModeEffective = string(codexFingerprintOff) + snapshot.FingerprintReason = "simulation_disabled" } else if snapshot.FingerprintModeConfigured == string(codexFingerprintOff) { snapshot.FingerprintReason = "explicit_off" } else if codexFingerprintModeRequiresSeed(codexFingerprintMode(snapshot.FingerprintModeEffective)) { - if _, ok := codexFingerprintSeed(routed.Extra); !ok { + if _, ok := codexFingerprintSeed(source.Extra); !ok { snapshot.FingerprintModeEffective = string(codexFingerprintOff) snapshot.FingerprintReason = "seed_missing" } @@ -90,12 +94,14 @@ func resolveCodexIdentitySnapshot(routed, source *Account, overrideUA string) co }() } - identity := resolveCodexOutboundIdentityForAccount(source, overrideUA) + identity := resolveCodexOutboundIdentityForAccount(source, overrideUA, policy) snapshot.UserAgent = identity.userAgent snapshot.Originator = identity.originator snapshot.Version = identity.version snapshot.IdentitySource = "canonical" - if strings.TrimSpace(overrideUA) != "" { + if !policy.enabled { + snapshot.IdentitySource = "protocol_fallback" + } else if strings.TrimSpace(overrideUA) != "" { // The selector rebuilds the effective version. Comparing complete UA // strings would mislabel a valid override carrying an older version. if _, _, ok := openai.PairCodexClientIdentity(overrideUA); ok { diff --git a/backend/internal/service/openai_codex_identity_test.go b/backend/internal/service/openai_codex_identity_test.go index 15768e5ea5e0..374853d7a217 100644 --- a/backend/internal/service/openai_codex_identity_test.go +++ b/backend/internal/service/openai_codex_identity_test.go @@ -260,7 +260,7 @@ func TestEnforceCodexIdentityHeaders_EnforcementDisabled(t *testing.T) { require.Equal(t, "0.145.2", h.Get("version")) } -// 关闭强制统一后,第三方 UA 仍整体回退为规范身份并对齐 version。 +// 关闭模拟后保留已声明的客户端身份,兜底只补缺失头。 func TestEnforceCodexIdentityHeaders_EnforcementDisabledThirdPartyFallback(t *testing.T) { SetCodexIdentityEnforcementEnabled(false) t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) }) @@ -272,9 +272,9 @@ func TestEnforceCodexIdentityHeaders_EnforcementDisabledThirdPartyFallback(t *te enforceCodexIdentityHeaders(h) - require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator")) - require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) - require.Equal(t, codexCLIVersion, h.Get("version")) + require.Equal(t, "opencode", h.Get("originator")) + require.Equal(t, "luna/1.0.0", h.Get("user-agent")) + require.Equal(t, "2.1.0", h.Get("version")) } // 收口必须幂等:透传等路径可能先后多次经过收口。 diff --git a/backend/internal/service/openai_codex_version_sync_service.go b/backend/internal/service/openai_codex_version_sync_service.go index c42399cbec46..4d04cab9e2b2 100644 --- a/backend/internal/service/openai_codex_version_sync_service.go +++ b/backend/internal/service/openai_codex_version_sync_service.go @@ -141,6 +141,11 @@ func (s *OpenAICodexVersionSyncService) runOnce() { return } s.settingService.InvalidateOpenAICodexClientVersionCache() + if s.settingService != nil && s.settingService.settingRepo != nil && publishedCodexFingerprintPolicy.Load() != nil { + if err := s.settingService.LoadCodexFingerprintSettings(ctx); err != nil { + slog.Warn("openai_codex_fingerprint_policy_refresh_failed", "error", err) + } + } slog.Info("openai_codex_version_synced", "previous", current, "version", latest) } diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index 604be7530758..862f781f5fe1 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -1127,8 +1127,8 @@ func TestForwardAsAnthropic_KeepsInboundHyphenSessionHeaderOverCacheKeyFallback( 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 回退覆盖") + scoped := 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")) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 626dc4eff541..40e4f361aa3e 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -559,10 +559,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco markDecodedModified() } // 带真实 device_id 时补齐 client_metadata 安装标识,与真实 Codex 对齐(compact 形态不同,跳过)。 - if !isCompactRequest && applyCodexClientMetadata(decoded, account) { + if !isCompactRequest && codexFingerprintPolicyForContext(c, account).enabled && applyCodexClientMetadata(decoded, account) { markDecodedModified() } - if !isCompactRequest && applyCodexAccountIdentityClientMetadataMap(decoded, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) { + if !isCompactRequest && applyCodexAccountIdentityClientMetadataMap(decoded, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c), codexFingerprintPolicyForContext(c, account)) { markDecodedModified() } stageCodexFingerprintIDs(c, nil) @@ -583,6 +583,11 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco // 无条件覆写(含 nil):failover 从收敛账号切到 off 账号时,上一 // 账号的 IDs 不得残留(stageCodexFingerprintIDs 注释)。 stageCodexFingerprintIDs(c, fpIDs) + if fpIDs == nil && codexFingerprintPolicyForContext(c, account).enabled { + if alignCodexSandboxClientMetadata(decoded, codexSandboxForUserAgent(s.effectiveCodexOutboundUserAgent(c, account, clientHeaders))) { + markDecodedModified() + } + } } if codexResult.NormalizedModel != "" { upstreamModel = codexResult.NormalizedModel @@ -1597,7 +1602,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. req.Header.Set("user-agent", CodexCanonicalUserAgent()) } - applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c), codexFingerprintPolicyForContext(c, account)) // 指纹收敛:使用 Forward() 中预计算的收敛 ID 改写出站头,与请求体使用同一份 IDs。 applyStagedCodexFingerprintHeaders(c, account, req.Header) // 真实 Codex 必带的连字符会话头:入站缺失时以最终请求体的 prompt_cache_key @@ -1646,7 +1651,14 @@ func (s *OpenAIGatewayService) codexIdentityOverrideUA(account *Account) string // 最终出站 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) + policy := codexFingerprintPolicyForContext(c, account) + source := codexAccountIdentitySource(c, account) + scopedHeaders := clientHeaders.Clone() + if scopedHeaders == nil { + scopedHeaders = http.Header{} + } + applyCodexAccountIdentityHeaders(scopedHeaders, source, getAPIKeyIDFromContext(c), policy) + ids := resolveCodexFingerprintIDsWithSource(account, source, scopedHeaders, policy) if ids == nil { return nil } @@ -1661,6 +1673,13 @@ func (s *OpenAIGatewayService) resolveStagedCodexFingerprintIDs(c *gin.Context, // 保留构造器写入的 UA——ForceCodexCLI 时为规范 UA,否则账号显式 UA,否则客户端 UA; // 不合法的 UA 回落规范身份。该全局开关与 fingerprint mode 是不同门控。 func (s *OpenAIGatewayService) effectiveCodexOutboundUserAgent(c *gin.Context, account *Account, clientHeaders http.Header) string { + policy := codexFingerprintPolicyForContext(c, account) + if !policy.enabled { + if ua := clientHeaders.Get("user-agent"); ua != "" { + return ua + } + return codexCLIUserAgent + } if isOpenAICompatMessagesBridgeContext(c) { // 兼容 Messages 桥接:构造器删除 originator,终态收口早退,不做 ForAccount 统一; // 线上 UA 就是构造器写入的值(ForceCodexCLI 规范 UA > 账号显式 UA > 客户端 UA), @@ -1677,8 +1696,8 @@ func (s *OpenAIGatewayService) effectiveCodexOutboundUserAgent(c *gin.Context, a return "" } overrideUA := s.codexIdentityOverrideUA(account) - if codexIdentityEnforcement.Load() { - return resolveCodexOutboundIdentityForAccount(codexAccountIdentitySource(c, account), overrideUA).userAgent + if policy.enabled { + return resolveCodexOutboundIdentityForAccount(codexAccountIdentitySource(c, account), overrideUA, policy).userAgent } candidate := overrideUA if candidate == "" { @@ -1706,6 +1725,9 @@ func (s *OpenAIGatewayService) stageCodexFingerprintForWSFrame(c *gin.Context, a ids := s.resolveStagedCodexFingerprintIDs(c, account, clientHeaders) stageCodexFingerprintIDs(c, ids) if ids == nil { + if codexFingerprintPolicyForContext(c, account).enabled && account != nil && account.IsOpenAIOAuthLike() { + return alignCodexSandboxClientMetadataRaw(frame, codexSandboxForUserAgent(s.effectiveCodexOutboundUserAgent(c, account, clientHeaders))) + } return frame, nil } next, changed, err := applyCodexFingerprintClientMetadataRaw(frame, ids) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 2a9a91c373af..6bff6f7a0047 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -196,7 +196,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( // 账号作用域:body client_metadata / prompt_cache_key 与出站头共用同一映射, // 否则头已按账号改写而 body 仍是客户端原值,同一请求会暴露两套身份。 - accountScopedBody, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(body, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + accountScopedBody, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(body, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c), codexFingerprintPolicyForContext(c, account)) if scopeErr != nil { return nil, scopeErr } @@ -225,6 +225,13 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } } stageCodexFingerprintIDs(c, fpIDs) + if fpIDs == nil && codexFingerprintPolicyForContext(c, account).enabled { + var alignErr error + body, alignErr = alignCodexSandboxClientMetadataRaw(body, codexSandboxForUserAgent(s.effectiveCodexOutboundUserAgent(c, account, clientHeaders))) + if alignErr != nil { + return nil, alignErr + } + } } } if account != nil && account.IsOpenAI() { @@ -815,7 +822,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { req.Header.Set("user-agent", CodexCanonicalUserAgent()) } - applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c), codexFingerprintPolicyForContext(c, account)) // 指纹收敛:使用 forwardOpenAIPassthrough 中预计算的收敛 ID 改写出站头, // 与请求体 client_metadata 共享同一份 IDs(与非透传路径相同的相对位置: // 会话隔离之后、终态身份收口之前)。 diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 2157012c0d9b..e8a36b344ce5 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -61,7 +61,7 @@ const ( // 陈旧版本会被优先丢弃(HTTP 200 + 流内 server_is_overloaded);非官方客户端配不出 // 官方身份时整体回退到本常量,因此它必须跟随官方 CLI 的当前发布版本, // 落后多个版本会让这些请求稳定落在被优先丢弃的一侧。 - codexCLIVersion = "0.146.0" + codexCLIVersion = "0.161.0" // Codex 限额快照仅用于后台展示/诊断,不需要每个成功请求都立即落库。 openAICodexSnapshotPersistMinInterval = 30 * time.Second // 配额自动暂停时,超过该时长仍未刷新的 used% 快照视为陈旧,不再据此暂停账号。 diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 7f1d79237de8..b48bcf6b0db5 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -2670,7 +2670,7 @@ func TestOpenAIGatewayService_CodexFingerprintHTTPTransformedHeaderBodyParityAnd require.True(t, ok) wantInstall := resolveConvergedInstallationID(account, seed) wantSession := resolveConvergedSessionID(seed) - wantThread := resolveConvergedThreadID(seed, "header-session") + wantThread := resolveConvergedThreadID(seed, scopeCodexAccountIdentityValue(account, 0, "body-thread")) 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()) @@ -2733,7 +2733,7 @@ func TestOpenAIGatewayService_CodexFingerprintHTTPRawPassthroughHeaderBodyParity require.True(t, ok) wantInstall := resolveConvergedInstallationID(account, seed) wantSession := resolveConvergedSessionID(seed) - wantThread := resolveConvergedThreadID(seed, "header-session") + wantThread := resolveConvergedThreadID(seed, scopeCodexAccountIdentityValue(account, 0, "body-thread")) 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()) diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 720cec14aa42..bb291c727469 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -238,6 +238,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } parseClientPayload := func(turn int, raw []byte) (openAIWSClientPayload, error) { + stageCodexFingerprintPolicy(c, account) trimmed := bytes.TrimSpace(raw) if len(trimmed) == 0 { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "empty websocket request payload", nil) @@ -336,7 +337,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( normalized = next } accountIdentitySourceRaw := append([]byte(nil), normalized...) - accountScopedPayload, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(normalized, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + accountScopedPayload, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(normalized, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c), codexFingerprintPolicyForContext(c, account)) if scopeErr != nil { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", scopeErr) } @@ -864,9 +865,10 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } turnState = strings.TrimSpace(wsHeaders.Get(openAIWSTurnStateHeader)) baseAcquireReq := openAIWSAcquireRequest{ - Account: account, - WSURL: wsURL, - Headers: wsHeaders, + FingerprintPolicy: codexFingerprintPolicyForContext(c, account), + Account: account, + WSURL: wsURL, + Headers: wsHeaders, HeadersFactory: func(factoryCtx context.Context, headers http.Header) (http.Header, error) { return s.refreshOpenAIAgentIdentityHeaders(factoryCtx, account, headers) }, @@ -1777,6 +1779,15 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if headerErr != nil { return headerErr } + if sessionLease != nil && baseAcquireReq.FingerprintPolicy != nil && + baseAcquireReq.FingerprintPolicy.revision != codexFingerprintPolicyForContext(c, account).revision { + // The preceding turn is complete. Ask the client to reopen so a new + // handshake cannot inherit this socket's old identity or anchor. + resetSessionLease(false) + return NewOpenAIWSClientCloseError(coderws.StatusTryAgainLater, openAIWSNonInitialTurnRetryCloseReason, + errors.New("codex fingerprint settings changed; a new handshake is required")) + } + baseAcquireReq.FingerprintPolicy = codexFingerprintPolicyForContext(c, account) baseAcquireReq.Headers = finalHeaders if borrowTurn == nil { borrowPreviousID := currentPreviousResponseID 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 1caeeed317e5..20d6d854f265 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -1592,16 +1592,14 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade } require.Empty(t, captureDialer.lastHeaders.Get("session_id"), "真实 Codex 不发下划线 session_id") - require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "pcache_passthrough"), captureDialer.lastHeaders.Get("session-id"), "只做一次账号作用域映射") + require.Equal(t, "pcache_passthrough", captureDialer.lastHeaders.Get("session-id"), "设备模式保留客户端会话标识") require.Empty(t, captureDialer.lastHeaders.Get(openAIWSTurnStateHeader), "client turn state without account provenance must fail closed") // 握手 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.False(t, gjson.Get(handshakeTurnMetadata, "sandbox").Exists(), "未提供实际沙箱时不生成 OS 推断值") require.Len(t, upstreamConn.writes, 2) forwarded := requestToJSONString(upstreamConn.writes[0]) require.Equal(t, gjson.Get(forwarded, "prompt_cache_key").String(), captureDialer.lastHeaders.Get("session-id"), "握手回退与首帧 prompt_cache_key 同源") diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index cacdb34a1e43..a364db29b62c 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -136,7 +136,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( if metadata := strings.TrimSpace(turnMetadata); metadata != "" { headers.Set(openAIWSTurnMetadataHeader, metadata) } - applyCodexAccountIdentityHeaders(headers, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + applyCodexAccountIdentityHeaders(headers, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c), codexFingerprintPolicyForContext(c, account)) applyStagedCodexFingerprintHeaders(c, account, headers) if account != nil && account.UsesOpenAICodexProtocol() { // 入站缺失连字符会话头时补齐:来自帧体 prompt_cache_key 的回退值由调用方传入最终线上值 diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index 59eaf5cad3c1..e56d6f4e5cbb 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -464,10 +464,10 @@ func TestOpenAIGatewayService_BuildOpenAIWSHeadersDeviceModePreservesNamespacedC require.NoError(t, err) 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")) + require.Equal(t, "client-window", headers.Get("x-codex-window-id")) + require.Equal(t, "client-session", headers.Get("session-id")) + require.Equal(t, "client-thread", headers.Get("thread-id")) + require.Equal(t, "client-request", headers.Get("x-client-request-id")) } func TestLogOpenAIWSBindResponseAccountWarn(t *testing.T) { @@ -1158,7 +1158,7 @@ func TestOpenAIGatewayService_Forward_WSv2_CodexFingerprintHandshakeBodyParityAn require.True(t, ok) wantInstall := resolveConvergedInstallationID(account, seed) wantSession := resolveConvergedSessionID(seed) - wantThread := resolveConvergedThreadID(seed, "header-session") + wantThread := resolveConvergedThreadID(seed, scopeCodexAccountIdentityValue(account, 0, "body-thread")) payloadJSON := requestToJSONString(captureConn.lastWrite) require.Empty(t, captureDialer.lastHeaders.Get("x-codex-installation-id"), "installation id 只在 body client_metadata") @@ -1182,7 +1182,7 @@ func TestOpenAIGatewayService_Forward_WSv2_CodexFingerprintHandshakeBodyParityAn require.Equal(t, wantThread, gjson.Get(bodyTurnMetadata, "thread_id").String()) require.Equal(t, wantSession, gjson.Get(headerTurnMetadata, "session_id").String()) require.Equal(t, gjson.Get(bodyTurnMetadata, "turn_id").String(), gjson.Get(headerTurnMetadata, "turn_id").String()) - require.NotZero(t, gjson.Get(bodyTurnMetadata, "turn_started_at_unix_ms").Int()) + require.False(t, gjson.Get(bodyTurnMetadata, "turn_started_at_unix_ms").Exists(), "未提供的真实时间不生成") require.Equal(t, gjson.Get(bodyTurnMetadata, "turn_started_at_unix_ms").Int(), gjson.Get(headerTurnMetadata, "turn_started_at_unix_ms").Int(), diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 69fc1f33c241..186f612e9c88 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -74,7 +74,10 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( turnState = strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader)) turnMetadata = strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)) } - setOpenAIWSTurnMetadata(payload, turnMetadata) + clientMetadata, _ := payload["client_metadata"].(map[string]any) + if stringMetadataValue(clientMetadata, openAIWSTurnMetadataHeader) == "" { + setOpenAIWSTurnMetadata(payload, turnMetadata) + } applyStagedCodexFingerprintClientMetadata(c, account, payload) wirePayload, promptErr := s.finalizeResponsesForSend(c, account, payloadAsJSONBytes(payload)) if promptErr != nil { @@ -251,9 +254,10 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( defer acquireCancel() acquireReq := openAIWSAcquireRequest{ - Account: account, - WSURL: wsURL, - Headers: wsHeaders, + FingerprintPolicy: codexFingerprintPolicyForContext(c, account), + Account: account, + WSURL: wsURL, + Headers: wsHeaders, HeadersFactory: func(factoryCtx context.Context, headers http.Header) (http.Header, error) { return s.refreshOpenAIAgentIdentityHeaders(factoryCtx, account, headers) }, 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 3dc427919286..21afea49ebcf 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, "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.Equal(t, "client-session", gjson.GetBytes(upstream.bodies[1], "client_metadata.session_id").String()) + require.Equal(t, "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, "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.Equal(t, "client-session", gjson.GetBytes(upstream.bodies[2], "client_metadata.session_id").String()) + require.Equal(t, "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_pool.go b/backend/internal/service/openai_ws_pool.go index 8e41d323e8df..524efd71acd3 100644 --- a/backend/internal/service/openai_ws_pool.go +++ b/backend/internal/service/openai_ws_pool.go @@ -68,9 +68,10 @@ func (e *openAIWSDialError) Unwrap() error { } type openAIWSAcquireRequest struct { - Account *Account - WSURL string - Headers http.Header + FingerprintPolicy *codexFingerprintPolicy + Account *Account + WSURL string + Headers http.Header // HeadersFactory is evaluated inside dialConn. It exists so credentials // whose authorization is per-dial (Agent Identity) are never cached in // lastAcquire or delayed prewarm state. @@ -87,6 +88,11 @@ type openAIWSAcquireRequest struct { } type openAIWSHandshakeCompatibilityKey struct { + codexPolicyRevision uint64 + codexMode string + userAgent string + originator string + version string anchorScope string betaFeatures string codexInstallationID string @@ -2349,7 +2355,7 @@ func sameOpenAIWSPrewarmTarget(a, b openAIWSAcquireRequest) bool { } func normalizeOpenAIWSAcquireCompatibility(req openAIWSAcquireRequest) openAIWSHandshakeCompatibilityKey { - key := normalizeOpenAIWSHandshakeCompatibility(req.Account, req.Headers) + key := normalizeOpenAIWSHandshakeCompatibility(req.Account, req.Headers, req.FingerprintPolicy) key.anchorScope = stringsTrim(req.AnchorScope) return key } @@ -2379,11 +2385,25 @@ func normalizeOpenAIWSBetaFeatures(headers http.Header) string { return strings.Join(normalized, ",") } -func normalizeOpenAIWSHandshakeCompatibility(account *Account, headers http.Header) openAIWSHandshakeCompatibilityKey { +func normalizeOpenAIWSHandshakeCompatibility(account *Account, headers http.Header, policies ...*codexFingerprintPolicy) openAIWSHandshakeCompatibilityKey { + policy := selectCodexFingerprintPolicy(policies) + if len(policies) == 0 || policies[0] == nil { + policy = currentCodexFingerprintPolicyForAccount(account) + } key := openAIWSHandshakeCompatibilityKey{ betaFeatures: normalizeOpenAIWSBetaFeatures(headers), } - mode := activeCodexFingerprintMode(account) + if account != nil && account.IsOpenAIOAuthLike() { + key.codexPolicyRevision = policy.revision + key.codexMode = string(account.GetCodexFingerprintMode()) + key.userAgent = headers.Get("user-agent") + key.originator = headers.Get("originator") + key.version = headers.Get("version") + } + if !policy.enabled || account == nil { + return key + } + mode := activeCodexFingerprintMode(account, policy) if mode == codexFingerprintOff { return key } @@ -2399,8 +2419,9 @@ func normalizeOpenAIWSHandshakeCompatibility(account *Account, headers http.Head return key } -func activeCodexFingerprintMode(account *Account) codexFingerprintMode { - if account == nil || account.GetCodexFingerprintMode() == codexFingerprintOff { +func activeCodexFingerprintMode(account *Account, policies ...*codexFingerprintPolicy) codexFingerprintMode { + policy := selectCodexFingerprintPolicy(policies) + if account == nil || !policy.enabled || account.GetCodexFingerprintMode() == codexFingerprintOff { return codexFingerprintOff } if _, ok := codexFingerprintSeed(account.Extra); !ok { diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 1ea826ac14df..97f9c404f71e 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -786,7 +786,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2PassthroughAttempt( } else if changed { firstClientMessage = normalized } - accountScopedFirst, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(firstClientMessage, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + accountScopedFirst, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(firstClientMessage, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c), codexFingerprintPolicyForContext(c, account)) if scopeErr != nil { return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", scopeErr) } @@ -798,6 +798,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2PassthroughAttempt( return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", fingerprintErr) } firstClientMessage = fingerprintFirst + connectionFingerprintPolicy := codexFingerprintPolicyForContext(c, account) usageMeta := newOpenAIWSPassthroughUsageMeta(initialRequestModel, firstClientMessage) updatedFirst, blocked, policyErr := s.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, capturedSessionModel, firstClientMessage) if policyErr != nil { @@ -1079,6 +1080,10 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2PassthroughAttempt( responseCreateAt := time.Time{} acceptedTurn := false if isResponseCreate { + if account.IsOpenAIOAuthLike() && currentCodexFingerprintPolicyForAccount(account).revision != connectionFingerprintPolicy.revision { + return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusTryAgainLater, openAIWSNonInitialTurnRetryCloseReason, + errors.New("codex fingerprint settings changed; a new handshake is required")) + } responseCreateAt = time.Now() if !turnLifecycle.beginResponseCreate(clientFrameConn.markTurnStarted) { err := errors.New("overlapping response.create is not supported") @@ -1101,7 +1106,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2PassthroughAttempt( } } if isResponseCreate || eventType == "session.update" { - accountScopedPayload, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(payload, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + accountScopedPayload, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(payload, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c), codexFingerprintPolicyForContext(c, account)) if scopeErr != nil { return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", scopeErr) } diff --git a/backend/internal/service/setting_codex_fingerprint.go b/backend/internal/service/setting_codex_fingerprint.go new file mode 100644 index 000000000000..dde3366e6bf8 --- /dev/null +++ b/backend/internal/service/setting_codex_fingerprint.go @@ -0,0 +1,278 @@ +package service + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "strconv" + "strings" + "sync" + "sync/atomic" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/gin-gonic/gin" +) + +const SettingKeyCodexFingerprintEnabled = "codex_fingerprint_enabled" + +type CodexFingerprintSettings struct { + Enabled bool `json:"enabled"` + UserAgent string `json:"user_agent"` + ClientVersion string `json:"client_version"` + VersionAutoSyncEnabled bool `json:"version_auto_sync_enabled"` +} + +type CodexFingerprintSettingsView struct { + CodexFingerprintSettings + SyncedVersion string `json:"synced_version"` + EffectiveVersion string `json:"effective_version"` + EffectiveUserAgent string `json:"effective_user_agent"` +} + +// An immutable policy is shared by the body, headers and connection selection of +// one upstream attempt. Publishing new settings never mutates an active attempt. +type codexFingerprintPolicy struct { + enabled bool + userAgent string + revision uint64 +} + +var publishedCodexFingerprintPolicy atomic.Pointer[codexFingerprintPolicy] + +var codexFingerprintAccountEpochs sync.Map +var codexFingerprintAccountEpoch atomic.Uint64 + +// Account mode edits invalidate only that account's subsequent WS handshakes. +func notifyCodexFingerprintAccountChanged(accountID int64) { + codexFingerprintAccountEpochs.Store(accountID, codexFingerprintAccountEpoch.Add(1)) +} + +func currentCodexFingerprintPolicyForAccount(account *Account) *codexFingerprintPolicy { + policy := currentCodexFingerprintPolicy() + if account == nil { + return policy + } + if value, ok := codexFingerprintAccountEpochs.Load(account.ID); ok { + epoch, valid := value.(uint64) + if !valid { + return policy + } + copy := *policy + copy.revision ^= epoch * 0x9e3779b97f4a7c15 + return © + } + return policy +} + +func codexFingerprintPolicyRevision(enabled bool, userAgent string) uint64 { + digest := sha256.Sum256([]byte(strconv.FormatBool(enabled) + "\x00" + userAgent)) + return binary.BigEndian.Uint64(digest[:8]) +} + +func currentCodexFingerprintPolicy() *codexFingerprintPolicy { + if policy := publishedCodexFingerprintPolicy.Load(); policy != nil { + return policy + } + // Keep standalone tools and existing test construction working without Wire. + ua := codexCanonicalUserAgent() + enabled := codexIdentityEnforcement.Load() + return &codexFingerprintPolicy{enabled: enabled, userAgent: ua, revision: codexFingerprintPolicyRevision(enabled, ua)} +} + +type stagedCodexFingerprintPolicy struct { + accountID int64 + policy *codexFingerprintPolicy +} + +const codexFingerprintPolicyContextKey = "codex_fingerprint_policy" + +func stageCodexFingerprintPolicy(c *gin.Context, account *Account) *codexFingerprintPolicy { + policy := currentCodexFingerprintPolicyForAccount(account) + if c != nil && account != nil { + c.Set(codexFingerprintPolicyContextKey, stagedCodexFingerprintPolicy{accountID: account.ID, policy: policy}) + } + return policy +} + +func codexFingerprintPolicyForContext(c *gin.Context, account *Account) *codexFingerprintPolicy { + if c != nil && account != nil { + if value, ok := c.Get(codexFingerprintPolicyContextKey); ok { + if staged, ok := value.(stagedCodexFingerprintPolicy); ok && staged.accountID == account.ID { + return staged.policy + } + } + } + return stageCodexFingerprintPolicy(c, account) +} + +func selectCodexFingerprintPolicy(policies []*codexFingerprintPolicy) *codexFingerprintPolicy { + if len(policies) > 0 && policies[0] != nil { + return policies[0] + } + return currentCodexFingerprintPolicy() +} + +func DecodeCodexFingerprintSettings(raw []byte) (CodexFingerprintSettings, error) { + var config CodexFingerprintSettings + if err := decodeCodexFingerprintObject(raw, &config, "enabled", "user_agent", "client_version", "version_auto_sync_enabled"); err != nil { + return config, err + } + config.UserAgent = strings.TrimSpace(config.UserAgent) + config.ClientVersion = strings.TrimSpace(config.ClientVersion) + if len(config.UserAgent) > 512 || strings.ContainsAny(config.UserAgent, "\r\n") { + return config, errors.New("user_agent must be a valid single-line Codex User-Agent of at most 512 bytes") + } + if config.ClientVersion != "" && NormalizeCodexClientVersion(config.ClientVersion) == "" { + return config, errors.New("client_version must be empty or a valid Codex version") + } + return config, nil +} + +func decodeCodexFingerprintObject(raw []byte, target any, keys ...string) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil || fields == nil || len(fields) != len(keys) { + return errors.New("expected a JSON object with exactly the supported settings") + } + for _, key := range keys { + value, ok := fields[key] + if !ok || bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return fmt.Errorf("%s is required and must not be null", key) + } + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(new(any)); err != io.EOF { + return errors.New("expected one JSON object") + } + return nil +} + +var codexFingerprintSettingKeys = []string{ + SettingKeyCodexFingerprintEnabled, SettingKeyOpenAICodexUserAgent, + SettingKeyOpenAICodexClientVersion, SettingKeyOpenAICodexVersionAutoSyncEnabled, + SettingKeyOpenAICodexClientVersionSynced, +} + +func (s *SettingService) codexFingerprintSettingsFromValues(values map[string]string) (CodexFingerprintSettingsView, error) { + config := CodexFingerprintSettings{Enabled: s.cfg == nil || !s.cfg.Gateway.DisableCodexIdentityEnforcement, VersionAutoSyncEnabled: true} + if raw := values[SettingKeyCodexFingerprintEnabled]; raw != "" { + value, err := strconv.ParseBool(raw) + if err != nil { + return CodexFingerprintSettingsView{}, fmt.Errorf("invalid %s", SettingKeyCodexFingerprintEnabled) + } + config.Enabled = value + } + config.UserAgent = values[SettingKeyOpenAICodexUserAgent] + config.ClientVersion = values[SettingKeyOpenAICodexClientVersion] + if raw := strings.TrimSpace(values[SettingKeyOpenAICodexVersionAutoSyncEnabled]); raw != "" { + config.VersionAutoSyncEnabled = raw == "true" + } + synced := NormalizeCodexClientVersion(values[SettingKeyOpenAICodexClientVersionSynced]) + version := NormalizeCodexClientVersion(config.ClientVersion) + if version == "" { + version = synced + } + if version == "" || CompareVersions(version, codexUpstreamMinVersion) < 0 { + version = codexCLIVersion + } + ua := buildCodexCLIUserAgent(version) + if _, paired, ok := openai.PairCodexClientIdentity(config.UserAgent); ok { + if rebuilt := openai.SetCodexUserAgentVersion(paired, version); rebuilt != "" { + ua = rebuilt + } + } + return CodexFingerprintSettingsView{CodexFingerprintSettings: config, SyncedVersion: synced, EffectiveVersion: version, EffectiveUserAgent: ua}, nil +} + +func publishCodexFingerprintSettings(view CodexFingerprintSettingsView) { + publishedCodexFingerprintPolicy.Store(&codexFingerprintPolicy{ + enabled: view.Enabled, userAgent: view.EffectiveUserAgent, + revision: codexFingerprintPolicyRevision(view.Enabled, view.EffectiveUserAgent), + }) +} + +func (s *SettingService) GetCodexFingerprintSettings(ctx context.Context) (CodexFingerprintSettingsView, error) { + values, err := s.settingRepo.GetMultiple(ctx, codexFingerprintSettingKeys) + if err != nil { + return CodexFingerprintSettingsView{}, err + } + return s.codexFingerprintSettingsFromValues(values) +} + +func (s *SettingService) LoadCodexFingerprintSettings(ctx context.Context) error { + s.codexFingerprintMu.Lock() + defer s.codexFingerprintMu.Unlock() + view, err := s.GetCodexFingerprintSettings(ctx) + if err != nil { + return err + } + publishCodexFingerprintSettings(view) + return nil +} + +func (s *SettingService) UpdateCodexFingerprintSettings(ctx context.Context, config CodexFingerprintSettings) (CodexFingerprintSettingsView, error) { + s.codexFingerprintMu.Lock() + defer s.codexFingerprintMu.Unlock() + values, err := s.settingRepo.GetMultiple(ctx, codexFingerprintSettingKeys) + if err != nil { + return CodexFingerprintSettingsView{}, err + } + updates := map[string]string{ + SettingKeyCodexFingerprintEnabled: strconv.FormatBool(config.Enabled), + SettingKeyOpenAICodexUserAgent: config.UserAgent, + SettingKeyOpenAICodexClientVersion: config.ClientVersion, + SettingKeyOpenAICodexVersionAutoSyncEnabled: strconv.FormatBool(config.VersionAutoSyncEnabled), + } + for key, value := range updates { + values[key] = value + } + view, err := s.codexFingerprintSettingsFromValues(values) + if err != nil { + return CodexFingerprintSettingsView{}, err + } + if err := s.settingRepo.SetMultiple(ctx, updates); err != nil { + return CodexFingerprintSettingsView{}, err + } + s.InvalidateOpenAICodexClientVersionCache() + s.openAICodexUASF.Forget("openai_codex_user_agent") + s.openAICodexUACache.Store((*cachedOpenAICodexUserAgent)(nil)) + publishCodexFingerprintSettings(view) + return view, nil +} + +// CodexFingerprintAccountView uses the same identity selector as forwarding. +type CodexFingerprintAccountView struct { + Mode string `json:"mode"` + Identity codexIdentitySnapshot `json:"identity"` + SimulationEnabled bool `json:"simulation_enabled"` +} + +func DescribeCodexFingerprintAccount(routed, source *Account) CodexFingerprintAccountView { + policy := currentCodexFingerprintPolicy() + return CodexFingerprintAccountView{ + Mode: string(routed.GetCodexFingerprintMode()), + Identity: resolveCodexIdentitySnapshot(routed, source, codexAccountIdentityOverrideUA(source), policy), + SimulationEnabled: policy.enabled, + } +} + +func DecodeCodexFingerprintMode(raw []byte) (string, error) { + var config struct { + Mode string `json:"mode"` + } + if err := decodeCodexFingerprintObject(raw, &config, "mode"); err != nil { + return "", err + } + if _, ok := codexFingerprintModeExplicit(map[string]any{codexFingerprintModeExtraKey: config.Mode}); !ok { + return "", errors.New("mode must be off, device, session or full") + } + return strings.TrimSpace(config.Mode), nil +} diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 3e0cc3e815db..6c2111767adf 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -117,6 +117,7 @@ type WebSearchManagerBuilder func(cfg *WebSearchEmulationConfig, proxyURLs map[i // SettingService 系统设置服务 type SettingService struct { + codexFingerprintMu sync.Mutex settingRepo SettingRepository defaultSubGroupReader DefaultSubscriptionGroupReader proxyRepo ProxyRepository // for resolving websearch provider proxy URLs diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 7efbc72291af..1b4a38a5ccca 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -48,6 +48,11 @@ func (s *SettingService) UpdateSettingsOmitting(ctx context.Context, settings *S return err } s.refreshCachedSettingsAfterWrite(ctx, settings, omitted) + if publishedCodexFingerprintPolicy.Load() != nil { + if err := s.LoadCodexFingerprintSettings(ctx); err != nil { + return err + } + } return nil } @@ -78,6 +83,11 @@ func (s *SettingService) UpdateSettingsWithAuthSourceDefaultsOmitting(ctx contex return err } s.refreshCachedSettingsAfterWrite(ctx, settings, omitted) + if publishedCodexFingerprintPolicy.Load() != nil { + if err := s.LoadCodexFingerprintSettings(ctx); err != nil { + return err + } + } return nil } diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 506084d9a655..f50386bf90e1 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -929,7 +929,7 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit svc := NewSettingService(settingRepo, cfg) // Observability and Codex request compression switches are read by // package-level gates before the gateway starts. - for _, load := range []func(context.Context) error{svc.LoadAdminObservabilityConfig, svc.LoadCodexRuntimeConfig} { + for _, load := range []func(context.Context) error{svc.LoadAdminObservabilityConfig, svc.LoadCodexRuntimeConfig, svc.LoadCodexFingerprintSettings} { if err := load(context.Background()); err != nil { return nil, err } diff --git a/frontend/src/api/admin/codexFingerprint.ts b/frontend/src/api/admin/codexFingerprint.ts new file mode 100644 index 000000000000..af90affef771 --- /dev/null +++ b/frontend/src/api/admin/codexFingerprint.ts @@ -0,0 +1,51 @@ +import { apiClient } from '../client' + +export type CodexFingerprintMode = 'off' | 'device' | 'session' | 'full' + +export interface CodexFingerprintSettings { + enabled: boolean + user_agent: string + client_version: string + version_auto_sync_enabled: boolean +} + +export interface CodexFingerprintSettingsView extends CodexFingerprintSettings { + synced_version: string + effective_version: string + effective_user_agent: string +} + +export interface CodexFingerprintAccountView { + mode: CodexFingerprintMode + simulation_enabled: boolean + identity: { + user_agent: string + originator: string + version: string + identity_source: string + identity_account_id: number + fingerprint_mode_effective: CodexFingerprintMode + fingerprint_reason: string + } +} + +export const codexFingerprintAPI = { + async getSettings(): Promise { + const { data } = await apiClient.get('/admin/settings/codex-fingerprint') + if (typeof data.enabled !== 'boolean' || typeof data.user_agent !== 'string' || typeof data.client_version !== 'string' || + typeof data.version_auto_sync_enabled !== 'boolean' || typeof data.effective_version !== 'string') throw new Error('Invalid Codex fingerprint settings') + return data + }, + async saveSettings(config: CodexFingerprintSettings): Promise { + const { data } = await apiClient.put('/admin/settings/codex-fingerprint', config) + return data + }, + async getAccount(id: number): Promise { + const { data } = await apiClient.get(`/admin/accounts/${id}/codex-fingerprint`) + return data + }, + async saveAccount(id: number, mode: CodexFingerprintMode): Promise { + const { data } = await apiClient.put(`/admin/accounts/${id}/codex-fingerprint`, { mode }) + return data + } +} diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index 4475d731c19a..ab4e45a65ade 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -974,24 +974,7 @@ - -
-
- - -
-
-

- {{ t('admin.accounts.openai.codexFingerprintModeDesc') }} -

- -
-
- +
(OPENAI_WS_MODE_OFF const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const codexCLIOnlyEnabled = ref(false) const codexCLIOnlyAppServerEnabled = ref(false) -type CodexFingerprintMode = 'off' | 'device' | 'session' | 'full' -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') }, - { value: 'session' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintSession') }, - { value: 'full' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintFull') }, -]) type AnthropicAPIKeyAuthScheme = 'x_api_key' | 'authorization_bearer' const anthropicPassthroughEnabled = ref(false) const anthropicAPIKeyAuthScheme = ref('x_api_key') @@ -5581,7 +5557,6 @@ const resetForm = () => { openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF codexCLIOnlyEnabled.value = false codexCLIOnlyAppServerEnabled.value = false - codexFingerprintMode.value = 'device' anthropicPassthroughEnabled.value = false anthropicAPIKeyAuthScheme.value = 'x_api_key' webSearchEmulationMode.value = 'default' @@ -5682,10 +5657,6 @@ const buildOpenAIExtra = (base?: Record): Record
- -
-
-
- -

- {{ t('admin.accounts.openai.codexFingerprintModeDesc') }} -

-
-
- - - + + diff --git a/frontend/src/components/admin/account/__tests__/AccountTestReasoningSelect.spec.ts b/frontend/src/components/admin/account/__tests__/AccountTestReasoningSelect.spec.ts new file mode 100644 index 000000000000..b4636e87a4b7 --- /dev/null +++ b/frontend/src/components/admin/account/__tests__/AccountTestReasoningSelect.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import AccountTestReasoningSelect from '../AccountTestReasoningSelect.vue' +import { isAccountTestReasoningValid } from '@/utils/accountTestModels' + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) + +describe('AccountTestReasoningSelect native efforts', () => { + it('never displays ultra from a stale capability list, default or selected value, and resets the old selection', () => { + const model = { id: 'alias', display_name: 'Astra', reasoning_efforts: ['high', 'max', 'ultra'], default_reasoning_effort: 'ultra' } + const wrapper = mount(AccountTestReasoningSelect, { props: { model, modelValue: 'ultra' } }) + expect(wrapper.text()).not.toContain('ultra') + expect(wrapper.findAll('option').map(option => option.attributes('value'))).toEqual(['', 'high', 'max']) + expect(wrapper.emitted('update:modelValue')).toEqual([['']]) + expect(isAccountTestReasoningValid(model, 'ultra')).toBe(false) + expect(isAccountTestReasoningValid(model, 'max')).toBe(true) + wrapper.unmount() + }) +}) diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 7273aa782465..417ccec70de7 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -833,6 +833,7 @@ const personalNavItems = computed((): NavItem[] => finalizeNav(buildSelfNavItems const adminExtensionNavItems = computed((): NavItem[] => [ { path: '/admin/system-prompts', label: t('nav.systemPrompts'), icon: DocumentIcon }, { path: '/admin/codex-runtime', label: t('nav.codexRuntime'), icon: PluginIcon }, + { path: '/admin/codex-fingerprint', label: t('nav.codexFingerprint'), icon: ShieldIcon }, { path: '/admin/codex-gateway-borrow', label: t('nav.codexGatewayBorrow'), icon: GatewayBorrowIcon }, { path: '/admin/pelican-tests', label: t('nav.pelicanTests'), icon: GatewayBorrowIcon }, { path: '/admin/reasoning-recovery', label: t('nav.reasoningRecovery'), icon: RecoveryIcon }, diff --git a/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts b/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts index 22801b32254e..9be669024be2 100644 --- a/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts +++ b/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts @@ -111,6 +111,7 @@ describe('AppSidebar simple mode extensions', () => { expect(extensionLinks(wrapper)).toEqual([ '/admin/system-prompts', '/admin/codex-runtime', + '/admin/codex-fingerprint', '/admin/codex-gateway-borrow', '/admin/pelican-tests', '/admin/reasoning-recovery', diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index 662082bf6b17..c21053e94367 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -189,6 +189,7 @@ export default { accounts: 'Accounts', plugins: 'Plugins', codexRuntime: 'Codex runtime settings', + codexFingerprint: 'Codex fingerprint', proxies: 'Proxies', redeemCodes: 'Redeem Codes', ops: 'Ops', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index b0415043422a..3034f6acfffc 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -1017,10 +1017,10 @@ export default {accounts: { codexCLIOnlyAppServerDesc: '仅在上方开关开启时生效。开启后本账号额外放行内嵌 Codex 引擎、经 app-server 协议接入的第三方客户端(如 Claude Code 的 codex 插件),仍需通过全局引擎指纹门;与全局 app-server 开关取 OR(任一开即放行)。', codexFingerprintMode: 'Codex 指纹收敛', codexFingerprintModeDesc: '多人共享同一 OAuth 账号时,将各用户的设备/会话标识收敛为账号级恒定值,减少上游可见的设备数和会话数。默认“仅设备”:一个账号对上游只表现为一台设备,并配合账号级的 Codex 客户端身份;选择关闭则原样透传客户端标识。', - codexFingerprintOff: '关闭(透传)', + codexFingerprintOff: '保留设备/会话标识', codexFingerprintDevice: '仅设备', codexFingerprintSession: '设备+会话', - codexFingerprintFull: '完全收敛', + codexFingerprintFull: '设备+会话+线程', codexImageTool: 'Codex 图片桥接策略', codexImageToolDesc: '统一控制 Codex /responses 文本请求的 hosted image_generation 桥接和客户端图片工具声明。hosted 工具自动注入仅适用于非 Responses Lite 请求;账号级策略优先于渠道和全局配置,不影响独立图片生成接口。', diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts index 55ee64e3a5d2..79c67a1902d1 100644 --- a/frontend/src/i18n/locales/zh/common.ts +++ b/frontend/src/i18n/locales/zh/common.ts @@ -189,6 +189,7 @@ export default { accounts: '账号管理', plugins: '插件管理', codexRuntime: 'Codex 运行设置', + codexFingerprint: 'Codex 指纹', proxies: 'IP管理', redeemCodes: '兑换码', ops: '运维监控', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index a1ec28c64dbe..e305923fa475 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -543,6 +543,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/admin/CodexRuntimeView.vue'), meta: { requiresAuth: true, requiresAdmin: true, title: 'Codex runtime settings', titleKey: 'nav.codexRuntime' } }, + { + path: '/admin/codex-fingerprint', + name: 'AdminCodexFingerprint', + component: () => import('@/views/admin/CodexFingerprintView.vue'), + meta: { requiresAuth: true, requiresAdmin: true, title: 'Codex fingerprint', titleKey: 'nav.codexFingerprint' } + }, { path: '/admin/codex-gateway-borrow', name: 'AdminCodexGatewayBorrow', diff --git a/frontend/src/utils/accountTestModels.ts b/frontend/src/utils/accountTestModels.ts index 15c828d79b9c..397c6a55eaa2 100644 --- a/frontend/src/utils/accountTestModels.ts +++ b/frontend/src/utils/accountTestModels.ts @@ -1,7 +1,11 @@ import type { AccountAvailableModel, AccountTestPlanView } from '@/types' export function isAccountTestReasoningValid(model: AccountAvailableModel | undefined, effort: string): boolean { - return effort === '' || model?.reasoning_efforts?.includes(effort) === true + return effort === '' || (effort.trim().toLowerCase() !== 'ultra' && model?.reasoning_efforts?.includes(effort) === true) +} + +export function accountTestReasoningLevels(model: AccountAvailableModel | undefined): string[] { + return (model?.reasoning_efforts || []).filter(effort => effort.trim().toLowerCase() !== 'ultra') } export function validateAccountTestPlan(value: unknown, accountID: number): AccountTestPlanView { @@ -14,6 +18,8 @@ export function validateAccountTestPlan(value: unknown, accountID: number): Acco if (!model || typeof model.id !== 'string' || !model.id || ids.has(model.id) || typeof model.display_name !== 'string' || !model.display_name) throw new Error('Invalid account test model') ids.add(model.id) + if (model.reasoning_efforts) model.reasoning_efforts = accountTestReasoningLevels(model) + if (model.default_reasoning_effort?.trim().toLowerCase() === 'ultra') delete model.default_reasoning_effort } for (const view of Object.values(plan.mode_views)) { if (!view || !Array.isArray(view.model_ids) || new Set(view.model_ids).size !== view.model_ids.length || diff --git a/frontend/src/views/admin/CodexFingerprintView.vue b/frontend/src/views/admin/CodexFingerprintView.vue new file mode 100644 index 000000000000..24fb1a90d476 --- /dev/null +++ b/frontend/src/views/admin/CodexFingerprintView.vue @@ -0,0 +1,228 @@ + + + diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 746e401f3be6..6351e4a9592a 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -5806,100 +5806,6 @@

- -
- - -

- {{ - t( - "admin.settings.gatewayForwarding.openaiCodexUserAgentHint", - ) - }} -

-
- - -
- - -

- {{ - t( - "admin.settings.gatewayForwarding.openaiCodexClientVersionHint", - ) - }} -

-
- - -
-
- -

- {{ - t( - "admin.settings.gatewayForwarding.openaiCodexVersionAutoSyncHint", - ) - }} -

-

- {{ codexSyncedVersionLabel }} -

-
- -
-