fix: stabilize Codex overdraft detection and account fingerprints

This commit is contained in:
bmiddle1.backend.zhangfucheng committed 2026-08-22 10:21:01 +08:00
1 parent 73ea828a5f
commit 5aa4a065f3
18 files changed
+263 -28

No files matched your search

+11 -2
View File
@@ -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 官方更新
+16 -2
View File
@@ -9,6 +9,8 @@
- 参考实现:<https://github.com/Mxucc/cpa-account-config-manager>
- 功能开关:`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. 账号禁用、过载、代理/传输故障、模型级冷却及其他临时不可调度原因不被绕过。
+1 -1
View File
@@ -1 +1 @@
0.1.179-overdraft.1
0.1.179-overdraft.2
+4
View File
@@ -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)
@@ -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)
}
}
@@ -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)
}
@@ -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) {
@@ -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
}
}
@@ -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)
@@ -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()
@@ -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 {
@@ -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 {
@@ -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)
@@ -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)
+29 -11
View File
@@ -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 {
@@ -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)
+3
View File
@@ -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` 的模板文件路径。
#
+1
View File
@@ -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}