feat: harden Codex quota overdraft probing
This commit is contained in:
1 parent
9babce2dd3
commit
2a3d7200ef
23 files changed
+1849
-115
No files matched your search
@@ -270,9 +270,13 @@ docker logs --since 30m sub2api 2>&1 | \
|
||||
| --- | --- |
|
||||
| `codex_quota_overdraft_probe_passed` | 至少一次真实探测成功,透支成立,账号继续参与调度 |
|
||||
| `codex_quota_overdraft_probe_failed` | 5 次都明确返回额度限制,账号暂停至额度恢复时间 |
|
||||
| `codex_quota_overdraft_probe_inconclusive` | 网络、超时、5xx 等导致无法确认,约 1 分钟后可重试 |
|
||||
| `codex_quota_overdraft_probe_attempt` | 单次探测结果,包含模型、HTTP 状态码、结果和原因,不记录凭据及完整响应正文 |
|
||||
| `codex_quota_overdraft_probe_inconclusive` | 网络、超时、5xx 等导致无法确认,按 1、3、10 分钟退避重试 |
|
||||
| `codex_quota_overdraft_retry_scheduled` | 已安排主动重试 |
|
||||
| `codex_quota_overdraft_retry_started` | 主动重试开始;服务重启后也会由数据库到期扫描恢复 |
|
||||
| `codex_quota_overdraft_probe_claim_failed` | 无法在 PostgreSQL 中原子领取探测任务,需要排查数据库或版本 |
|
||||
| `codex_quota_overdraft_state_persist_failed` | 状态无法写入 `accounts.extra`,页面可能不显示最新结果 |
|
||||
| `codex_quota_overdraft_pause_applied` | `failed` 状态、账号暂停和调度通知已经原子提交 |
|
||||
| `codex_quota_overdraft_stale_rate_limit_cleared` | 探测已证明账号可用,并清除了并发 429 遗留的账号级限流状态 |
|
||||
|
||||
真正“透支成功”的最直接证据是:
|
||||
@@ -312,7 +316,7 @@ docker compose \
|
||||
|
||||
## 探测逻辑
|
||||
|
||||
每个额度周期最多探测 5 次,每次最长 20 秒。模型按以下顺序循环:
|
||||
每个额度周期的单轮计划最多探测 5 次,每次最长 20 秒。模型按以下顺序循环:
|
||||
|
||||
```text
|
||||
首选模型 -> gpt-5.5 -> gpt-5.4-mini -> 首选模型 -> gpt-5.5
|
||||
@@ -324,6 +328,8 @@ docker compose \
|
||||
- 401/403 继续由原有认证失效逻辑处理。
|
||||
- 账号禁用、过载、代理故障、模型冷却等非额度限制不会被本功能绕过。
|
||||
|
||||
无法确认时,`retry_count` 记录当前退避轮次。后台会依次等待 1 分钟、3 分钟和 10 分钟执行最多 3 轮自动重试;每分钟的数据库扫描会恢复因容器重启而丢失的内存定时器,并接续超过 2 分钟的遗留 `pending` 状态。达到自动重试上限后不会持续消耗上游请求,但后续额度刷新、账号测试或新的明确额度响应仍可继续观察。
|
||||
|
||||
多实例部署通过 PostgreSQL 原子领取(atomic claim)保证同一账号、同一额度周期只有一个实例发起探测。
|
||||
|
||||
## 反向代理
|
||||
|
||||
@@ -65,10 +65,11 @@ gateway:
|
||||
2. 最多执行 5 次真实 Responses 请求,每次独立 20 秒超时。
|
||||
3. 模型按“首选模型、`gpt-5.5`、`gpt-5.4-mini`”循环。例如首选为 `gpt-5.4` 时,顺序是 `gpt-5.4`、`gpt-5.5`、`gpt-5.4-mini`、`gpt-5.4`、`gpt-5.5`。
|
||||
4. 任意一次返回有效的 `response.completed` 或 `response.output_item.done` 即判定 `passed`,账号继续调度并开始透支统计。
|
||||
5. 只有 5 次均返回明确 `quota_limited` 才判定 `failed`,账号暂停到对应额度恢复时间;5h 和 7d 同时耗尽时取最晚恢复时间。
|
||||
6. 网络错误、超时、5xx、普通瞬时 429、无效响应、认证依赖不可用均判定 `inconclusive`,不会误停账号,一分钟后允许重试。
|
||||
7. 401/403 仍交给原有认证失效策略处理;账号禁用、过载、代理/传输故障、模型级冷却及其他临时不可调度原因不被绕过。
|
||||
8. 额度恢复后状态改为 `recovered`,清理本功能或额度阈值产生的暂停;5h 与 7d 分别维护透支起点,不会因另一窗口后来耗尽而重置已有统计。
|
||||
5. 只有 5 次均返回明确 `quota_limited` 才判定 `failed`,账号暂停到对应额度恢复时间;5h 和 7d 同时耗尽时取最晚恢复时间。`failed` 状态、临时暂停和调度 outbox 在同一数据库事务中提交,避免出现状态已失败但账号没有暂停。
|
||||
6. 网络错误、超时、5xx、普通瞬时 429 和无效响应均判定 `inconclusive`,不会误停账号;后台按 1 分钟、3 分钟、10 分钟主动重试,并每分钟扫描数据库恢复服务重启前已到期的任务。重启时遗留超过 2 分钟的 `pending` 状态也会自动接续。每个额度周期最多自动重试 3 轮,之后仍可由额度刷新或新请求继续观察。
|
||||
7. 401/403、账号停用等认证问题交给原有认证异常逻辑处理,不加入额度重试;400/404 会轮换全部探测模型后再判定为 `inconclusive`,不会暂停整个账号。
|
||||
8. 账号禁用、过载、代理/传输故障、模型级冷却及其他临时不可调度原因不被绕过。
|
||||
9. 额度恢复后状态改为 `recovered`,清理本功能或额度阈值产生的暂停;5h 与 7d 分别维护透支起点,不会因另一窗口后来耗尽而重置已有统计。
|
||||
|
||||
状态值包括 `pending`、`passed`、`failed`、`inconclusive`、`recovered`。账号用量页面显示探测状态、尝试次数、额度周期、透支期成功请求数、Token、账号金额及预计恢复时间。
|
||||
|
||||
@@ -155,6 +156,10 @@ git diff --check
|
||||
codex_quota_overdraft_probe_passed
|
||||
codex_quota_overdraft_probe_failed
|
||||
codex_quota_overdraft_probe_inconclusive
|
||||
codex_quota_overdraft_probe_attempt
|
||||
codex_quota_overdraft_retry_scheduled
|
||||
codex_quota_overdraft_retry_started
|
||||
codex_quota_overdraft_pause_applied
|
||||
codex_quota_overdraft_stale_rate_limit_cleared
|
||||
```
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.1.177-overdraft.1
|
||||
0.1.177-overdraft.2
|
||||
@@ -24,7 +24,8 @@ English | [中文](README_CN.md) | [日本語](README_JA.md)
|
||||
- Runs up to five real probes after the reported Codex 5h or 7d quota reaches 100%.
|
||||
- Keeps an account schedulable after a successful probe and tracks overdraft requests, tokens, cost, and recovery for both windows independently.
|
||||
- Exposes `pending`, `passed`, `failed`, `inconclusive`, and `recovered` states in the admin UI and PostgreSQL.
|
||||
- Uses an atomic PostgreSQL claim for multi-instance deployments and requires no schema migration.
|
||||
- Actively retries inconclusive probes after 1, 3, and 10 minutes, including recovery from persisted retry state after a restart.
|
||||
- Uses an atomic PostgreSQL claim for multi-instance deployments and atomically commits terminal failure, account pause, and scheduler notification without a schema migration.
|
||||
- Supports an immediate configuration rollback to the upstream scheduling behavior.
|
||||
|
||||
See the **[Chinese deployment and operations guide](CODEX_OVERDRAFT_DEPLOYMENT_CN.md)** for source builds, migration, verification, upgrades, rollback, Nginx, and troubleshooting. Maintainers should also read [CODEX_QUOTA_OVERDRAFT_CUSTOMIZATION.md](CODEX_QUOTA_OVERDRAFT_CUSTOMIZATION.md).
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ docker logs --since 30m sub2api 2>&1 | \
|
||||
grep -E 'codex_quota_overdraft_(probe|state|pause|stale_rate_limit)'
|
||||
```
|
||||
|
||||
出现 `codex_quota_overdraft_probe_passed`,页面显示“透支中”,并且后续真实业务请求成功,即可确认透支功能完整生效。OpenAI OAuth 常规文本“测试账号连接”也会使用透支请求形态并接入同一探测状态机;API Key、Shadow、图片和 Compact 测试除外。额度未达到 100% 时没有探测日志是正常现象。
|
||||
出现 `codex_quota_overdraft_probe_passed`,页面显示“透支中”,并且后续真实业务请求成功,即可确认透支功能完整生效。无法确认的探测会按 1、3、10 分钟主动重试,容器重启后由持久化状态继续;5 次明确额度失败时,探测状态、账号暂停和调度通知会原子提交。OpenAI OAuth 常规文本“测试账号连接”也会使用透支请求形态并接入同一探测状态机;API Key、Shadow、图片和 Compact 测试除外。额度未达到 100% 时没有探测日志是正常现象。
|
||||
|
||||
## 来源、许可证与风险
|
||||
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
- Codex の 5h または 7d クォータが 100% に達した後、最大 5 回の実リクエストで利用可否を検証します。
|
||||
- 検証成功後もアカウントをスケジュール対象に保ち、リクエスト数、Token、コスト、回復時刻を表示します。
|
||||
- `pending`、`passed`、`failed`、`inconclusive`、`recovered` の状態を管理画面と PostgreSQL に保存します。
|
||||
- PostgreSQL の atomic claim により複数インスタンスで重複検証を防ぎ、追加の schema migration は不要です。
|
||||
- 判定不能な検証は 1 分、3 分、10 分後に再試行し、再起動後も PostgreSQL の保存状態から再開します。
|
||||
- PostgreSQL の atomic claim により複数インスタンスで重複検証を防ぎ、最終失敗・アカウント停止・スケジューラ通知を同一トランザクションで保存します。追加の schema migration は不要です。
|
||||
|
||||
ソースビルド、既存環境からの移行、検証、更新、ロールバック、Nginx、トラブルシューティングについては、**[中国語のデプロイ・運用ガイド](CODEX_OVERDRAFT_DEPLOYMENT_CN.md)** を参照してください。
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository, settingService)
|
||||
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService)
|
||||
usageCache := service.NewUsageCache()
|
||||
codexQuotaOverdraftCoordinator := service.ProvideCodexQuotaOverdraftCoordinator(accountRepository, httpUpstream, openAITokenProvider, tlsFingerprintProfileService, configConfig, tempUnschedCache, openAIGatewayService, rateLimitService)
|
||||
codexQuotaOverdraftCoordinator := service.ProvideCodexQuotaOverdraftCoordinator(accountRepository, httpUpstream, openAITokenProvider, tlsFingerprintProfileService, configConfig, tempUnschedCache, openAIGatewayService, rateLimitService, timingWheelService)
|
||||
accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService, codexQuotaOverdraftCoordinator)
|
||||
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, codexQuotaOverdraftCoordinator)
|
||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||
|
||||
@@ -2636,6 +2636,145 @@ func (r *accountRepository) ClaimCodexQuotaOverdraftProbe(
|
||||
return true, nil
|
||||
}
|
||||
|
||||
type codexQuotaOverdraftTxBeginner interface {
|
||||
BeginTx(context.Context, *sql.TxOptions) (*sql.Tx, error)
|
||||
}
|
||||
|
||||
// FinalizeCodexQuotaOverdraftProbeFailed persists the terminal probe state,
|
||||
// account pause, and scheduler notification in one transaction.
|
||||
func (r *accountRepository) FinalizeCodexQuotaOverdraftProbeFailed(
|
||||
ctx context.Context,
|
||||
id int64,
|
||||
state *service.CodexQuotaOverdraftProbeState,
|
||||
until time.Time,
|
||||
reason string,
|
||||
) (bool, error) {
|
||||
if state == nil || state.Status != "failed" || strings.TrimSpace(state.CycleKey) == "" || until.IsZero() {
|
||||
return false, nil
|
||||
}
|
||||
payload, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
beginner, ok := r.sql.(codexQuotaOverdraftTxBeginner)
|
||||
if !ok {
|
||||
return false, errors.New("account repository does not support transactions")
|
||||
}
|
||||
tx, err := beginner.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE accounts
|
||||
SET extra = COALESCE(extra, '{}'::jsonb) || jsonb_build_object($1::text, $2::jsonb),
|
||||
temp_unschedulable_until = CASE
|
||||
WHEN temp_unschedulable_until IS NULL OR temp_unschedulable_until < $3 THEN $3
|
||||
ELSE temp_unschedulable_until
|
||||
END,
|
||||
temp_unschedulable_reason = CASE
|
||||
WHEN temp_unschedulable_until IS NULL OR temp_unschedulable_until < $3 THEN $4
|
||||
ELSE temp_unschedulable_reason
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $5
|
||||
AND deleted_at IS NULL
|
||||
AND extra #>> '{codex_quota_overdraft_probe,cycle_key}' = $6
|
||||
AND extra #>> '{codex_quota_overdraft_probe,status}' IN ('pending', 'failed')
|
||||
`, service.CodexQuotaOverdraftProbeExtraKey, string(payload), until, reason, id, state.CycleKey)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if affected == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if err := enqueueSchedulerOutbox(ctx, tx, service.SchedulerOutboxEventAccountChanged, &id, nil, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
r.syncSchedulerAccountSnapshotDetached(ctx, id)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ListDueCodexQuotaOverdraftProbes returns a bounded set of persisted
|
||||
// inconclusive or stale pending probes so the retry sweep can resume work.
|
||||
func (r *accountRepository) ListDueCodexQuotaOverdraftProbes(ctx context.Context, now time.Time, limit int) ([]*service.Account, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
rows, err := r.sql.QueryContext(ctx, `
|
||||
SELECT id
|
||||
FROM accounts
|
||||
WHERE deleted_at IS NULL
|
||||
AND platform = $1
|
||||
AND type = $2
|
||||
AND status = $3
|
||||
AND schedulable IS TRUE
|
||||
AND parent_account_id IS NULL
|
||||
AND (
|
||||
(
|
||||
extra #>> '{codex_quota_overdraft_probe,status}' = 'inconclusive'
|
||||
AND COALESCE(NULLIF(extra #>> '{codex_quota_overdraft_probe,retry_at}', '')::timestamptz, 'infinity'::timestamptz) <= $4
|
||||
AND COALESCE(NULLIF(extra #>> '{codex_quota_overdraft_probe,retry_count}', '')::integer, 0) <= $5
|
||||
)
|
||||
OR (
|
||||
extra #>> '{codex_quota_overdraft_probe,status}' = 'pending'
|
||||
AND COALESCE(NULLIF(extra #>> '{codex_quota_overdraft_probe,started_at}', '')::timestamptz, 'infinity'::timestamptz) <= $4 - INTERVAL '2 minutes'
|
||||
)
|
||||
)
|
||||
AND COALESCE(NULLIF(extra #>> '{codex_quota_overdraft_probe,recover_at}', '')::timestamptz, 'infinity'::timestamptz) > $4
|
||||
ORDER BY COALESCE(
|
||||
NULLIF(extra #>> '{codex_quota_overdraft_probe,retry_at}', '')::timestamptz,
|
||||
NULLIF(extra #>> '{codex_quota_overdraft_probe,started_at}', '')::timestamptz
|
||||
) ASC, id ASC
|
||||
LIMIT $6
|
||||
`, service.PlatformOpenAI, service.AccountTypeOAuth, service.StatusActive, now, 3, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := make([]int64, 0, limit)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
accounts, err := r.GetByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := make(map[int64]*service.Account, len(accounts))
|
||||
for i := range accounts {
|
||||
account := accounts[i]
|
||||
byID[account.ID] = account
|
||||
}
|
||||
ordered := make([]*service.Account, 0, len(accounts))
|
||||
for _, id := range ids {
|
||||
if account := byID[id]; account != nil {
|
||||
ordered = append(ordered, account)
|
||||
}
|
||||
}
|
||||
return ordered, nil
|
||||
}
|
||||
|
||||
// UpdateUpstreamBillingProbeSnapshot stores a probe result only while the
|
||||
// network identity used by that probe is still current.
|
||||
func (r *accountRepository) UpdateUpstreamBillingProbeSnapshot(
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFinalizeCodexQuotaOverdraftProbeFailedIsAtomic(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
now := time.Now().UTC()
|
||||
until := now.Add(5 * time.Hour)
|
||||
state := &service.CodexQuotaOverdraftProbeState{
|
||||
Status: "failed",
|
||||
CycleKey: "5h:quota-cycle",
|
||||
QuotaWindow: "5h",
|
||||
Attempts: 5,
|
||||
Limit: 5,
|
||||
StartedAt: now,
|
||||
RecoverAt: &until,
|
||||
}
|
||||
reason := service.BuildTempUnschedReasonPayload("codex_quota_overdraft", "quota exhausted")
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(`(?s)UPDATE accounts.*temp_unschedulable_until.*codex_quota_overdraft_probe`).
|
||||
WithArgs(service.CodexQuotaOverdraftProbeExtraKey, sqlmock.AnyArg(), until, reason, int64(77), state.CycleKey).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload, dedup_key)")).
|
||||
WithArgs(service.SchedulerOutboxEventAccountChanged, int64(77), nil, nil, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
repo := newAccountRepositoryWithSQL(nil, db, nil)
|
||||
finalized, err := repo.FinalizeCodexQuotaOverdraftProbeFailed(context.Background(), 77, state, until, reason)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, finalized)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestFinalizeCodexQuotaOverdraftProbeFailedRollsBackWhenOutboxFails(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
now := time.Now().UTC()
|
||||
until := now.Add(5 * time.Hour)
|
||||
state := &service.CodexQuotaOverdraftProbeState{
|
||||
Status: "failed",
|
||||
CycleKey: "5h:quota-cycle",
|
||||
QuotaWindow: "5h",
|
||||
Attempts: 5,
|
||||
Limit: 5,
|
||||
StartedAt: now,
|
||||
RecoverAt: &until,
|
||||
}
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(`(?s)UPDATE accounts.*temp_unschedulable_until.*codex_quota_overdraft_probe`).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload, dedup_key)")).
|
||||
WillReturnError(errors.New("outbox unavailable"))
|
||||
mock.ExpectRollback()
|
||||
|
||||
repo := newAccountRepositoryWithSQL(nil, db, nil)
|
||||
finalized, err := repo.FinalizeCodexQuotaOverdraftProbeFailed(context.Background(), 77, state, until, "quota exhausted")
|
||||
|
||||
require.ErrorContains(t, err, "outbox unavailable")
|
||||
require.False(t, finalized)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestListDueCodexQuotaOverdraftProbesIncludesInconclusiveAndStalePending(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
now := time.Now().UTC()
|
||||
mock.ExpectQuery(`(?s)SELECT id.*status}' = 'inconclusive'.*retry_count.*status}' = 'pending'.*INTERVAL '2 minutes'`).
|
||||
WithArgs(service.PlatformOpenAI, service.AccountTypeOAuth, service.StatusActive, now, 3, 100).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
|
||||
repo := newAccountRepositoryWithSQL(nil, db, nil)
|
||||
accounts, err := repo.ListDueCodexQuotaOverdraftProbes(context.Background(), now, 100)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, accounts)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
@@ -30,9 +30,12 @@ const (
|
||||
codexQuotaOverdraftProbeAttemptLimit = 5
|
||||
codexQuotaOverdraftProbeAttemptTimeout = 20 * time.Second
|
||||
codexQuotaOverdraftProbePlanTimeout = codexQuotaOverdraftProbeAttemptLimit * codexQuotaOverdraftProbeAttemptTimeout
|
||||
codexQuotaOverdraftProbeRetryDelay = time.Minute
|
||||
codexQuotaOverdraftProbeBodyLimit = 256 << 10
|
||||
codexQuotaOverdraftPauseSource = "codex_quota_overdraft"
|
||||
codexQuotaOverdraftPersistAttempts = 3
|
||||
codexQuotaOverdraftAutomaticRetries = 3
|
||||
codexQuotaOverdraftRetrySweepInterval = time.Minute
|
||||
codexQuotaOverdraftRetrySweepLimit = 100
|
||||
|
||||
codexQuotaOverdraftFallbackModel = "gpt-5.5"
|
||||
codexQuotaOverdraftCompatibilityModel = "gpt-5.4-mini"
|
||||
@@ -51,6 +54,7 @@ type CodexQuotaOverdraftProbeState struct {
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
TestedAt *time.Time `json:"tested_at,omitempty"`
|
||||
RetryAt *time.Time `json:"retry_at,omitempty"`
|
||||
RetryCount int `json:"retry_count,omitempty"`
|
||||
RecoverAt *time.Time `json:"recover_at,omitempty"`
|
||||
FiveHourRecoverAt *time.Time `json:"five_hour_recover_at,omitempty"`
|
||||
SevenDayRecoverAt *time.Time `json:"seven_day_recover_at,omitempty"`
|
||||
@@ -81,6 +85,20 @@ type codexQuotaOverdraftProbeClaimer interface {
|
||||
ClaimCodexQuotaOverdraftProbe(context.Context, int64, *CodexQuotaOverdraftProbeState) (bool, error)
|
||||
}
|
||||
|
||||
type codexQuotaOverdraftFailedFinalizer interface {
|
||||
FinalizeCodexQuotaOverdraftProbeFailed(context.Context, int64, *CodexQuotaOverdraftProbeState, time.Time, string) (bool, error)
|
||||
}
|
||||
|
||||
type codexQuotaOverdraftDueProbeLister interface {
|
||||
ListDueCodexQuotaOverdraftProbes(context.Context, time.Time, int) ([]*Account, error)
|
||||
}
|
||||
|
||||
type codexQuotaOverdraftRetryScheduler interface {
|
||||
Schedule(string, time.Duration, func())
|
||||
ScheduleRecurring(string, time.Duration, func())
|
||||
Cancel(string)
|
||||
}
|
||||
|
||||
// CodexQuotaOverdraftCoordinator implements the bounded real-request gate used
|
||||
// by cpa-account-config-manager before it disables a quota-exhausted account.
|
||||
type CodexQuotaOverdraftCoordinator struct {
|
||||
@@ -95,6 +113,7 @@ type CodexQuotaOverdraftCoordinator struct {
|
||||
agentIdentityWS agentIdentityWSConnectionInvalidator
|
||||
agentIdentityTaskMu sync.Mutex
|
||||
running sync.Map
|
||||
retryScheduler codexQuotaOverdraftRetryScheduler
|
||||
now func() time.Time
|
||||
probeAttemptForTest func(context.Context, *Account, string) codexQuotaOverdraftProbeResult
|
||||
}
|
||||
@@ -124,6 +143,16 @@ func NewCodexQuotaOverdraftCoordinator(
|
||||
return coordinator
|
||||
}
|
||||
|
||||
// SetRetryScheduler enables active retry timers and a small persisted-state
|
||||
// sweep so inconclusive or stale pending probes resume after a restart.
|
||||
func (c *CodexQuotaOverdraftCoordinator) SetRetryScheduler(scheduler codexQuotaOverdraftRetryScheduler) {
|
||||
if c == nil || scheduler == nil {
|
||||
return
|
||||
}
|
||||
c.retryScheduler = scheduler
|
||||
scheduler.ScheduleRecurring("codex-overdraft:retry-sweep", codexQuotaOverdraftRetrySweepInterval, c.resumeDueProbes)
|
||||
}
|
||||
|
||||
func (c *CodexQuotaOverdraftCoordinator) enabled() bool {
|
||||
return c != nil && c.cfg != nil && c.cfg.Gateway.CodexQuotaOverdraftEnabled &&
|
||||
c.accountRepo != nil && c.httpUpstream != nil
|
||||
@@ -226,6 +255,9 @@ func (c *CodexQuotaOverdraftCoordinator) startProbe(account *Account, signal cod
|
||||
SevenDayRecoverAt: cloneTimePtr(signal.SevenDayRecoverAt),
|
||||
ObservedRateLimit: cloneTimePtr(account.RateLimitResetAt),
|
||||
}
|
||||
if current != nil && codexQuotaOverdraftStateCoversSignal(current, signal) {
|
||||
state.RetryCount = current.RetryCount
|
||||
}
|
||||
carryCodexQuotaOverdraftWindowStarts(state, current, signal, startedAt)
|
||||
claimed, err := c.claimProbe(account.ID, state)
|
||||
if err != nil {
|
||||
@@ -270,7 +302,7 @@ func (c *CodexQuotaOverdraftCoordinator) runProbePlan(
|
||||
|
||||
account, err := c.accountRepo.GetByID(planCtx, accountID)
|
||||
if err != nil || !isCodexQuotaOverdraftAccount(account) {
|
||||
c.finishInconclusive(accountID, state, "upstream_unavailable")
|
||||
c.finishInconclusive(accountID, signal, preferredModel, state, "upstream_unavailable")
|
||||
return
|
||||
}
|
||||
models := codexQuotaOverdraftProbeModels(preferredModel)
|
||||
@@ -278,7 +310,7 @@ func (c *CodexQuotaOverdraftCoordinator) runProbePlan(
|
||||
quotaLimitedAttempts := 0
|
||||
for attempt := 0; attempt < codexQuotaOverdraftProbeAttemptLimit; attempt++ {
|
||||
if planCtx.Err() != nil {
|
||||
c.finishInconclusive(accountID, state, "request_timeout")
|
||||
c.finishInconclusive(accountID, signal, preferredModel, state, "request_timeout")
|
||||
return
|
||||
}
|
||||
model := models[attempt%len(models)]
|
||||
@@ -294,6 +326,16 @@ func (c *CodexQuotaOverdraftCoordinator) runProbePlan(
|
||||
if result.ReasonCode == "quota_limited" {
|
||||
quotaLimitedAttempts++
|
||||
}
|
||||
slog.Info("codex_quota_overdraft_probe_attempt",
|
||||
"account_id", accountID,
|
||||
"attempt", state.Attempts,
|
||||
"limit", codexQuotaOverdraftProbeAttemptLimit,
|
||||
"model", state.Model,
|
||||
"status_code", result.StatusCode,
|
||||
"result_status", result.Status,
|
||||
"reason", result.ReasonCode,
|
||||
"quota_window", signal.Window,
|
||||
)
|
||||
c.persistState(accountID, state)
|
||||
|
||||
if result.Status == "available" {
|
||||
@@ -302,44 +344,182 @@ func (c *CodexQuotaOverdraftCoordinator) runProbePlan(
|
||||
state.RetryAt = nil
|
||||
startCodexQuotaOverdraftWindows(state, signal, testedAt)
|
||||
c.persistState(accountID, state)
|
||||
c.cancelRetry(accountID)
|
||||
c.clearQuotaPause(accountID, state)
|
||||
slog.Info("codex_quota_overdraft_probe_passed", "account_id", accountID, "attempts", state.Attempts, "model", state.Model, "quota_window", signal.Window)
|
||||
return
|
||||
}
|
||||
if result.Status == "inconclusive" {
|
||||
c.finishInconclusive(accountID, state, result.ReasonCode)
|
||||
c.finishInconclusive(accountID, signal, preferredModel, state, result.ReasonCode)
|
||||
return
|
||||
}
|
||||
if result.Status == "authentication_failed" {
|
||||
if c.rateLimitService != nil {
|
||||
c.rateLimitService.HandleUpstreamError(context.Background(), account, result.StatusCode, result.Headers, result.Body)
|
||||
}
|
||||
c.finishInconclusive(accountID, state, result.ReasonCode)
|
||||
c.finishInconclusive(accountID, signal, preferredModel, state, result.ReasonCode)
|
||||
return
|
||||
}
|
||||
}
|
||||
if quotaLimitedAttempts < codexQuotaOverdraftProbeAttemptLimit {
|
||||
c.finishInconclusive(accountID, state, lastReason)
|
||||
c.finishInconclusive(accountID, signal, preferredModel, state, lastReason)
|
||||
return
|
||||
}
|
||||
|
||||
state.Status = codexQuotaOverdraftProbeFailed
|
||||
state.ReasonCode = lastReason
|
||||
state.RetryAt = nil
|
||||
c.persistState(accountID, state)
|
||||
c.ensureFailedPause(account, state)
|
||||
c.cancelRetry(accountID)
|
||||
if !c.ensureFailedPause(account, state) {
|
||||
return
|
||||
}
|
||||
slog.Warn("codex_quota_overdraft_probe_failed", "account_id", accountID, "attempts", state.Attempts, "quota_window", signal.Window, "recover_at", state.RecoverAt)
|
||||
}
|
||||
|
||||
func (c *CodexQuotaOverdraftCoordinator) finishInconclusive(accountID int64, state *CodexQuotaOverdraftProbeState, reason string) {
|
||||
func (c *CodexQuotaOverdraftCoordinator) finishInconclusive(
|
||||
accountID int64,
|
||||
signal codexQuotaOverdraftSignal,
|
||||
preferredModel string,
|
||||
state *CodexQuotaOverdraftProbeState,
|
||||
reason string,
|
||||
) {
|
||||
now := c.currentTime().UTC()
|
||||
retryAt := now.Add(codexQuotaOverdraftProbeRetryDelay)
|
||||
retryable := reason != "authentication_failed" && reason != "account_deactivated"
|
||||
state.Status = codexQuotaOverdraftProbeInconclusive
|
||||
state.ReasonCode = reason
|
||||
state.TestedAt = &now
|
||||
state.RetryAt = &retryAt
|
||||
state.RetryAt = nil
|
||||
if retryable && state.RetryCount < codexQuotaOverdraftAutomaticRetries {
|
||||
state.RetryCount++
|
||||
retryAt := now.Add(codexQuotaOverdraftRetryDelay(state.RetryCount))
|
||||
state.RetryAt = &retryAt
|
||||
}
|
||||
c.persistState(accountID, state)
|
||||
slog.Warn("codex_quota_overdraft_probe_inconclusive", "account_id", accountID, "attempts", state.Attempts, "reason", reason)
|
||||
if state.RetryAt != nil {
|
||||
c.scheduleRetry(accountID, signal.CycleKey, preferredModel, *state.RetryAt)
|
||||
}
|
||||
slog.Warn("codex_quota_overdraft_probe_inconclusive",
|
||||
"account_id", accountID,
|
||||
"attempts", state.Attempts,
|
||||
"reason", reason,
|
||||
"retry_count", state.RetryCount,
|
||||
"retry_at", state.RetryAt,
|
||||
)
|
||||
}
|
||||
|
||||
func codexQuotaOverdraftRetryDelay(retryCount int) time.Duration {
|
||||
switch retryCount {
|
||||
case 1:
|
||||
return time.Minute
|
||||
case 2:
|
||||
return 3 * time.Minute
|
||||
default:
|
||||
return 10 * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
func codexQuotaOverdraftRetryTaskName(accountID int64) string {
|
||||
return fmt.Sprintf("codex-overdraft:retry:%d", accountID)
|
||||
}
|
||||
|
||||
func (c *CodexQuotaOverdraftCoordinator) scheduleRetry(accountID int64, cycleKey, preferredModel string, retryAt time.Time) {
|
||||
if c == nil || c.retryScheduler == nil || accountID <= 0 {
|
||||
return
|
||||
}
|
||||
delay := retryAt.Sub(c.currentTime())
|
||||
if delay < time.Second {
|
||||
delay = time.Second
|
||||
}
|
||||
name := codexQuotaOverdraftRetryTaskName(accountID)
|
||||
c.retryScheduler.Schedule(name, delay, func() {
|
||||
c.resumeProbe(accountID, cycleKey, preferredModel)
|
||||
})
|
||||
slog.Info("codex_quota_overdraft_retry_scheduled",
|
||||
"account_id", accountID,
|
||||
"cycle_key", cycleKey,
|
||||
"retry_at", retryAt,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *CodexQuotaOverdraftCoordinator) cancelRetry(accountID int64) {
|
||||
if c == nil || c.retryScheduler == nil || accountID <= 0 {
|
||||
return
|
||||
}
|
||||
c.retryScheduler.Cancel(codexQuotaOverdraftRetryTaskName(accountID))
|
||||
}
|
||||
|
||||
func (c *CodexQuotaOverdraftCoordinator) resumeProbe(accountID int64, cycleKey, preferredModel string) {
|
||||
if !c.enabled() || accountID <= 0 {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
account, err := c.accountRepo.GetByID(ctx, accountID)
|
||||
cancel()
|
||||
if err != nil || !isCodexQuotaOverdraftAccount(account) {
|
||||
slog.Warn("codex_quota_overdraft_retry_load_failed", "account_id", accountID, "error", err)
|
||||
return
|
||||
}
|
||||
state, ok := codexQuotaOverdraftStateFromAccount(account)
|
||||
if !ok || (cycleKey != "" && state.CycleKey != cycleKey) {
|
||||
return
|
||||
}
|
||||
switch state.Status {
|
||||
case codexQuotaOverdraftProbeInconclusive:
|
||||
if state.RetryAt == nil {
|
||||
return
|
||||
}
|
||||
if c.currentTime().Before(*state.RetryAt) {
|
||||
c.scheduleRetry(accountID, state.CycleKey, preferredModel, *state.RetryAt)
|
||||
return
|
||||
}
|
||||
case codexQuotaOverdraftProbePending:
|
||||
staleAt := state.StartedAt.Add(2 * time.Minute)
|
||||
if c.currentTime().Before(staleAt) {
|
||||
c.scheduleRetry(accountID, state.CycleKey, preferredModel, staleAt)
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(preferredModel) == "" {
|
||||
preferredModel = state.Model
|
||||
}
|
||||
slog.Info("codex_quota_overdraft_retry_started",
|
||||
"account_id", accountID,
|
||||
"cycle_key", state.CycleKey,
|
||||
"retry_count", state.RetryCount,
|
||||
)
|
||||
c.observeAccount(account, preferredModel)
|
||||
}
|
||||
|
||||
func (c *CodexQuotaOverdraftCoordinator) resumeDueProbes() {
|
||||
if !c.enabled() {
|
||||
return
|
||||
}
|
||||
lister, ok := c.accountRepo.(codexQuotaOverdraftDueProbeLister)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
accounts, err := lister.ListDueCodexQuotaOverdraftProbes(ctx, c.currentTime(), codexQuotaOverdraftRetrySweepLimit)
|
||||
cancel()
|
||||
if err != nil {
|
||||
slog.Warn("codex_quota_overdraft_retry_sweep_failed", "error", err)
|
||||
return
|
||||
}
|
||||
for _, account := range accounts {
|
||||
if account == nil {
|
||||
continue
|
||||
}
|
||||
state, _ := codexQuotaOverdraftStateFromAccount(account)
|
||||
cycleKey := ""
|
||||
preferredModel := ""
|
||||
if state != nil {
|
||||
cycleKey = state.CycleKey
|
||||
preferredModel = state.Model
|
||||
}
|
||||
go c.resumeProbe(account.ID, cycleKey, preferredModel)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CodexQuotaOverdraftCoordinator) runProbeAttempt(ctx context.Context, account *Account, model string) codexQuotaOverdraftProbeResult {
|
||||
@@ -453,6 +633,9 @@ func classifyCodexQuotaOverdraftProbe(statusCode int, headers http.Header, body
|
||||
lower := bytes.ToLower(body)
|
||||
if bytes.Contains(lower, []byte(`"type":"error"`)) || bytes.Contains(lower, []byte(`"type": "error"`)) ||
|
||||
bytes.Contains(lower, []byte(`"response.failed"`)) {
|
||||
if codexQuotaOverdraftResponseIsQuotaLimited(headers, body) {
|
||||
return "retry", "quota_limited"
|
||||
}
|
||||
return "retry", "invalid_response"
|
||||
}
|
||||
if bytes.Contains(lower, []byte(`"response.completed"`)) || bytes.Contains(lower, []byte(`"response.output_item.done"`)) {
|
||||
@@ -480,35 +663,104 @@ func classifyCodexQuotaOverdraftProbe(statusCode int, headers http.Header, body
|
||||
case http.StatusRequestTimeout, http.StatusGatewayTimeout:
|
||||
return "inconclusive", "request_timeout"
|
||||
default:
|
||||
if codexQuotaOverdraftResponseIsQuotaLimited(headers, body) {
|
||||
return "retry", "quota_limited"
|
||||
}
|
||||
if statusCode >= http.StatusInternalServerError {
|
||||
return "inconclusive", "upstream_unavailable"
|
||||
}
|
||||
if statusCode == http.StatusBadRequest || statusCode == http.StatusNotFound {
|
||||
return "inconclusive", "model_not_found"
|
||||
return "retry", "model_not_found"
|
||||
}
|
||||
return "inconclusive", "invalid_response"
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CodexQuotaOverdraftCoordinator) persistState(accountID int64, state *CodexQuotaOverdraftProbeState) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := c.accountRepo.UpdateExtra(ctx, accountID, map[string]any{CodexQuotaOverdraftProbeExtraKey: state}); err != nil {
|
||||
slog.Warn("codex_quota_overdraft_state_persist_failed", "account_id", accountID, "error", err)
|
||||
func (c *CodexQuotaOverdraftCoordinator) persistState(accountID int64, state *CodexQuotaOverdraftProbeState) bool {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= codexQuotaOverdraftPersistAttempts; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
lastErr = c.accountRepo.UpdateExtra(ctx, accountID, map[string]any{CodexQuotaOverdraftProbeExtraKey: state})
|
||||
cancel()
|
||||
if lastErr == nil {
|
||||
return true
|
||||
}
|
||||
if attempt < codexQuotaOverdraftPersistAttempts {
|
||||
time.Sleep(time.Duration(attempt) * 100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
slog.Warn("codex_quota_overdraft_state_persist_failed",
|
||||
"account_id", accountID,
|
||||
"attempts", codexQuotaOverdraftPersistAttempts,
|
||||
"error", lastErr,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *CodexQuotaOverdraftCoordinator) ensureFailedPause(account *Account, state *CodexQuotaOverdraftProbeState) {
|
||||
func (c *CodexQuotaOverdraftCoordinator) ensureFailedPause(account *Account, state *CodexQuotaOverdraftProbeState) bool {
|
||||
if account == nil || state == nil || state.RecoverAt == nil || !state.RecoverAt.After(c.currentTime()) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
if persisted, ok := codexQuotaOverdraftStateFromAccount(account); ok &&
|
||||
persisted.Status == codexQuotaOverdraftProbeFailed && persisted.CycleKey == state.CycleKey &&
|
||||
account.TempUnschedulableUntil != nil && !account.TempUnschedulableUntil.Before(*state.RecoverAt) {
|
||||
return true
|
||||
}
|
||||
reason := BuildTempUnschedReasonPayload(codexQuotaOverdraftPauseSource, "five real overdraft probes confirmed quota exhaustion")
|
||||
if finalizer, ok := c.accountRepo.(codexQuotaOverdraftFailedFinalizer); ok {
|
||||
finalized := false
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= codexQuotaOverdraftPersistAttempts; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
finalized, lastErr = finalizer.FinalizeCodexQuotaOverdraftProbeFailed(ctx, account.ID, state, *state.RecoverAt, reason)
|
||||
cancel()
|
||||
if lastErr == nil {
|
||||
break
|
||||
}
|
||||
if attempt < codexQuotaOverdraftPersistAttempts {
|
||||
time.Sleep(time.Duration(attempt) * 100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
slog.Warn("codex_quota_overdraft_pause_failed",
|
||||
"account_id", account.ID,
|
||||
"attempts", codexQuotaOverdraftPersistAttempts,
|
||||
"error", lastErr,
|
||||
)
|
||||
return false
|
||||
}
|
||||
if !finalized {
|
||||
slog.Warn("codex_quota_overdraft_pause_stale", "account_id", account.ID, "cycle_key", state.CycleKey)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if !c.persistState(account.ID, state) {
|
||||
return false
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= codexQuotaOverdraftPersistAttempts; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
lastErr = c.accountRepo.SetTempUnschedulable(ctx, account.ID, *state.RecoverAt, reason)
|
||||
cancel()
|
||||
if lastErr == nil {
|
||||
break
|
||||
}
|
||||
if attempt < codexQuotaOverdraftPersistAttempts {
|
||||
time.Sleep(time.Duration(attempt) * 100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
slog.Warn("codex_quota_overdraft_pause_failed",
|
||||
"account_id", account.ID,
|
||||
"attempts", codexQuotaOverdraftPersistAttempts,
|
||||
"error", lastErr,
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
c.cancelRetry(account.ID)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := c.accountRepo.SetTempUnschedulable(ctx, account.ID, *state.RecoverAt, reason); err != nil {
|
||||
slog.Warn("codex_quota_overdraft_pause_failed", "account_id", account.ID, "error", err)
|
||||
return
|
||||
}
|
||||
if c.tempUnschedCache != nil {
|
||||
_ = c.tempUnschedCache.SetTempUnsched(ctx, account.ID, &TempUnschedState{
|
||||
UntilUnix: state.RecoverAt.Unix(),
|
||||
@@ -520,9 +772,12 @@ func (c *CodexQuotaOverdraftCoordinator) ensureFailedPause(account *Account, sta
|
||||
if c.runtimeBlocker != nil {
|
||||
c.runtimeBlocker.BlockAccountScheduling(account, *state.RecoverAt, codexQuotaOverdraftPauseSource)
|
||||
}
|
||||
slog.Info("codex_quota_overdraft_pause_applied", "account_id", account.ID, "until", state.RecoverAt, "cycle_key", state.CycleKey)
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *CodexQuotaOverdraftCoordinator) clearQuotaPause(accountID int64, state *CodexQuotaOverdraftProbeState) {
|
||||
c.cancelRetry(accountID)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
account, err := c.accountRepo.GetByID(ctx, accountID)
|
||||
@@ -605,8 +860,20 @@ func codexQuotaOverdraftResponseIsQuotaLimited(headers http.Header, body []byte)
|
||||
return true
|
||||
}
|
||||
}
|
||||
var payload any
|
||||
if len(bytes.TrimSpace(body)) > 0 && json.Unmarshal(body, &payload) == nil && codexQuotaOverdraftJSONHasQuotaEvidence(payload, 0) {
|
||||
return true
|
||||
}
|
||||
text := strings.ToLower(strings.Join(strings.Fields(string(body)), " "))
|
||||
for _, marker := range []string{"usage_limit_reached", "usage limit has been reached", "quota exhausted", "weekly limit reached"} {
|
||||
for _, marker := range []string{
|
||||
"usage_limit_reached",
|
||||
"usage limit has been reached",
|
||||
"you have reached your usage limit",
|
||||
"quota exhausted",
|
||||
"insufficient quota",
|
||||
"weekly limit reached",
|
||||
"weekly_limit_reached",
|
||||
} {
|
||||
if strings.Contains(text, marker) {
|
||||
return true
|
||||
}
|
||||
@@ -614,6 +881,69 @@ func codexQuotaOverdraftResponseIsQuotaLimited(headers http.Header, body []byte)
|
||||
return false
|
||||
}
|
||||
|
||||
func codexQuotaOverdraftJSONHasQuotaEvidence(value any, depth int) bool {
|
||||
if depth > 6 {
|
||||
return false
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, raw := range typed {
|
||||
normalizedKey := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(key), "-", "_"))
|
||||
switch normalizedKey {
|
||||
case "type", "code", "reason", "error_code":
|
||||
if marker, ok := raw.(string); ok && codexQuotaOverdraftQuotaCode(marker) {
|
||||
return true
|
||||
}
|
||||
case "limit_reached", "limitreached":
|
||||
if reached, ok := raw.(bool); ok && reached {
|
||||
return true
|
||||
}
|
||||
case "used_percent", "usedpercent":
|
||||
if used, ok := raw.(float64); ok && used >= 100 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if codexQuotaOverdraftJSONHasQuotaEvidence(raw, depth+1) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if codexQuotaOverdraftJSONHasQuotaEvidence(item, depth+1) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case string:
|
||||
if codexQuotaOverdraftQuotaCode(typed) {
|
||||
return true
|
||||
}
|
||||
text := strings.ToLower(strings.Join(strings.Fields(typed), " "))
|
||||
for _, marker := range []string{
|
||||
"usage limit has been reached",
|
||||
"you have reached your usage limit",
|
||||
"quota exhausted",
|
||||
"insufficient quota",
|
||||
"weekly limit reached",
|
||||
} {
|
||||
if strings.Contains(text, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func codexQuotaOverdraftQuotaCode(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
value = strings.NewReplacer("-", "_", " ", "_").Replace(value)
|
||||
switch value {
|
||||
case "usage_limit_reached", "weekly_limit_reached", "monthly_limit_reached", "quota_exhausted", "insufficient_quota", "billing_hard_limit_reached":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func codexQuotaOverdraftSignalFromAccount(account *Account, state *CodexQuotaOverdraftProbeState, now time.Time) (codexQuotaOverdraftSignal, bool) {
|
||||
if account == nil || len(account.Extra) == 0 {
|
||||
return codexQuotaOverdraftSignal{}, false
|
||||
|
||||
@@ -26,6 +26,30 @@ type codexOverdraftRuntimeBlockerStub struct {
|
||||
clearCalls int
|
||||
}
|
||||
|
||||
type codexOverdraftRetrySchedulerStub struct {
|
||||
scheduledName string
|
||||
scheduledDelay time.Duration
|
||||
scheduledFn func()
|
||||
recurringName string
|
||||
recurringDelay time.Duration
|
||||
canceledName string
|
||||
}
|
||||
|
||||
func (s *codexOverdraftRetrySchedulerStub) Schedule(name string, delay time.Duration, fn func()) {
|
||||
s.scheduledName = name
|
||||
s.scheduledDelay = delay
|
||||
s.scheduledFn = fn
|
||||
}
|
||||
|
||||
func (s *codexOverdraftRetrySchedulerStub) ScheduleRecurring(name string, delay time.Duration, _ func()) {
|
||||
s.recurringName = name
|
||||
s.recurringDelay = delay
|
||||
}
|
||||
|
||||
func (s *codexOverdraftRetrySchedulerStub) Cancel(name string) {
|
||||
s.canceledName = name
|
||||
}
|
||||
|
||||
func (b *codexOverdraftRuntimeBlockerStub) BlockAccountScheduling(*Account, time.Time, string) {}
|
||||
|
||||
func (b *codexOverdraftRuntimeBlockerStub) ClearAccountSchedulingBlock(int64) {
|
||||
@@ -166,6 +190,30 @@ func TestCodexQuotaOverdraftProbeRequiresFiveExplicitQuotaFailures(t *testing.T)
|
||||
require.False(t, codexQuotaOverdraftSchedulingAllowed(account, now), "同周期确认失败后不得继续绕过限额")
|
||||
}
|
||||
|
||||
func TestCodexQuotaOverdraftFailedPauseIsIdempotentForPersistedCycle(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 13, 14, 0, 0, 0, time.UTC)
|
||||
account := newCodexOverdraftProbeTestAccount(now)
|
||||
recoverAt := now.Add(5 * time.Hour)
|
||||
state := &CodexQuotaOverdraftProbeState{
|
||||
Status: codexQuotaOverdraftProbeFailed,
|
||||
QuotaWindow: "5h",
|
||||
CycleKey: "5h:" + formatCodexOverdraftUnix(recoverAt),
|
||||
Attempts: codexQuotaOverdraftProbeAttemptLimit,
|
||||
Limit: codexQuotaOverdraftProbeAttemptLimit,
|
||||
StartedAt: now,
|
||||
RecoverAt: codexQuotaOverdraftTimePtr(recoverAt),
|
||||
}
|
||||
account.Extra[CodexQuotaOverdraftProbeExtraKey] = state
|
||||
account.TempUnschedulableUntil = codexQuotaOverdraftTimePtr(recoverAt)
|
||||
account.TempUnschedulableReason = BuildTempUnschedReasonPayload(codexQuotaOverdraftPauseSource, "quota exhausted")
|
||||
repo := &codexOverdraftProbeRepoStub{account: account}
|
||||
coordinator := &CodexQuotaOverdraftCoordinator{accountRepo: repo, now: func() time.Time { return now }}
|
||||
|
||||
require.True(t, coordinator.ensureFailedPause(account, state))
|
||||
require.Zero(t, repo.tempPauseCalls)
|
||||
require.Empty(t, repo.states)
|
||||
}
|
||||
|
||||
func TestCodexQuotaOverdraftProbeInconclusiveNeverPauses(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 13, 14, 0, 0, 0, time.UTC)
|
||||
for _, result := range []codexQuotaOverdraftProbeResult{
|
||||
@@ -190,6 +238,93 @@ func TestCodexQuotaOverdraftProbeInconclusiveNeverPauses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexQuotaOverdraftProbeRotatesModelsBeforeInconclusive(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 13, 14, 0, 0, 0, time.UTC)
|
||||
account := newCodexOverdraftProbeTestAccount(now)
|
||||
repo := &codexOverdraftProbeRepoStub{account: account}
|
||||
coordinator := &CodexQuotaOverdraftCoordinator{accountRepo: repo, now: func() time.Time { return now }}
|
||||
models := make([]string, 0, codexQuotaOverdraftProbeAttemptLimit)
|
||||
coordinator.probeAttemptForTest = func(_ context.Context, _ *Account, model string) codexQuotaOverdraftProbeResult {
|
||||
models = append(models, model)
|
||||
return codexQuotaOverdraftProbeResult{Status: "retry", ReasonCode: "model_not_found", StatusCode: http.StatusNotFound, Model: model}
|
||||
}
|
||||
signal, _ := codexQuotaOverdraftSignalFromAccount(account, nil, now)
|
||||
state := newCodexOverdraftPendingState(signal, now)
|
||||
|
||||
coordinator.runProbePlan(account.ID, signal, "gpt-5.4", state)
|
||||
|
||||
require.Equal(t, codexQuotaOverdraftProbeInconclusive, state.Status)
|
||||
require.Equal(t, codexQuotaOverdraftProbeAttemptLimit, state.Attempts)
|
||||
require.Equal(t, 1, state.RetryCount)
|
||||
require.Equal(t, []string{"gpt-5.4", "gpt-5.5", "gpt-5.4-mini", "gpt-5.4", "gpt-5.5"}, models)
|
||||
require.Zero(t, repo.tempPauseCalls)
|
||||
}
|
||||
|
||||
func TestCodexQuotaOverdraftInconclusiveSchedulesBackoffRetry(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 13, 14, 0, 0, 0, time.UTC)
|
||||
account := newCodexOverdraftProbeTestAccount(now)
|
||||
repo := &codexOverdraftProbeRepoStub{account: account}
|
||||
scheduler := &codexOverdraftRetrySchedulerStub{}
|
||||
coordinator := &CodexQuotaOverdraftCoordinator{
|
||||
accountRepo: repo,
|
||||
retryScheduler: scheduler,
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
signal, _ := codexQuotaOverdraftSignalFromAccount(account, nil, now)
|
||||
state := newCodexOverdraftPendingState(signal, now)
|
||||
|
||||
coordinator.finishInconclusive(account.ID, signal, "gpt-5.4", state, "upstream_unavailable")
|
||||
|
||||
require.Equal(t, codexQuotaOverdraftProbeInconclusive, state.Status)
|
||||
require.Equal(t, 1, state.RetryCount)
|
||||
require.WithinDuration(t, now.Add(time.Minute), *state.RetryAt, time.Second)
|
||||
require.Equal(t, codexQuotaOverdraftRetryTaskName(account.ID), scheduler.scheduledName)
|
||||
require.Equal(t, time.Minute, scheduler.scheduledDelay)
|
||||
require.NotNil(t, scheduler.scheduledFn)
|
||||
|
||||
state.RetryCount = 1
|
||||
coordinator.finishInconclusive(account.ID, signal, "gpt-5.4", state, "upstream_unavailable")
|
||||
require.Equal(t, 2, state.RetryCount)
|
||||
require.Equal(t, 3*time.Minute, scheduler.scheduledDelay)
|
||||
}
|
||||
|
||||
func TestCodexQuotaOverdraftAuthenticationFailureDoesNotScheduleQuotaRetry(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 13, 14, 0, 0, 0, time.UTC)
|
||||
account := newCodexOverdraftProbeTestAccount(now)
|
||||
repo := &codexOverdraftProbeRepoStub{account: account}
|
||||
scheduler := &codexOverdraftRetrySchedulerStub{}
|
||||
coordinator := &CodexQuotaOverdraftCoordinator{accountRepo: repo, retryScheduler: scheduler, now: func() time.Time { return now }}
|
||||
signal, _ := codexQuotaOverdraftSignalFromAccount(account, nil, now)
|
||||
state := newCodexOverdraftPendingState(signal, now)
|
||||
|
||||
coordinator.finishInconclusive(account.ID, signal, "gpt-5.4", state, "authentication_failed")
|
||||
|
||||
require.Empty(t, scheduler.scheduledName)
|
||||
require.Nil(t, state.RetryAt)
|
||||
require.Zero(t, state.RetryCount)
|
||||
}
|
||||
|
||||
func TestCodexQuotaOverdraftInconclusiveStopsAtAutomaticRetryLimit(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 13, 14, 0, 0, 0, time.UTC)
|
||||
account := newCodexOverdraftProbeTestAccount(now)
|
||||
repo := &codexOverdraftProbeRepoStub{account: account}
|
||||
scheduler := &codexOverdraftRetrySchedulerStub{}
|
||||
coordinator := &CodexQuotaOverdraftCoordinator{
|
||||
accountRepo: repo,
|
||||
retryScheduler: scheduler,
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
signal, _ := codexQuotaOverdraftSignalFromAccount(account, nil, now)
|
||||
state := newCodexOverdraftPendingState(signal, now)
|
||||
state.RetryCount = codexQuotaOverdraftAutomaticRetries
|
||||
|
||||
coordinator.finishInconclusive(account.ID, signal, "gpt-5.4", state, "upstream_unavailable")
|
||||
|
||||
require.Equal(t, codexQuotaOverdraftAutomaticRetries, state.RetryCount)
|
||||
require.Nil(t, state.RetryAt)
|
||||
require.Empty(t, scheduler.scheduledName)
|
||||
}
|
||||
|
||||
func TestCodexQuotaOverdraftNewWindowPreservesExistingWindowBaseline(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 13, 14, 0, 0, 0, time.UTC)
|
||||
fiveStarted := now.Add(-time.Hour)
|
||||
@@ -323,6 +458,18 @@ func TestClassifyCodexQuotaOverdraftProbeResponses(t *testing.T) {
|
||||
require.Equal(t, "retry", status)
|
||||
require.Equal(t, "quota_limited", reason)
|
||||
|
||||
status, reason = classifyCodexQuotaOverdraftProbe(http.StatusTooManyRequests, nil, []byte(`{"error":{"code":"weekly_limit_reached"}}`))
|
||||
require.Equal(t, "retry", status)
|
||||
require.Equal(t, "quota_limited", reason)
|
||||
|
||||
status, reason = classifyCodexQuotaOverdraftProbe(http.StatusTooManyRequests, nil, []byte(`{"rate_limit":{"allowed":false,"limit_reached":true}}`))
|
||||
require.Equal(t, "retry", status)
|
||||
require.Equal(t, "quota_limited", reason)
|
||||
|
||||
status, reason = classifyCodexQuotaOverdraftProbe(http.StatusTooManyRequests, nil, []byte(`{"error":{"type":"rate_limit_exceeded","message":"too many requests"}}`))
|
||||
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)
|
||||
@@ -331,6 +478,10 @@ func TestClassifyCodexQuotaOverdraftProbeResponses(t *testing.T) {
|
||||
require.Equal(t, "authentication_failed", status)
|
||||
require.Equal(t, "authentication_failed", reason)
|
||||
|
||||
status, reason = classifyCodexQuotaOverdraftProbe(http.StatusNotFound, nil, []byte(`{"detail":"model not found"}`))
|
||||
require.Equal(t, "retry", status)
|
||||
require.Equal(t, "model_not_found", reason)
|
||||
|
||||
status, reason = classifyCodexQuotaOverdraftProbe(http.StatusOK, nil, []byte(`data: {"type":"response.failed"}\ndata: {"type":"response.completed"}`))
|
||||
require.Equal(t, "retry", status)
|
||||
require.Equal(t, "invalid_response", reason)
|
||||
|
||||
@@ -231,6 +231,7 @@ func ProvideCodexQuotaOverdraftCoordinator(
|
||||
tempUnschedCache TempUnschedCache,
|
||||
runtimeBlocker AccountRuntimeBlocker,
|
||||
rateLimitService *RateLimitService,
|
||||
timingWheel *TimingWheelService,
|
||||
) *CodexQuotaOverdraftCoordinator {
|
||||
coordinator := NewCodexQuotaOverdraftCoordinator(
|
||||
accountRepo,
|
||||
@@ -242,6 +243,7 @@ func ProvideCodexQuotaOverdraftCoordinator(
|
||||
runtimeBlocker,
|
||||
rateLimitService,
|
||||
)
|
||||
coordinator.SetRetryScheduler(timingWheel)
|
||||
if gateway, ok := runtimeBlocker.(*OpenAIGatewayService); ok {
|
||||
gateway.SetCodexQuotaOverdraftCoordinator(coordinator)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Rate Limit Display (429) - Two-line layout -->
|
||||
<div v-if="isRateLimited" class="flex flex-col items-center gap-1">
|
||||
<div v-if="isRateLimited && !isCodexQuotaOverdraftPause" class="flex flex-col items-center gap-1">
|
||||
<span class="badge text-xs badge-warning">{{ t('admin.accounts.status.rateLimited') }}</span>
|
||||
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ rateLimitResumeText }}</span>
|
||||
</div>
|
||||
@@ -276,6 +276,21 @@ const isTempUnschedulable = computed(() => {
|
||||
return new Date(props.account.temp_unschedulable_until) > new Date()
|
||||
})
|
||||
|
||||
const tempUnschedulableSource = computed(() => {
|
||||
const reason = props.account.temp_unschedulable_reason
|
||||
if (!reason) return ''
|
||||
try {
|
||||
const parsed = JSON.parse(reason) as { source?: unknown }
|
||||
return typeof parsed.source === 'string' ? parsed.source : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const isCodexQuotaOverdraftPause = computed(() => {
|
||||
return isTempUnschedulable.value && tempUnschedulableSource.value === 'codex_quota_overdraft'
|
||||
})
|
||||
|
||||
// Computed: has error status
|
||||
const hasError = computed(() => {
|
||||
return props.account.status === 'error'
|
||||
@@ -339,6 +354,9 @@ const statusText = computed(() => {
|
||||
return t('admin.accounts.status.error')
|
||||
}
|
||||
if (isTempUnschedulable.value) {
|
||||
if (isCodexQuotaOverdraftPause.value) {
|
||||
return t('admin.accounts.status.codexQuotaPaused')
|
||||
}
|
||||
return t('admin.accounts.status.tempUnschedulable')
|
||||
}
|
||||
if (props.account.status !== 'active') {
|
||||
|
||||
@@ -768,12 +768,14 @@ const codexOverdraftStatus = computed(() => {
|
||||
const testedAt = probe.tested_at ? new Date(probe.tested_at).toLocaleString() : ''
|
||||
const recoverAt = probe.recover_at ? new Date(probe.recover_at).toLocaleString() : ''
|
||||
const retryAt = probe.retry_at ? new Date(probe.retry_at).toLocaleString() : ''
|
||||
const retryCount = Math.max(0, probe.retry_count || 0)
|
||||
const titleParts = [windowLabel]
|
||||
if (probe.model) titleParts.push(probe.model)
|
||||
if (probe.reason_code) titleParts.push(probe.reason_code)
|
||||
if (testedAt) titleParts.push(`${t('usage.overdraftTestedAt')}: ${testedAt}`)
|
||||
if (recoverAt) titleParts.push(`${t('usage.overdraftRecoverAt')}: ${recoverAt}`)
|
||||
if (retryAt) titleParts.push(`${t('usage.overdraftRetryAt')}: ${retryAt}`)
|
||||
if (retryCount > 0) titleParts.push(`${t('usage.overdraftRetryCount')}: ${retryCount}`)
|
||||
|
||||
switch (probe.status) {
|
||||
case 'pending':
|
||||
|
||||
@@ -106,6 +106,33 @@ describe('AccountStatusIndicator', () => {
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.status.tempUnschedulable')
|
||||
})
|
||||
|
||||
it('Codex 透支确认失败时优先显示额度暂停', () => {
|
||||
const wrapper = mount(AccountStatusIndicator, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 6,
|
||||
platform: 'openai',
|
||||
rate_limited_at: '2026-08-15T00:00:00Z',
|
||||
rate_limit_reset_at: '2099-08-15T05:00:00Z',
|
||||
temp_unschedulable_until: '2099-08-15T05:00:00Z',
|
||||
temp_unschedulable_reason: JSON.stringify({
|
||||
source: 'codex_quota_overdraft',
|
||||
error_message: 'five real overdraft probes confirmed quota exhaustion'
|
||||
})
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Icon: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.find('.badge-warning').text()).toBe('admin.accounts.status.codexQuotaPaused')
|
||||
expect(wrapper.text()).toContain('admin.accounts.status.tempUnschedulableUntil')
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.status.rateLimitedAutoResume')
|
||||
})
|
||||
|
||||
it('模型限流 + overages 启用 + 无 AICredits key → 显示 ⚡ (credits_active)', () => {
|
||||
const wrapper = mount(AccountStatusIndicator, {
|
||||
props: {
|
||||
|
||||
@@ -660,6 +660,53 @@ describe('AccountUsageCell', () => {
|
||||
expect(wrapper.text()).toContain('5/5 · 5h')
|
||||
})
|
||||
|
||||
it('OpenAI OAuth 无法确认探测时会显示重试轮次和时间', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
five_hour: {
|
||||
utilization: 100,
|
||||
resets_at: '2099-03-07T12:00:00Z',
|
||||
remaining_seconds: 3600
|
||||
},
|
||||
codex_quota_overdraft: {
|
||||
status: 'inconclusive',
|
||||
quota_window: '5h',
|
||||
cycle_key: '5h:4076577600',
|
||||
attempts: 1,
|
||||
limit: 5,
|
||||
model: 'gpt-5.4',
|
||||
reason_code: 'upstream_unavailable',
|
||||
retry_count: 2,
|
||||
started_at: '2099-03-07T10:00:00Z',
|
||||
tested_at: '2099-03-07T10:01:00Z',
|
||||
retry_at: '2099-03-07T10:04:00Z',
|
||||
recover_at: '2099-03-07T12:00:00Z'
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 2006,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
extra: {}
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: true,
|
||||
AccountQuotaInfo: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const status = wrapper.get('[title*="usage.overdraftRetryCount: 2"]')
|
||||
expect(status.text()).toContain('usage.overdraftProbeInconclusive')
|
||||
expect(status.attributes('title')).toContain('usage.overdraftRetryAt')
|
||||
})
|
||||
|
||||
it('Key 账号会展示 today stats 徽章并带 A/U 提示', async () => {
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
|
||||
@@ -137,6 +137,7 @@ export default {
|
||||
rateLimited: 'Rate Limited',
|
||||
overloaded: 'Overloaded',
|
||||
tempUnschedulable: 'Temp Unschedulable',
|
||||
codexQuotaPaused: 'Quota Paused',
|
||||
quotaExceeded: 'Quota Exceeded',
|
||||
unschedulable: 'Unschedulable',
|
||||
rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}',
|
||||
|
||||
@@ -285,6 +285,7 @@ export default {
|
||||
overdraftActive: 'Overdraft active',
|
||||
overdraftRecoverAt: 'Expected recovery',
|
||||
overdraftRetryAt: 'Next retry',
|
||||
overdraftRetryCount: 'Retry round',
|
||||
overdraftTestedAt: 'Probed at',
|
||||
overdraftProbePending: 'Overdraft probe running',
|
||||
overdraftProbeFailed: 'Quota limit confirmed',
|
||||
|
||||
@@ -340,6 +340,7 @@ export default {
|
||||
rateLimited: '限流中',
|
||||
overloaded: '过载中',
|
||||
tempUnschedulable: '临时不可调度',
|
||||
codexQuotaPaused: '额度暂停',
|
||||
quotaExceeded: '配额超限',
|
||||
unschedulable: '不可调度',
|
||||
rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复',
|
||||
|
||||
@@ -290,6 +290,7 @@ export default {
|
||||
overdraftActive: '透支中',
|
||||
overdraftRecoverAt: '预计恢复',
|
||||
overdraftRetryAt: '下次重试',
|
||||
overdraftRetryCount: '重试轮次',
|
||||
overdraftTestedAt: '探测时间',
|
||||
overdraftProbePending: '透支探测中',
|
||||
overdraftProbeFailed: '已确认限额',
|
||||
|
||||
@@ -1278,6 +1278,7 @@ export interface CodexQuotaOverdraftProbeState {
|
||||
started_at: string
|
||||
tested_at?: string | null
|
||||
retry_at?: string | null
|
||||
retry_count?: number
|
||||
recover_at?: string | null
|
||||
five_hour_recover_at?: string | null
|
||||
seven_day_recover_at?: string | null
|
||||
|
||||
+11
-7
@@ -7,22 +7,26 @@
|
||||
- Sub2API 基础版本:`0.1.177`
|
||||
- Git 描述:`v0.1.177-1-gbaeac1f3d`
|
||||
- 基础提交:`baeac1f3de21d37b129405f092ef86c24b3f203d`
|
||||
- 目标源码提交:`f48d8ed7ed63cb7eee85306ae8f6f183a82eba91`
|
||||
- 目标 Git tree(不含 `patches/`):`9aca85e55803fa59698c3a57e5a632818d072754`
|
||||
- SHA-256:`72076ac6ea09e9c5719c191dacec6e79e0b478309b8f92ae1d6deb1bb6e411de`
|
||||
- 文件大小:202,451 字节
|
||||
- Patch 生成基准 Fork 提交:`9babce2dd30903f9b1d6590a202e94d932e555e5`
|
||||
- 目标 Git tree(不含 `patches/`):`0f0c2cc2cd65db4bd5ed4230f144d55319b56980`
|
||||
- SHA-256:`fb7d7686a8f90b1a0e6bc788740e240628a485c9ad7bf04d5c4fca84d42163c4`
|
||||
- 文件大小:236,992 字节
|
||||
|
||||
该 Patch 包含 Codex 5h / 7d 额度透支后端、前端显示、配置、源码构建 Compose、品牌和公开部署文档,并额外包含以下修复:
|
||||
|
||||
- OpenAI OAuth 常规文本“测试账号连接”使用透支请求形态,并接入额度观察及明确额度 429 探测。
|
||||
- `passed/recovered` 后清理并发 429 遗留的账号级限流状态,避免账号实际可用但页面仍显示“限流中”。
|
||||
- API Key、Shadow、图片和 Compact 测试保持原行为。
|
||||
- 无法确认的探测按 1、3、10 分钟主动退避,每个额度周期最多自动重试 3 轮;服务重启后会从 PostgreSQL 恢复到期任务和遗留 `pending` 状态。
|
||||
- 400/404 会轮换全部探测模型;普通瞬时 429、网络错误和 5xx 不会误停账号,401/403 仍交给原认证异常逻辑处理。
|
||||
- 扩充结构化额度证据解析并记录逐次探测日志,便于区分明确额度耗尽与临时上游故障。
|
||||
- `failed` 状态、账号暂停和 scheduler outbox 在同一个数据库事务内提交,并对持久化失败进行有限重试和同周期幂等保护。
|
||||
- 更新检查跟踪 `DeanZFC/sub2api-overdraft` 的 `codex-overdraft` 分支和 `FORK_VERSION`,不再把官方 Release 误报为 Fork 更新。
|
||||
- 源码构建只提示 `git pull` 和重新构建,禁用可能覆盖 Fork 功能的二进制在线更新与回退。
|
||||
- 兼容 Sub2API `v0.1.177` 的原生 remote compaction v2,旧 Compact 与原生 v2 均不会误启用透支调度。
|
||||
- 新增对应后端单元测试和故障排查日志。
|
||||
|
||||
Patch 共修改 51 个源码和文档文件。`patches/` 目录本身不包含在 Patch 中,避免 Patch 递归包含自身。
|
||||
Patch 共修改 56 个源码和文档文件。`patches/` 目录本身不包含在 Patch 中,避免 Patch 递归包含自身。
|
||||
|
||||
## 在精确基线上应用
|
||||
|
||||
@@ -61,10 +65,10 @@ git apply --3way /tmp/sub2api-overdraft.patch
|
||||
本 Patch 已在临时工作树中从基础提交执行 `git apply --check` 和三方应用。应用后写入的 Git tree 为:
|
||||
|
||||
```text
|
||||
9aca85e55803fa59698c3a57e5a632818d072754
|
||||
0f0c2cc2cd65db4bd5ed4230f144d55319b56980
|
||||
```
|
||||
|
||||
它与目标源码提交 `f48d8ed7ed63cb7eee85306ae8f6f183a82eba91` 排除 `patches/` 后的 Git tree 完全一致。
|
||||
它与生成 Patch 时本地源码快照排除 `patches/` 后的 Git tree 完全一致;该快照基于 Fork 提交 `9babce2dd30903f9b1d6590a202e94d932e555e5` 生成。
|
||||
|
||||
## 历史 Patch
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user