fix: stabilize Codex overdraft detection and account fingerprints
This commit is contained in:
1 parent
73ea828a5f
commit
5aa4a065f3
18 files changed
+263
-28
No files matched your search
@@ -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 官方更新
|
||||
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
0.1.179-overdraft.1
|
||||
0.1.179-overdraft.2
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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` 的模板文件路径。
|
||||
#
|
||||
|
||||
@@ -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}
|
||||
Reference in new issue
Block a user