From 5aa4a065f3d6d282d6aa284178148ff3f37c243b Mon Sep 17 00:00:00 2001 From: "bmiddle1.backend.zhangfucheng" <5286482+zhang_fucheng@user.noreply.gitee.com> Date: Sat, 22 Aug 2026 10:21:01 +0800 Subject: [PATCH] fix: stabilize Codex overdraft detection and account fingerprints --- CODEX_OVERDRAFT_DEPLOYMENT_CN.md | 13 ++- CODEX_QUOTA_OVERDRAFT_CUSTOMIZATION.md | 18 ++++- FORK_VERSION | 2 +- backend/internal/config/config.go | 4 + .../internal/service/account_test_service.go | 2 +- .../service/openai_codex_fingerprint.go | 81 +++++++++++++++++-- .../service/openai_codex_fingerprint_test.go | 33 ++++++++ .../openai_codex_quota_overdraft_probe.go | 27 ++++++- ...openai_codex_quota_overdraft_probe_test.go | 12 +++ .../service/openai_gateway_forward.go | 2 +- .../service/openai_gateway_passthrough.go | 2 +- .../service/openai_ws_forwarder_payload.go | 1 + .../openai_ws_forwarder_success_test.go | 34 ++++++++ .../service/openai_ws_forwarder_v2.go | 1 + backend/internal/service/openai_ws_pool.go | 40 ++++++--- .../openai_ws_v2_passthrough_adapter.go | 15 ++++ deploy/config.example.yaml | 3 + deploy/docker-compose.overdraft.yml | 1 + 18 files changed, 263 insertions(+), 28 deletions(-) diff --git a/CODEX_OVERDRAFT_DEPLOYMENT_CN.md b/CODEX_OVERDRAFT_DEPLOYMENT_CN.md index 501917f33..5bc63f4cc 100644 --- a/CODEX_OVERDRAFT_DEPLOYMENT_CN.md +++ b/CODEX_OVERDRAFT_DEPLOYMENT_CN.md @@ -92,6 +92,15 @@ openssl rand -hex 32 GATEWAY_CODEX_QUOTA_OVERDRAFT_ENABLED=true ``` +OpenAI OAuth 账号固定指纹默认开启,配置键为: + +```bash +GATEWAY_OPENAI_ACCOUNT_UNIQUE_FINGERPRINT_ENABLED=true +``` + +它会按账号固定 Codex 设备指纹;单个账号可在 `extra` 中设置 +`codex_fingerprint_mode: off` 关闭。该开关只影响 OpenAI OAuth/Codex 账号,不影响 API Key。 + ### 3. 创建数据目录 ```bash @@ -390,13 +399,13 @@ docker compose \ 如果服务器目录是 `/opt/sub2api`,只需替换第一条路径。升级完成后重新执行“判断功能是否生效”中的镜像、环境变量和日志检查。 -更新后可在管理后台点击刷新,版本应显示为类似 `v0.1.179-overdraft.1`,更新方式应提示源码构建使用 `git pull`。也可以直接检查容器内二进制版本: +更新后可在管理后台点击刷新,版本应显示为类似 `v0.1.179-overdraft.2`,更新方式应提示源码构建使用 `git pull`。也可以直接检查容器内二进制版本: ```bash docker exec sub2api /app/sub2api -version ``` -维护者发布新的源码版本时必须递增 `FORK_VERSION`,例如从 `0.1.177-overdraft.3` 改为 `0.1.177-overdraft.4`。同步到新的上游 Sub2API 版本时,使用 `0.1.179-overdraft.1` 这样的版本号。 +维护者发布新的源码版本时必须递增 `FORK_VERSION`,例如从 `0.1.177-overdraft.3` 改为 `0.1.177-overdraft.4`。同步到新的上游 Sub2API 版本时,使用 `0.1.179-overdraft.2` 这样的版本号。 ## 合并 Sub2API 官方更新 diff --git a/CODEX_QUOTA_OVERDRAFT_CUSTOMIZATION.md b/CODEX_QUOTA_OVERDRAFT_CUSTOMIZATION.md index 4c0d8cffe..2da763d35 100644 --- a/CODEX_QUOTA_OVERDRAFT_CUSTOMIZATION.md +++ b/CODEX_QUOTA_OVERDRAFT_CUSTOMIZATION.md @@ -9,6 +9,8 @@ - 参考实现: - 功能开关:`gateway.codex_quota_overdraft_enabled` - 代码默认值:关闭;`deploy/config.example.yaml` 部署示例默认开启 +- OpenAI 账号固定指纹开关:`gateway.openai_account_unique_fingerprint_enabled` +- 固定指纹开关默认开启;没有显式账号模式时按账号 ID 派生稳定设备指纹,账号 extra 中显式设置 `codex_fingerprint_mode: off` 可单独关闭 - Fork 版本文件:`FORK_VERSION` - 更新源:`DeanZFC/sub2api-overdraft` 的 `codex-overdraft` 分支 @@ -16,7 +18,7 @@ 源码 Docker 构建会读取根目录的 `FORK_VERSION`,并以 `BuildType=source` 写入二进制。后台更新服务通过 GitHub Contents API 读取 Fork 分支上的同名文件,使用语义化版本比较判断是否有新版本。Redis 缓存同时记录仓库和构建类型,因此旧的官方更新缓存不会继续生效。 -源码构建仅显示 `git pull` 更新提示,`PerformUpdate`、指定版本回退和在线回退列表均被禁用,防止官方二进制覆盖透支功能。维护者每次发布 Fork 更新都必须递增 `FORK_VERSION`;当前版本为 `0.1.179-overdraft.1`。 +源码构建仅显示 `git pull` 更新提示,`PerformUpdate`、指定版本回退和在线回退列表均被禁用,防止官方二进制覆盖透支功能。维护者每次发布 Fork 更新都必须递增 `FORK_VERSION`;当前版本为 `0.1.179-overdraft.2`。 Sub2API `v0.1.179` 继续保留原生 remote compaction v2 在 `/responses` 路径。本定制同时检查旧 Compact 路径和原生 v2 请求信号,二者都不会开启额度透支调度或注入透支请求形态。 @@ -43,10 +45,22 @@ jsonb_build_object($1::text, $2::jsonb) ```yaml gateway: codex_quota_overdraft_enabled: true + openai_account_unique_fingerprint_enabled: true ``` 修改配置后重启 sub2api。出现上游兼容问题时可改为 `false` 并重启,立即恢复官方调度和请求行为。 +## OpenAI 账号固定指纹 + +固定指纹行为覆盖 HTTP Responses、OpenAI 透传、Responses WebSocket 和账号测试/额度探测。 +默认模式为 `device`:同一 OpenAI OAuth 账号的 `x-codex-installation-id` 在请求、重启和 +重新构建后保持不变,不同账号按账号 ID 派生不同 UUID。已经配置 `device`、`session`、`full` +或 `off` 的账号保持原有显式配置优先级;全局开关关闭后,未显式配置模式的账号恢复原样。 + +老账号即使没有 `codex_fingerprint_seed` 也不需要立即执行迁移,运行时会使用账号 ID 派生稳定 +种子;显式 `device/session/full` 模式仍要求账号已有有效种子。该功能只对 OpenAI OAuth/Codex +账号生效,不会改写 API Key 或其他平台账号。 + ## 完整行为 开启后,对 OpenAI OAuth 普通账号和 Agent Identity 账号生效,Shadow 账号除外。支持路径: @@ -64,7 +78,7 @@ gateway: 1. 实际注入的业务请求成功时直接判定 `passed`,账号继续调度并开始透支统计,不额外消耗探测请求。 2. 实际注入的业务请求返回明确额度 429 时直接判定 `failed`,账号暂停到对应额度恢复时间并切号;5h 和 7d 同时耗尽时取最晚恢复时间。 3. 如果没有可用的业务成功证据,同一额度周期仅启动 1 次独立 Responses 探测,最长 20 秒并优先使用当前业务模型。多实例使用 PostgreSQL 原子 claim 去重。 -4. 独立探测成功即判定 `passed`,明确 `quota_limited` 即判定 `failed`;网络错误、超时、5xx、普通瞬时 429 和无效响应判定 `inconclusive`,同周期不自动重试。 +4. 独立探测成功即判定 `passed`,明确 `quota_limited` 即判定 `failed`;网络错误、超时、5xx、普通瞬时 429(包括 `error.type=rate_limit_error`)和无效响应判定 `inconclusive`,同周期不自动重试。 5. `failed` 状态、临时暂停和调度 outbox 在同一数据库事务中提交。同周期 `failed` 为终态,晚到的业务成功或探测结果不能覆盖;业务明确额度 429 可以把并发产生的 `passed` 或 `inconclusive` 收敛为 `failed`。 6. 401/403、账号停用等认证问题交给原有认证异常逻辑处理;400/404 判定为 `inconclusive`,不会暂停整个账号。 7. 账号禁用、过载、代理/传输故障、模型级冷却及其他临时不可调度原因不被绕过。 diff --git a/FORK_VERSION b/FORK_VERSION index e4c0fdf36..540ffff00 100644 --- a/FORK_VERSION +++ b/FORK_VERSION @@ -1 +1 @@ -0.1.179-overdraft.1 +0.1.179-overdraft.2 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f0ed03679..9eb8b4b04 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -972,6 +972,9 @@ type GatewayConfig struct { // CodexQuotaOverdraftEnabled: 是否启用 OpenAI OAuth Codex 5h/7d 额度透支。 // 包括请求注入、五次真实复核、调度门控和透支期用量统计;默认关闭。 CodexQuotaOverdraftEnabled bool `mapstructure:"codex_quota_overdraft_enabled"` + // OpenAIAccountUniqueFingerprintEnabled: 是否为每个 OpenAI OAuth 账号固定唯一的 + // Codex 设备指纹。默认开启;显式设置账号 extra.codex_fingerprint_mode=off 可对单个账号关闭。 + OpenAIAccountUniqueFingerprintEnabled bool `mapstructure:"openai_account_unique_fingerprint_enabled"` // ForcedCodexInstructionsTemplateFile: 服务端强制附加到 Codex 顶层 instructions 的模板文件路径。 // 模板渲染后会直接覆盖最终 instructions;若需要保留客户端 system 转换结果,请在模板中显式引用 {{ .ExistingInstructions }}。 ForcedCodexInstructionsTemplateFile string `mapstructure:"forced_codex_instructions_template_file"` @@ -2343,6 +2346,7 @@ func setDefaults() { viper.SetDefault("gateway.disable_codex_originator_normalization", false) viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false) viper.SetDefault("gateway.codex_quota_overdraft_enabled", false) + viper.SetDefault("gateway.openai_account_unique_fingerprint_enabled", true) viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false) viper.SetDefault("gateway.openai_compact_model", "gpt-5.4") viper.SetDefault("gateway.live.max_session_duration_seconds", 3600) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 6f7e1463f..30d79c16f 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -2114,7 +2114,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, s.cfg != nil && s.cfg.Gateway.OpenAIAccountUniqueFingerprintEnabled); fpIDs != nil { applyCodexFingerprintHeaders(req.Header, fpIDs) } } diff --git a/backend/internal/service/openai_codex_fingerprint.go b/backend/internal/service/openai_codex_fingerprint.go index 0f841fb2f..9bf5dec1f 100644 --- a/backend/internal/service/openai_codex_fingerprint.go +++ b/backend/internal/service/openai_codex_fingerprint.go @@ -57,6 +57,26 @@ func applyStagedCodexFingerprintClientMetadata(c *gin.Context, account *Account, return applyCodexFingerprintClientMetadata(reqBody, stagedCodexFingerprintIDs(c, account)) } +// ensureStagedCodexFingerprintIDs 为没有经过 HTTP JSON 转发入口的 WS 请求 +// 初始化账号级指纹快照。快照放在 gin context 中,保证同一轮请求的请求体和 +// 握手头使用同一份 IDs;已有快照则复用,避免 session/full 模式生成两套随机 ID。 +func ensureStagedCodexFingerprintIDs(c *gin.Context, account *Account, enabled bool) *codexFingerprintIDs { + if account == nil || !account.IsOpenAIOAuth() { + return nil + } + if ids := stagedCodexFingerprintIDs(c, account); ids != nil { + return ids + } + var clientHeaders http.Header + if c != nil && c.Request != nil { + clientHeaders = c.Request.Header + } + ids := resolveCodexFingerprintIDsFromRequest(account, clientHeaders, enabled) + // 即使结果为 nil 也要覆写 context,防止 failover 后沿用上一账号快照。 + stageCodexFingerprintIDs(c, ids) + return ids +} + // codexFingerprintMode 控制 OAuth 账号出站请求的设备指纹收敛强度。 // 多人共享同一 OAuth 账号时,每个用户的 Codex 客户端会携带各自不同的 // installation_id / session_id / thread_id,上游据此判定设备数和会话数。 @@ -65,7 +85,8 @@ type codexFingerprintMode string const ( // codexFingerprintOff 不做任何收敛,原样透传客户端标识。 - // 这是默认值:收敛是显式 opt-in 的(见 GetCodexFingerprintMode)。 + // 账号 extra 未显式配置模式时,GetCodexFingerprintMode 返回此值; + // 出站请求是否按全局开关提升到 device 模式由 resolveCodexFingerprintMode 决定。 codexFingerprintOff codexFingerprintMode = "off" // codexFingerprintDevice 仅收敛 installation_id 为账号级恒定值。 // 上游看到 1 台设备 + 多会话(每用户各自的 session)。 @@ -209,6 +230,35 @@ func (a *Account) GetCodexFingerprintMode() codexFingerprintMode { return codexFingerprintModeFromExtra(a.Extra) } +// resolveCodexFingerprintMode resolves the effective account mode. An explicit +// per-account value always wins; when the global switch is enabled and the +// account has no mode key, device-level convergence is enabled by default. +func resolveCodexFingerprintMode(account *Account, enabled bool) (codexFingerprintMode, bool) { + if account == nil || !account.IsOpenAIOAuth() { + return codexFingerprintOff, false + } + if account.Extra != nil { + if _, configured := account.Extra[codexFingerprintModeExtraKey]; configured { + return codexFingerprintModeFromExtra(account.Extra), false + } + } + if enabled { + return codexFingerprintDevice, true + } + return codexFingerprintOff, false +} + +// deriveAccountCodexFingerprintSeed gives existing accounts a stable seed even +// before a database migration has materialized codex_fingerprint_seed. Account +// IDs are unique and durable, so the same account keeps the same device ID +// across requests and process restarts without mutating account extra. +func deriveAccountCodexFingerprintSeed(account *Account) string { + if account == nil || account.ID <= 0 { + return "" + } + return deriveStableUUIDv4(fmt.Sprintf("sub2api:openai-account-fingerprint:v1:%d", account.ID)) +} + // deriveStableUUIDv4 从种子确定性派生一个 UUIDv4 格式的字符串。 // 同一种子永远返回同一值。 func deriveStableUUIDv4(seed string) string { @@ -280,11 +330,22 @@ type codexFingerprintIDs struct { // 返回 nil 表示 off 模式,不需要改写。 // 注意:包含随机生成的 turn_id,调用方必须只调用一次并共享结果给头改写和体改写。 func resolveCodexFingerprintIDs(account *Account, clientSessionID string, mode codexFingerprintMode) *codexFingerprintIDs { + return resolveCodexFingerprintIDsWithSeed(account, clientSessionID, mode, "") +} + +func resolveCodexFingerprintIDsWithSeed(account *Account, clientSessionID string, mode codexFingerprintMode, seedOverride string) *codexFingerprintIDs { if account == nil || mode == codexFingerprintOff { return nil } - seed, ok := codexFingerprintSeed(account.Extra) - if !ok { + seed := strings.TrimSpace(seedOverride) + if seed == "" { + var ok bool + seed, ok = codexFingerprintSeed(account.Extra) + if !ok { + return nil + } + } + if seed == "" { return nil } @@ -337,11 +398,12 @@ func extractClientSessionID(h http.Header) string { // resolveCodexFingerprintIDsFromRequest 从客户端原始请求头中提取 session-id, // 结合账号配置一次性解析收敛 ID 集合。调用方应将返回的 ids 同时传给 // applyCodexFingerprintHeaders 和 applyCodexFingerprintClientMetadata。 -func resolveCodexFingerprintIDsFromRequest(account *Account, clientHeaders http.Header) *codexFingerprintIDs { +func resolveCodexFingerprintIDsFromRequest(account *Account, clientHeaders http.Header, uniqueFingerprintEnabled ...bool) *codexFingerprintIDs { if account == nil { return nil } - mode := account.GetCodexFingerprintMode() + enabled := len(uniqueFingerprintEnabled) > 0 && uniqueFingerprintEnabled[0] + mode, isDefault := resolveCodexFingerprintMode(account, enabled) if mode == codexFingerprintOff { return nil } @@ -349,6 +411,15 @@ func resolveCodexFingerprintIDsFromRequest(account *Account, clientHeaders http. if clientHeaders != nil { clientSessionID = extractClientSessionID(clientHeaders) } + if isDefault { + seed := deriveAccountCodexFingerprintSeed(account) + // 已有系统管理种子优先,保证迁移过的账号不会因连接池键与 + // 实际出站头使用不同种子而被拆成多组连接。 + if persisted, ok := codexFingerprintSeed(account.Extra); ok { + seed = persisted + } + return resolveCodexFingerprintIDsWithSeed(account, clientSessionID, mode, seed) + } return resolveCodexFingerprintIDs(account, clientSessionID, mode) } diff --git a/backend/internal/service/openai_codex_fingerprint_test.go b/backend/internal/service/openai_codex_fingerprint_test.go index 841ee3437..338a0fb5f 100644 --- a/backend/internal/service/openai_codex_fingerprint_test.go +++ b/backend/internal/service/openai_codex_fingerprint_test.go @@ -129,6 +129,39 @@ func TestResolveCodexFingerprintIDsFromRequest_ExplicitOff(t *testing.T) { assert.Nil(t, ids, "显式 off 模式应返回 nil") } +func TestResolveCodexFingerprintIDsFromRequest_DefaultEnabledIsStablePerAccount(t *testing.T) { + account := &Account{ID: 901, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + first := resolveCodexFingerprintIDsFromRequest(account, nil, true) + second := resolveCodexFingerprintIDsFromRequest(account, nil, true) + other := resolveCodexFingerprintIDsFromRequest(&Account{ID: 902, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, nil, true) + + require.NotNil(t, first) + require.NotNil(t, second) + require.NotNil(t, other) + assert.Equal(t, codexFingerprintDevice, first.mode) + assert.Equal(t, first.installationID, second.installationID) + assert.NotEqual(t, first.installationID, other.installationID) + assert.Nil(t, resolveCodexFingerprintIDsFromRequest(account, nil, false)) + assert.Nil(t, resolveCodexFingerprintIDsFromRequest(&Account{ + ID: account.ID, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{codexFingerprintModeExtraKey: "off"}, + }, nil, true)) +} + +func TestResolveCodexFingerprintIDsFromRequest_DefaultEnabledUsesPersistedSeed(t *testing.T) { + account := &Account{ + ID: 903, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{codexFingerprintSeedExtraKey: testCodexFingerprintSeed}, + } + ids := resolveCodexFingerprintIDsFromRequest(account, nil, true) + require.NotNil(t, ids) + assert.Equal(t, resolveConvergedInstallationID(account, testCodexFingerprintSeed), ids.installationID) +} + // 未显式配置的存量账号不得被收敛(#5610):默认返回 nil,出站身份保持 // v0.1.175 之前的客户端原值。 func TestResolveCodexFingerprintIDsFromRequest_DefaultIsOff(t *testing.T) { diff --git a/backend/internal/service/openai_codex_quota_overdraft_probe.go b/backend/internal/service/openai_codex_quota_overdraft_probe.go index 4ea323727..96229f22d 100644 --- a/backend/internal/service/openai_codex_quota_overdraft_probe.go +++ b/backend/internal/service/openai_codex_quota_overdraft_probe.go @@ -911,9 +911,6 @@ func codexQuotaOverdraftProbeModels(preferred string) []string { func codexQuotaOverdraftResponseIsQuotaLimited(headers http.Header, body []byte) bool { var payload any parsedPayload := len(bytes.TrimSpace(body)) > 0 && json.Unmarshal(body, &payload) == nil - if parsedPayload && codexQuotaOverdraftJSONHasQuotaEvidence(payload, 0) { - return true - } text := strings.ToLower(strings.Join(strings.Fields(string(body)), " ")) for _, marker := range []string{ "usage_limit_reached", @@ -928,9 +925,15 @@ func codexQuotaOverdraftResponseIsQuotaLimited(headers http.Header, body []byte) return true } } + if codexQuotaOverdraftTextHasTransientRateLimitEvidence(text) { + return false + } if parsedPayload && codexQuotaOverdraftJSONHasTransientRateLimitEvidence(payload, 0) { return false } + if parsedPayload && codexQuotaOverdraftJSONHasQuotaEvidence(payload, 0) { + return true + } if snapshot := ParseCodexRateLimitHeaders(headers); snapshot != nil { if normalized := snapshot.Normalize(); normalized != nil && (normalized.Used5hPercent != nil && *normalized.Used5hPercent >= 100 || @@ -941,6 +944,22 @@ func codexQuotaOverdraftResponseIsQuotaLimited(headers http.Header, body []byte) return false } +func codexQuotaOverdraftTextHasTransientRateLimitEvidence(text string) bool { + normalized := strings.NewReplacer("-", "_", " ", "_").Replace(strings.ToLower(text)) + for _, marker := range []string{ + "rate_limit_error", + "rate_limit_exceeded", + "too_many_requests", + "request_rate_limited", + "token_rate_limited", + } { + if strings.Contains(normalized, marker) { + return true + } + } + return false +} + func codexQuotaOverdraftJSONHasQuotaEvidence(value any, depth int) bool { if depth > 6 { return false @@ -1016,7 +1035,7 @@ func codexQuotaOverdraftJSONHasTransientRateLimitEvidence(value any, depth int) if marker, ok := raw.(string); ok { marker = strings.NewReplacer("-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(marker))) switch marker { - case "rate_limit_exceeded", "too_many_requests", "request_rate_limited", "token_rate_limited": + case "rate_limit_error", "rate_limit_exceeded", "too_many_requests", "request_rate_limited", "token_rate_limited": return true } } diff --git a/backend/internal/service/openai_codex_quota_overdraft_probe_test.go b/backend/internal/service/openai_codex_quota_overdraft_probe_test.go index 31ea45f69..5fbfe29dc 100644 --- a/backend/internal/service/openai_codex_quota_overdraft_probe_test.go +++ b/backend/internal/service/openai_codex_quota_overdraft_probe_test.go @@ -528,6 +528,18 @@ func TestClassifyCodexQuotaOverdraftProbeResponses(t *testing.T) { require.Equal(t, "inconclusive", status) require.Equal(t, "transient_failure", reason) + status, reason = classifyCodexQuotaOverdraftProbe(http.StatusTooManyRequests, headers, []byte(`{"error":{"type":"rate_limit_error","message":"temporarily limited"}}`)) + require.Equal(t, "inconclusive", status) + require.Equal(t, "transient_failure", reason) + + status, reason = classifyCodexQuotaOverdraftProbe(http.StatusTooManyRequests, headers, []byte(`{"error":{"type":"rate_limit_error"},"usage":{"used_percent":100}}`)) + require.Equal(t, "inconclusive", status) + require.Equal(t, "transient_failure", reason) + + status, reason = classifyCodexQuotaOverdraftProbe(http.StatusTooManyRequests, headers, []byte("data: {\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\"}}\n\n")) + require.Equal(t, "inconclusive", status) + require.Equal(t, "transient_failure", reason) + status, reason = classifyCodexQuotaOverdraftProbe(http.StatusServiceUnavailable, nil, nil) require.Equal(t, "inconclusive", status) require.Equal(t, "upstream_unavailable", reason) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 7e3829c9f..c3e2f893f 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -432,7 +432,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if c != nil && c.Request != nil { clientHeaders = c.Request.Header } - fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) + fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders, s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIAccountUniqueFingerprintEnabled) if fpIDs != nil { if applyCodexFingerprintClientMetadata(decoded, fpIDs) { markDecodedModified() diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index c5b227fea..b96faeeb5 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -169,7 +169,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( if c != nil && c.Request != nil { clientHeaders = c.Request.Header } - fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) + fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders, s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIAccountUniqueFingerprintEnabled) if fpIDs != nil { fpBody, fpChanged, fpErr := applyCodexFingerprintClientMetadataRaw(body, fpIDs) if fpErr != nil { diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index 3d6fef10b..d497c7a4f 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -134,6 +134,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( if metadata := strings.TrimSpace(turnMetadata); metadata != "" { headers.Set(openAIWSTurnMetadataHeader, metadata) } + ensureStagedCodexFingerprintIDs(c, account, s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIAccountUniqueFingerprintEnabled) applyStagedCodexFingerprintHeaders(c, account, headers) if account != nil && account.Type == AccountTypeOAuth { diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index 2c6977638..1aadc096f 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -471,6 +471,40 @@ func TestOpenAIGatewayService_BuildOpenAIWSHeadersDeviceModePreservesClientSessi require.Equal(t, "client-request", headers.Get("x-client-request-id")) } +func TestOpenAIGatewayService_BuildOpenAIWSHeaders_DefaultFingerprintIsStablePerAccount(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set("X-Codex-Installation-ID", "client-installation") + c.Request.Header.Set("session-id", "client-session") + + account := &Account{ID: 1301, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{ + OpenAIAccountUniqueFingerprintEnabled: true, + }}} + + first, _, err := svc.buildOpenAIWSHeaders( + context.Background(), c, account, "token", + OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2}, + true, "", "", "", "", "", + ) + require.NoError(t, err) + second, _, err := svc.buildOpenAIWSHeaders( + context.Background(), c, account, "token", + OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2}, + true, "", "", "", "", "", + ) + require.NoError(t, err) + + installationID := first.Get("x-codex-installation-id") + require.NotEmpty(t, installationID) + require.Equal(t, installationID, second.Get("x-codex-installation-id")) + require.NotEqual(t, "client-installation", installationID) + require.Equal(t, installationID, resolveConvergedInstallationID(account, deriveAccountCodexFingerprintSeed(account))) +} + func TestLogOpenAIWSBindResponseAccountWarn(t *testing.T) { require.NotPanics(t, func() { logOpenAIWSBindResponseAccountWarn(1, 2, "resp_ok", nil) diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 4ec3d3a67..a5533dd61 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -69,6 +69,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( turnMetadata = strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)) } setOpenAIWSTurnMetadata(payload, turnMetadata) + ensureStagedCodexFingerprintIDs(c, account, s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIAccountUniqueFingerprintEnabled) applyStagedCodexFingerprintClientMetadata(c, account, payload) previousResponseID := openAIWSPayloadString(payload, "previous_response_id") previousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(previousResponseID) diff --git a/backend/internal/service/openai_ws_pool.go b/backend/internal/service/openai_ws_pool.go index 2c62b6b4c..b9a941b20 100644 --- a/backend/internal/service/openai_ws_pool.go +++ b/backend/internal/service/openai_ws_pool.go @@ -861,7 +861,7 @@ func (p *openAIWSConnPool) acquire(ctx context.Context, req openAIWSAcquireReque retryAcquire: accountID := req.Account.ID - compatibility := normalizeOpenAIWSHandshakeCompatibility(req.Account, req.Headers) + compatibility := normalizeOpenAIWSHandshakeCompatibility(req.Account, req.Headers, p != nil && p.cfg != nil && p.cfg.Gateway.OpenAIAccountUniqueFingerprintEnabled) routingAffinity := normalizeOpenAIWSRoutingAffinity(req.Headers) effectiveMaxConns := p.effectiveMaxConnsByAccount(req.Account) if effectiveMaxConns <= 0 { @@ -1647,7 +1647,7 @@ func (p *openAIWSConnPool) prewarmConns(accountID int64, req openAIWSAcquireRequ conn.close() continue } - if !sameOpenAIWSPrewarmTarget(req, *ap.lastAcquire) { + if !sameOpenAIWSPrewarmTarget(req, *ap.lastAcquire, p != nil && p.cfg != nil && p.cfg.Gateway.OpenAIAccountUniqueFingerprintEnabled) { staleTarget = true ap.signalChangedLocked() ap.mu.Unlock() @@ -1807,7 +1807,7 @@ func (p *openAIWSConnPool) dialConn(ctx context.Context, req openAIWSAcquireRequ } id := p.nextConnID(req.Account.ID) pooledConn := newOpenAIWSConn(id, req.Account.ID, conn, handshakeHeaders) - pooledConn.handshakeCompatibility = normalizeOpenAIWSHandshakeCompatibility(req.Account, req.Headers) + pooledConn.handshakeCompatibility = normalizeOpenAIWSHandshakeCompatibility(req.Account, req.Headers, p != nil && p.cfg != nil && p.cfg.Gateway.OpenAIAccountUniqueFingerprintEnabled) pooledConn.routingAffinity = normalizeOpenAIWSRoutingAffinity(req.Headers) return pooledConn, nil } @@ -1987,10 +1987,10 @@ func cloneOpenAIWSAcquireRequestPtr(req *openAIWSAcquireRequest) *openAIWSAcquir return &copied } -func sameOpenAIWSPrewarmTarget(a, b openAIWSAcquireRequest) bool { +func sameOpenAIWSPrewarmTarget(a, b openAIWSAcquireRequest, uniqueFingerprintEnabled ...bool) bool { return stringsTrim(a.WSURL) == stringsTrim(b.WSURL) && stringsTrim(a.ProxyURL) == stringsTrim(b.ProxyURL) && - normalizeOpenAIWSHandshakeCompatibility(a.Account, a.Headers) == normalizeOpenAIWSHandshakeCompatibility(b.Account, b.Headers) + normalizeOpenAIWSHandshakeCompatibility(a.Account, a.Headers, uniqueFingerprintEnabled...) == normalizeOpenAIWSHandshakeCompatibility(b.Account, b.Headers, uniqueFingerprintEnabled...) } func normalizeOpenAIWSBetaFeatures(headers http.Header) string { @@ -2018,15 +2018,28 @@ 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, uniqueFingerprintEnabled ...bool) openAIWSHandshakeCompatibilityKey { key := openAIWSHandshakeCompatibilityKey{ betaFeatures: normalizeOpenAIWSBetaFeatures(headers), } - mode := activeCodexFingerprintMode(account) + mode, isDefault := resolveCodexFingerprintMode(account, len(uniqueFingerprintEnabled) > 0 && uniqueFingerprintEnabled[0]) if mode == codexFingerprintOff { return key } - key.codexInstallationID = normalizeOpenAIWSStableIdentityHeader(headers, "x-codex-installation-id") + if !isDefault { + if _, ok := codexFingerprintSeed(account.Extra); !ok { + return key + } + } + if isDefault { + seed := deriveAccountCodexFingerprintSeed(account) + if persisted, ok := codexFingerprintSeed(account.Extra); ok { + seed = persisted + } + key.codexInstallationID = resolveConvergedInstallationID(account, seed) + } else { + key.codexInstallationID = normalizeOpenAIWSStableIdentityHeader(headers, "x-codex-installation-id") + } if mode == codexFingerprintDevice { return key } @@ -2038,14 +2051,19 @@ func normalizeOpenAIWSHandshakeCompatibility(account *Account, headers http.Head return key } -func activeCodexFingerprintMode(account *Account) codexFingerprintMode { - if account == nil || account.GetCodexFingerprintMode() == codexFingerprintOff { +func activeCodexFingerprintMode(account *Account, uniqueFingerprintEnabled ...bool) codexFingerprintMode { + enabled := len(uniqueFingerprintEnabled) > 0 && uniqueFingerprintEnabled[0] + mode, isDefault := resolveCodexFingerprintMode(account, enabled) + if mode == codexFingerprintOff { return codexFingerprintOff } + if isDefault { + return mode + } if _, ok := codexFingerprintSeed(account.Extra); !ok { return codexFingerprintOff } - return account.GetCodexFingerprintMode() + return mode } func normalizeOpenAIWSStableIdentityHeader(headers http.Header, name string) string { diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 663643626..bcbd5f3f0 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -753,6 +753,14 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } firstClientMessage = updatedFirst firstClientMessage = s.prepareCodexQuotaOverdraftBody(ctx, account, false, firstClientMessage) + ensureStagedCodexFingerprintIDs(c, account, s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIAccountUniqueFingerprintEnabled) + if fingerprintIDs := stagedCodexFingerprintIDs(c, account); fingerprintIDs != nil { + var fingerprintErr error + firstClientMessage, _, fingerprintErr = applyCodexFingerprintClientMetadataRaw(firstClientMessage, fingerprintIDs) + if fingerprintErr != nil { + return fmt.Errorf("apply codex fingerprint to first ws frame: %w", fingerprintErr) + } + } // 在 policy filter 之后再提取 service_tier / reasoning_effort 用于 // usage 上报:filter @@ -1039,6 +1047,13 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( // service_tier 时按 default 处理,billing 应如实反映。 if policyErr == nil && blocked == nil && isResponseCreate { out = s.prepareCodexQuotaOverdraftBody(ctx, account, false, out) + if fingerprintIDs := stagedCodexFingerprintIDs(c, account); fingerprintIDs != nil { + var fingerprintErr error + out, _, fingerprintErr = applyCodexFingerprintClientMetadataRaw(out, fingerprintIDs) + if fingerprintErr != nil { + return out, nil, fmt.Errorf("apply codex fingerprint to ws frame: %w", fingerprintErr) + } + } usageMeta.updateFromResponseCreate(out, model, requestModelForThisFrame) responseCreateAtCopy := responseCreateAt acceptedTurnStartedAt.Store(&responseCreateAtCopy) diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index b5eb16aa2..2bea03e8f 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -275,6 +275,9 @@ gateway: # 实验性 Codex 额度透支:为符合条件的 OAuth 请求注入空操作工具调用,并忽略 # 5h/7d 用量百分比预暂停。真实上游 429 仍会触发账号冷却。此部署示例默认开启。 codex_quota_overdraft_enabled: true + # 为每个 OpenAI OAuth 账号固定唯一且稳定的 Codex 设备指纹;默认开启。 + # 单个账号可在 extra 中显式设置 codex_fingerprint_mode: off 关闭。 + openai_account_unique_fingerprint_enabled: true # Optional: template file used to build the final top-level Codex `instructions`. # 可选:用于构建最终 Codex 顶层 `instructions` 的模板文件路径。 # diff --git a/deploy/docker-compose.overdraft.yml b/deploy/docker-compose.overdraft.yml index 0ba503eda..1fbe3b13d 100644 --- a/deploy/docker-compose.overdraft.yml +++ b/deploy/docker-compose.overdraft.yml @@ -16,3 +16,4 @@ services: BUILD_TYPE: source environment: - GATEWAY_CODEX_QUOTA_OVERDRAFT_ENABLED=${GATEWAY_CODEX_QUOTA_OVERDRAFT_ENABLED:-true} + - GATEWAY_OPENAI_ACCOUNT_UNIQUE_FINGERPRINT_ENABLED=${GATEWAY_OPENAI_ACCOUNT_UNIQUE_FINGERPRINT_ENABLED:-true}