Files
sub2api-overdraft/patches/sub2api-overdraft-v0.1.177-baeac1f3d.patch

6330 lines
260 KiB
Diff
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
diff --git a/CODEX_OVERDRAFT_DEPLOYMENT_CN.md b/CODEX_OVERDRAFT_DEPLOYMENT_CN.md
new file mode 100644
index 0000000000000000000000000000000000000000..a0e777ee34adef258dc1d145780d24bbc08a0bce
--- /dev/null
+++ b/CODEX_OVERDRAFT_DEPLOYMENT_CN.md
@@ -0,0 +1,529 @@
+# sub2api-overdraft 部署与运维指南
+
+本文档面向希望部署本项目、验证 Codex 5h / 7d 额度透支状态,以及后续安全升级的使用者。
+
+本项目是 [Wei-Shaw/sub2api](https://github.com/Wei-Shaw/sub2api) 的非官方衍生版本,公开项目名为 **sub2api-overdraft**。内部二进制名、Docker 服务名、数据目录和 Go module 仍保留 `sub2api`,这是为了兼容原项目并降低同步上游更新时的冲突。
+
+## 重要说明
+
+- 本功能仅表示:Codex 5h 或 7d 用量达到 95% 后开始注入兼容请求形态,并在额度达到 100% 后通过真实业务结果或单次独立探测确认上游是否仍允许继续调用。
+- `passed` 只代表探测时上游仍返回有效响应,不承诺之后始终可用,也不会绕过认证失效、账号禁用、网络故障或其他风控限制。
+- 探测请求和透支期请求都可能产生真实上游用量和费用。
+- 此行为可能不符合上游服务条款。部署者必须自行确认合规性,并承担账号限制、服务中断等风险。
+- 官方 Sub2API 安装脚本和 `weishaw/sub2api:latest` 镜像不包含本 Fork 的透支功能。
+- 本 Fork 的后台更新检查读取 `DeanZFC/sub2api-overdraft`,不会把官方 Sub2API Release 当作本项目更新。
+
+## 功能范围
+
+开启 `gateway.codex_quota_overdraft_enabled` 后,本项目会:
+
+- 对符合条件的 OpenAI OAuth 普通账号和 Agent Identity 账号启用透支调度,Shadow 子账号除外。
+- 支持 `/v1/responses`、转换为 Responses 的 `/v1/chat/completions` 和 `/v1/messages`,以及 Responses WebSocket v2。
+- 在适用请求的最后一条用户消息后注入一对无操作工具调用,用于保持与参考实现一致的请求形态。
+- 用量达到 95% 后开始对适用业务请求注入;达到 100% 后,注入业务成功直接确认 `passed`,明确额度 429 直接确认 `failed`。没有业务结果可用时,同周期最多补充 1 次独立探测。
+- 管理页面的 OpenAI OAuth 常规文本“测试账号连接”也使用相同请求形态,并接入同一套额度探测状态机。
+- 分别维护 5h、7d 透支周期,并在管理页面显示状态、请求数、Token、账号金额和预计恢复时间。
+- 将探测状态保存在现有 `accounts.extra` JSONB 字段中,不新增数据库表,不需要手动执行数据库迁移(migration)。
+
+以下端点不启用透支:`/responses/compact`、图片生成、Embedding、Count Tokens 和 Live;API Key、Shadow、图片及 Compact 的后台测试也不启用透支。
+
+## 前置条件
+
+- Linux amd64 或 arm64 服务器
+- Git
+- Docker 20.10 或更高版本
+- Docker Compose v2,命令为 `docker compose`
+- 建议至少 2 核 CPU、4 GB 内存和 20 GB 可用磁盘
+- 可以访问 GitHub、Docker Hub、Go module 和 npm/pnpm 依赖源
+
+首次源码构建时间取决于服务器性能和网络状况。PostgreSQL 与 Redis 已包含在 Compose 中,不需要提前单独安装。
+
+## 全新部署
+
+### 1. 克隆项目
+
+```bash
+sudo mkdir -p /opt
+cd /opt
+sudo git clone https://github.com/DeanZFC/sub2api-overdraft.git
+sudo chown -R "$(id -u):$(id -g)" /opt/sub2api-overdraft
+cd /opt/sub2api-overdraft/deploy
+```
+
+仓库默认分支是 `codex-overdraft`。确认当前分支:
+
+```bash
+git branch --show-current
+```
+
+预期输出:
+
+```text
+codex-overdraft
+```
+
+### 2. 创建环境配置
+
+```bash
+cp .env.example .env
+chmod 600 .env
+```
+
+至少设置以下值,不要把真实密钥提交到 Git:
+
+```bash
+POSTGRES_PASSWORD=替换为随机密码
+JWT_SECRET=替换为随机密钥
+TOTP_ENCRYPTION_KEY=替换为随机密钥
+ADMIN_EMAIL=你的管理员邮箱
+ADMIN_PASSWORD=替换为强密码
+SERVER_PORT=8080
+```
+
+可分别使用以下命令生成随机值:
+
+```bash
+openssl rand -hex 32
+```
+
+透支开关在公开部署覆盖文件中默认开启。也可以在 `.env` 中显式配置:
+
+```bash
+GATEWAY_CODEX_QUOTA_OVERDRAFT_ENABLED=true
+```
+
+### 3. 创建数据目录
+
+```bash
+mkdir -p data postgres_data redis_data
+```
+
+这些目录和 `.env` 已被 Git 忽略,不会在正常的 `git add .` 中上传。
+
+### 4. 从源码构建并启动
+
+后续所有 Compose 命令都应同时带上这两个文件:
+
+```bash
+docker compose \
+ -f docker-compose.local.yml \
+ -f docker-compose.overdraft.yml \
+ up -d --build
+```
+
+`docker-compose.local.yml` 提供应用、PostgreSQL、Redis、网络和数据卷;`docker-compose.overdraft.yml` 把应用镜像切换为本仓库源码构建,并默认开启透支功能。
+
+查看容器状态:
+
+```bash
+docker compose \
+ -f docker-compose.local.yml \
+ -f docker-compose.overdraft.yml \
+ ps
+```
+
+三个容器应处于 `Up` 或 `healthy` 状态:
+
+```text
+sub2api
+sub2api-postgres
+sub2api-redis
+```
+
+查看启动日志:
+
+```bash
+docker compose \
+ -f docker-compose.local.yml \
+ -f docker-compose.overdraft.yml \
+ logs -f sub2api
+```
+
+默认访问地址为 `http://服务器IP:8080`。
+
+## 配置文件位置与优先级
+
+源码仓库中的 `deploy/config.example.yaml` 只是完整配置模板,不是容器实际读取的运行配置。
+
+Compose 本地目录部署的实际配置通常位于:
+
+```text
+/opt/sub2api-overdraft/deploy/data/config.yaml
+```
+
+它在容器内对应:
+
+```text
+/app/data/config.yaml
+```
+
+如果手动通过 YAML 开启,请确保层级正确:
+
+```yaml
+gateway:
+ codex_quota_overdraft_enabled: true
+```
+
+环境变量 `GATEWAY_CODEX_QUOTA_OVERDRAFT_ENABLED` 的优先级高于 YAML。本项目提供的 Compose 覆盖文件默认将它设为 `true`,因此全新部署不需要手动编辑 `data/config.yaml`。
+
+检查容器实际收到的环境变量:
+
+```bash
+docker compose \
+ -f docker-compose.local.yml \
+ -f docker-compose.overdraft.yml \
+ exec sub2api sh -lc 'printf "%s\n" "$GATEWAY_CODEX_QUOTA_OVERDRAFT_ENABLED"'
+```
+
+预期输出为 `true`。
+
+## 从现有 Sub2API 部署迁移
+
+迁移前先备份 `.env`、运行配置和数据库。不要删除 `data`、`postgres_data`、`redis_data`。
+
+如果服务器上的 `/opt/sub2api` 已经是 Git 仓库,并且包含手工修改,先检查:
+
+```bash
+cd /opt/sub2api
+git status --short
+git branch --show-current
+git remote -v
+```
+
+有未提交代码时先保存:
+
+```bash
+git stash push -u -m "server changes before codex-overdraft switch"
+```
+
+然后连接本 Fork 并切换到公开分支:
+
+```bash
+git remote set-url origin https://github.com/DeanZFC/sub2api-overdraft.git
+git fetch origin
+git switch codex-overdraft 2>/dev/null || \
+ git switch -c codex-overdraft --track origin/codex-overdraft
+git pull --ff-only origin codex-overdraft
+```
+
+如果 Git 报 `detected dubious ownership`,确认目录确实是本项目后再执行:
+
+```bash
+git config --global --add safe.directory /opt/sub2api
+```
+
+已有的服务器专用 Compose 文件可以保留,但推荐改用仓库内公开的覆盖文件:
+
+```bash
+cd /opt/sub2api/deploy
+docker compose \
+ -f docker-compose.local.yml \
+ -f docker-compose.overdraft.yml \
+ up -d --build --force-recreate sub2api
+```
+
+切换分支后不要直接执行 `git stash pop`。先用 `git stash show --stat` 检查旧 stash;本分支已经包含透支代码,直接恢复旧代码可能造成重复修改和冲突。
+
+## 判断功能是否生效
+
+### 第一层:确认运行的是源码构建镜像
+
+```bash
+docker inspect sub2api --format '{{.Config.Image}}'
+```
+
+预期输出:
+
+```text
+sub2api-overdraft:local
+```
+
+如果输出 `weishaw/sub2api:latest`,说明当前仍在运行官方镜像,本功能不会生效。
+
+### 第二层:确认开关已开启
+
+```bash
+docker exec sub2api sh -lc 'printf "%s\n" "$GATEWAY_CODEX_QUOTA_OVERDRAFT_ENABLED"'
+```
+
+预期输出 `true`。修改 `.env` 后必须重建或重新创建应用容器:
+
+```bash
+docker compose \
+ -f docker-compose.local.yml \
+ -f docker-compose.overdraft.yml \
+ up -d --force-recreate sub2api
+```
+
+### 第三层:查看真实探测日志
+
+账号 5h/7d 用量达到 95% 后开始注入;只有额度达到 100%,或者收到带明确额度信息的上游 429,才会产生透支确认状态。额度达到 100% 后,可以在管理页面对该 OpenAI OAuth 账号执行常规文本“测试账号连接”;测试请求本身会使用透支请求形态,并直接参与同一额度周期的判定。额度未耗尽时没有探测日志是正常现象。
+
+```bash
+docker logs --since 30m sub2api 2>&1 | \
+ grep -E 'codex_quota_overdraft_(probe|state|pause|stale_rate_limit)'
+```
+
+关键日志:
+
+| 日志 | 含义 |
+| --- | --- |
+| `codex_quota_overdraft_business_passed` | 注入后的真实业务或账号测试成功,直接确认透支可用 |
+| `codex_quota_overdraft_business_exhausted` | 注入后的业务请求收到明确额度 429,直接确认额度耗尽并暂停账号 |
+| `codex_quota_overdraft_probe_passed` | 至少一次真实探测成功,透支成立,账号继续参与调度 |
+| `codex_quota_overdraft_probe_failed` | 单次独立探测明确返回额度限制,账号暂停至额度恢复时间 |
+| `codex_quota_overdraft_probe_attempt` | 单次探测结果,包含模型、HTTP 状态码、结果和原因,不记录凭据及完整响应正文 |
+| `codex_quota_overdraft_probe_inconclusive` | 网络、超时、5xx、普通瞬时 429 等导致无法确认;同周期不自动重试 |
+| `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 遗留的账号级限流状态 |
+
+真正“透支成功”的最直接证据是:
+
+```text
+codex_quota_overdraft_business_passed
+```
+
+如果没有业务成功证据,也可以由 `codex_quota_overdraft_probe_passed` 确认。其中会包含 `account_id`、`model` 和 `quota_window`。
+
+### 第四层:查看数据库持久化状态
+
+```bash
+docker compose \
+ -f docker-compose.local.yml \
+ -f docker-compose.overdraft.yml \
+ exec -T postgres sh -lc \
+ 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -x -c "
+ SELECT id, name, extra->'\''codex_quota_overdraft_probe'\'' AS overdraft_probe
+ FROM accounts
+ WHERE platform = '\''openai'\'' AND type = '\''oauth'\''
+ ORDER BY id;
+ "'
+```
+
+重点看 JSON 中的 `status`:
+
+| 状态 | 页面显示 | 含义 |
+| --- | --- | --- |
+| `pending` | 透支探测中 | 单次独立探测正在执行 |
+| `passed` | 透支中 | 注入业务或独立探测成功,账号继续调度并开始统计透支用量 |
+| `failed` | 已确认限额 | 注入业务或独立探测明确受额度限制,暂停到恢复时间 |
+| `inconclusive` | 探测无法确认 | 单次探测受网络、瞬时限流或上游异常干扰;同周期不自动重试 |
+| `recovered` | 额度已恢复 | 原额度周期结束,相关暂停已清理 |
+
+页面显示 `透支中`,数据库为 `passed`,并且后续业务请求返回成功,三者同时满足即可确认功能完整生效。
+
+## 探测逻辑
+
+每个额度周期采用以下混合判定流程:
+
+- 用量低于 95% 时不注入,达到 95% 后对适用的普通 OAuth 文本请求持续注入。
+- 达到 100% 后,注入业务返回成功即记为 `passed`;返回明确 `quota_limited` 即记为 `failed`,暂停账号并切换到其他账号。
+- 如果额度刚从 95% 以下跳到 100%,当前业务请求没有注入,系统仅补充 1 次独立 Responses 探测,最长 20 秒,并优先使用当前业务模型。
+- 网络错误、超时、5xx、普通瞬时 429、无效响应等记为 `inconclusive`,不会因此误停账号。
+- 401/403 继续由原有认证失效逻辑处理。
+- 账号禁用、过载、代理故障、模型冷却等非额度限制不会被本功能绕过。
+
+独立探测无法确认后,同一额度周期不会自动重试,也不会持续消耗探测请求。后续已经注入的真实业务成功或明确额度 429 仍可把状态更新为 `passed` 或 `failed`;同周期 `failed` 为终态,成功结果不能覆盖它。
+
+多实例部署通过 PostgreSQL 原子领取(atomic claim)保证同一账号、同一额度周期只有一个实例发起探测。
+
+## 反向代理
+
+Nginx 需要把请求转发到宿主机的 `8080` 端口。使用 Codex CLI 时,建议在 Nginx `http` 块启用:
+
+```nginx
+underscores_in_headers on;
+```
+
+流式响应位置至少应包含:
+
+```nginx
+proxy_http_version 1.1;
+proxy_buffering off;
+proxy_cache off;
+proxy_read_timeout 3600s;
+proxy_send_timeout 3600s;
+```
+
+出现 `duplicate upstream "sub2api_backend"` 时,表示多个 Nginx 配置文件定义了同名 `upstream`。使用以下命令找出重复项,然后只保留一个定义:
+
+```bash
+grep -Rns 'upstream sub2api_backend' /etc/nginx/nginx.conf /etc/nginx/conf.d /etc/nginx/sites-enabled
+```
+
+修改后先验证再重载:
+
+```bash
+sudo nginx -t
+sudo systemctl reload nginx
+```
+
+## 日常升级本 Fork
+
+源码镜像的当前版本来自仓库根目录的 `FORK_VERSION`。后台检查更新时读取 GitHub 上 `codex-overdraft` 分支的同名文件:远端版本更高时显示更新提示,版本一致时显示最新。源码构建不会执行二进制在线更新或在线回退,以免官方程序覆盖 Fork 功能。
+
+运行数据均位于被 Git 忽略的目录中,正常 `git pull` 不会覆盖:
+
+```text
+deploy/.env
+deploy/data/
+deploy/postgres_data/
+deploy/redis_data/
+```
+
+升级步骤:
+
+```bash
+cd /opt/sub2api-overdraft
+git status --short
+git switch codex-overdraft
+git pull --ff-only origin codex-overdraft
+
+cd deploy
+docker compose \
+ -f docker-compose.local.yml \
+ -f docker-compose.overdraft.yml \
+ up -d --build
+```
+
+如果服务器目录是 `/opt/sub2api`,只需替换第一条路径。升级完成后重新执行“判断功能是否生效”中的镜像、环境变量和日志检查。
+
+更新后可在管理后台点击刷新,版本应显示为类似 `v0.1.177-overdraft.1`,更新方式应提示源码构建使用 `git pull`。也可以直接检查容器内二进制版本:
+
+```bash
+docker exec sub2api /app/sub2api -version
+```
+
+维护者发布新的源码版本时必须递增 `FORK_VERSION`,例如从 `0.1.177-overdraft.3` 改为 `0.1.177-overdraft.4`。同步到新的上游 Sub2API 版本时,使用 `0.1.178-overdraft.1` 这样的版本号。
+
+## 合并 Sub2API 官方更新
+
+本仓库保留两个分支角色:
+
+- `main`:尽量跟随官方 `Wei-Shaw/sub2api`,作为上游基线。
+- `codex-overdraft`:本项目默认分支,包含透支功能和公开文档。
+
+维护者可这样合并官方更新:
+
+```bash
+git remote get-url upstream >/dev/null 2>&1 || \
+ git remote add upstream https://github.com/Wei-Shaw/sub2api.git
+git fetch upstream
+git switch codex-overdraft
+git merge upstream/main
+```
+
+解决冲突后执行项目测试,提交并推送:
+
+```bash
+cd backend
+go test -tags=unit ./internal/repository ./internal/handler ./internal/service \
+ -run 'CodexQuotaOverdraft|AccountSchedulingThreshold|SchedulerSnapshot|SchedulableAccountQueryScopesCodex' \
+ -count=1
+go test ./internal/repository ./internal/handler ./internal/service ./cmd/server \
+ -run '^$' -count=1
+
+cd ../frontend
+pnpm install --frozen-lockfile
+pnpm run typecheck
+pnpm exec vitest run \
+ src/components/account/__tests__/UsageProgressBar.spec.ts \
+ src/components/account/__tests__/AccountUsageCell.spec.ts
+
+cd ..
+git diff --check
+git push origin codex-overdraft
+```
+
+更详细的实现文件和升级核对清单见 [CODEX_QUOTA_OVERDRAFT_CUSTOMIZATION.md](CODEX_QUOTA_OVERDRAFT_CUSTOMIZATION.md)。
+
+## 关闭与回滚
+
+如遇上游兼容问题,在 `deploy/.env` 设置:
+
+```bash
+GATEWAY_CODEX_QUOTA_OVERDRAFT_ENABLED=false
+```
+
+然后重新创建应用容器:
+
+```bash
+docker compose \
+ -f docker-compose.local.yml \
+ -f docker-compose.overdraft.yml \
+ up -d --force-recreate sub2api
+```
+
+关闭后会恢复官方调度与请求行为。数据库中已有的历史探测 JSON 可以保留,不影响关闭状态。
+
+## 备份
+
+数据库建议使用 `pg_dump` 做一致性备份:
+
+```bash
+cd /opt/sub2api-overdraft/deploy
+mkdir -p backups
+docker compose \
+ -f docker-compose.local.yml \
+ -f docker-compose.overdraft.yml \
+ exec -T postgres sh -lc \
+ 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \
+ > "backups/sub2api-$(date +%Y%m%d-%H%M%S).dump"
+```
+
+另外单独备份 `deploy/.env` 和 `deploy/data/config.yaml`。不要把备份、数据库目录、API Key 或 OAuth 凭证上传到公开仓库。
+
+## 常见故障
+
+### 没有任何透支日志
+
+先确认镜像和开关。若账号 5h/7d 尚未达到 100%,没有探测日志属于正常情况。使用管理页面的主动刷新获取最新额度后,再发送一次符合范围的文本请求。
+
+### 页面仍显示“限流中”
+
+页面的“限流中”直接来自账号记录的 `rate_limit_reset_at`。旧实现存在竞态:探测开始后若另一个 429 更新了该时间,即使探测最终 `passed`,严格时间比较也可能不清理它,因此出现“实际透支可用但页面限流中”。当前版本在 `passed/recovered` 时会清除该残留状态,并记录 `codex_quota_overdraft_stale_rate_limit_cleared`;`failed` 不会清理。
+
+其他真实限制仍会正常生效,包括账号禁用、401/403、注入业务或单次探测确认额度耗尽、代理/网络错误、模型级冷却、并发限制或没有明确额度证据的其他上游 429。若状态持续存在,先按 `account_id` 检查上述日志、数据库中的 `extra->'codex_quota_overdraft_probe'` 和 `rate_limit_reset_at`,区分额度透支与普通限流。
+
+### `pq: could not determine data type of parameter $1`
+
+这是旧版原子领取(atomic claim)SQL 的 PostgreSQL 参数类型问题。当前代码已使用:
+
+```sql
+jsonb_build_object($1::text, $2::jsonb)
+```
+
+拉取最新 `codex-overdraft` 分支并使用 `--build --force-recreate` 重建应用容器。
+
+### API 返回 503 `no available accounts`
+
+这表示账号调度阶段没有可用账号,不是 Nginx 连接故障。检查账号是否属于 API Key 对应分组、是否启用、是否认证有效、是否被其他原因暂停,以及探测状态是否为 `failed`。
+
+### 容器无法绑定 8080
+
+查找占用者:
+
+```bash
+sudo ss -ltnp 'sport = :8080'
+```
+
+如果是旧的 systemd 版 `sub2api`,先确认后停止并禁用:
+
+```bash
+sudo systemctl disable --now sub2api
+```
+
+也可以在 `.env` 将 `SERVER_PORT` 改为其他未占用端口。
+
+### PostgreSQL 或 Redis 主机名无法解析
+
+应用、PostgreSQL 和 Redis 必须加入同一个 Compose 网络。始终使用两个 Compose 文件一起启动完整服务,不要只用 `docker run` 单独启动应用容器。
+
+## 开源与归属
+
+- 上游项目:[Wei-Shaw/sub2api](https://github.com/Wei-Shaw/sub2api)
+- 透支逻辑参考:[Mxucc/cpa-account-config-manager](https://github.com/Mxucc/cpa-account-config-manager)
+- 本项目遵循仓库中的 [GNU LGPL-3.0](LICENSE) 许可证。
+- 本项目不是 Sub2API 官方发行版,也不受 OpenAI 或其他上游服务商认可或支持。
diff --git a/CODEX_QUOTA_OVERDRAFT_CUSTOMIZATION.md b/CODEX_QUOTA_OVERDRAFT_CUSTOMIZATION.md
new file mode 100644
index 0000000000000000000000000000000000000000..7a6cf5a16adc12aa32706f02b73e70e484de449b
--- /dev/null
+++ b/CODEX_QUOTA_OVERDRAFT_CUSTOMIZATION.md
@@ -0,0 +1,188 @@
+# sub2api-overdraft 5h / 7d 额度透支定制记录
+
+本文件是维护者实现与升级记录。面向部署者的完整安装、验证、升级和故障排查说明见
+[CODEX_OVERDRAFT_DEPLOYMENT_CN.md](CODEX_OVERDRAFT_DEPLOYMENT_CN.md)。
+
+## 基本信息
+
+- 当前实施基线:Sub2API `v0.1.177`,提交 `baeac1f3d`
+- 参考实现:<https://github.com/Mxucc/cpa-account-config-manager>
+- 功能开关:`gateway.codex_quota_overdraft_enabled`
+- 代码默认值:关闭;`deploy/config.example.yaml` 部署示例默认开启
+- Fork 版本文件:`FORK_VERSION`
+- 更新源:`DeanZFC/sub2api-overdraft` 的 `codex-overdraft` 分支
+
+## Fork 更新检查
+
+源码 Docker 构建会读取根目录的 `FORK_VERSION`,并以 `BuildType=source` 写入二进制。后台更新服务通过 GitHub Contents API 读取 Fork 分支上的同名文件,使用语义化版本比较判断是否有新版本。Redis 缓存同时记录仓库和构建类型,因此旧的官方更新缓存不会继续生效。
+
+源码构建仅显示 `git pull` 更新提示,`PerformUpdate`、指定版本回退和在线回退列表均被禁用,防止官方二进制覆盖透支功能。维护者每次发布 Fork 更新都必须递增 `FORK_VERSION`;当前版本为 `0.1.177-overdraft.6`,同步下一上游版本后使用 `0.1.178-overdraft.1`。
+
+Sub2API `v0.1.177` 将原生 remote compaction v2 保留在 `/responses` 路径。本定制同时检查旧 Compact 路径和原生 v2 请求信号,二者都不会开启额度透支调度或注入透支请求形态。
+
+## 后台测试连接与残留限流修复
+
+管理页面的 OpenAI OAuth 常规文本“测试账号连接”与真实业务请求使用相同策略:用量达到 95% 后注入;100% 时成功直接确认 `passed`,明确额度 429 直接确认 `failed`。API Key、Shadow、图片和 Compact 测试不启用该行为。
+
+探测得到 `passed`,或额度周期进入 `recovered` 后,会清除仍残留的账号级 `rate_limit_reset_at` 以及本功能或额度阈值产生的暂停。这里不再要求当前重置时间与探测开始时记录的 `observed_rate_limit_reset_at` 完全相同,因为并发 429 可能在探测期间更新该字段;`failed` 状态不会清理限流。发生清理时记录 `codex_quota_overdraft_stale_rate_limit_cleared`。
+
+## PostgreSQL 兼容性修复
+
+探测计划使用原子 claim 写入 `accounts.extra`。`jsonb_build_object` 的键参数必须显式
+转换为 `text`,否则 PostgreSQL 会报 `could not determine data type of parameter $1`,导致
+所有探测在启动前失败。对应 SQL 使用:
+
+```sql
+jsonb_build_object($1::text, $2::jsonb)
+```
+
+## 启用方式
+
+在实际使用的 `config.yaml` 中加入:
+
+```yaml
+gateway:
+ codex_quota_overdraft_enabled: true
+```
+
+修改配置后重启 sub2api。出现上游兼容问题时可改为 `false` 并重启,立即恢复官方调度和请求行为。
+
+## 完整行为
+
+开启后,对 OpenAI OAuth 普通账号和 Agent Identity 账号生效,Shadow 账号除外。支持路径:
+
+- `/v1/responses`
+- `/v1/chat/completions` 转换到 Responses 的请求
+- `/v1/messages` 转换到 Responses 的请求
+- Responses WebSocket v2 首轮和后续轮
+- 管理页面 OpenAI OAuth 常规文本“测试账号连接”
+
+用量达到 95% 后,每次最终发往上游前,如果 `input` 最后一项是用户消息,则追加一对与参考项目相同的无操作 `custom_tool_call` 和 `custom_tool_call_output`。请求体超过 32 MiB、JSON 无效、形状不匹配或已经注入时保持原样。
+
+5h 或 7d 额度达到 100%,或者收到包含明确额度证据的真实 429 时,执行以下混合判定:
+
+1. 实际注入的业务请求成功时直接判定 `passed`,账号继续调度并开始透支统计,不额外消耗探测请求。
+2. 实际注入的业务请求返回明确额度 429 时直接判定 `failed`,账号暂停到对应额度恢复时间并切号;5h 和 7d 同时耗尽时取最晚恢复时间。
+3. 如果没有可用的业务成功证据,同一额度周期仅启动 1 次独立 Responses 探测,最长 20 秒并优先使用当前业务模型。多实例使用 PostgreSQL 原子 claim 去重。
+4. 独立探测成功即判定 `passed`,明确 `quota_limited` 即判定 `failed`;网络错误、超时、5xx、普通瞬时 429 和无效响应判定 `inconclusive`,同周期不自动重试。
+5. `failed` 状态、临时暂停和调度 outbox 在同一数据库事务中提交。同周期 `failed` 为终态,晚到的业务成功或探测结果不能覆盖;业务明确额度 429 可以把并发产生的 `passed` 或 `inconclusive` 收敛为 `failed`。
+6. 401/403、账号停用等认证问题交给原有认证异常逻辑处理;400/404 判定为 `inconclusive`,不会暂停整个账号。
+7. 账号禁用、过载、代理/传输故障、模型级冷却及其他临时不可调度原因不被绕过。
+8. 额度恢复后状态改为 `recovered`,清理本功能或额度阈值产生的暂停;5h 与 7d 分别维护透支起点,不会因另一窗口后来耗尽而重置已有统计。
+
+状态值包括 `pending`、`passed`、`failed`、`inconclusive`、`recovered`。账号用量页面显示探测状态、尝试次数、额度周期、透支期成功请求数、Token、账号金额及预计恢复时间。
+
+不需要新增数据库表或执行数据库结构迁移(schema migration);周期状态保存在现有 `accounts.extra` JSONB 字段,透支统计读取现有 `usage_logs`。
+
+`/responses/compact`、生图请求、Embedding、Count Tokens、Live 等端点不启用额度透支。
+
+## 低冲突集成结构
+
+从 `0.1.177-overdraft.6` 起,透支行为保持不变,但实现按“独立模块 + 固定钩子”组织,以减少同步官方更新时的冲突:
+
+- 四个 PostgreSQL 原子状态方法及临时暂停查询扩展位于 `backend/internal/repository/account_repo_codex_overdraft.go`,`account_repo.go` 不再承载大段透支 SQL。
+- Gateway 通过 `sync.Once` 懒加载并持有全进程唯一协调器;账号用量服务和账号测试服务从同一个 Gateway 获取实例,不再向 Wire 图添加 Fork 专用 Provider。
+- `backend/cmd/server/wire_gen.go` 由官方 Wire 图正常生成,不包含透支专用变量或参数。
+- 调度、429、业务成功、用量快照和账号测试接线统一放在 `openai_codex_quota_overdraft_hooks.go`、`openai_codex_quota_overdraft_integration.go` 和 `account_test_codex_overdraft.go`;官方热点文件仅保留短调用。
+- 前端状态徽标和透支统计分别位于 `CodexOverdraftStatus.vue`、`CodexOverdraftStats.vue`,官方账号用量组件仅负责传入原有数据。
+
+这次调整没有修改 95% 注入门槛、Payload 内容、额度信号解析、429 分类、探测次数、状态转换、事务边界、暂停/恢复规则、统计口径或页面文案。升级时应优先保留独立文件,再逐一核对官方文件中的短钩子。
+
+## 修改文件
+
+后端配置、路由和调度:
+
+- `backend/internal/config/config.go`
+- `backend/internal/handler/openai_chat_completions.go`
+- `backend/internal/handler/openai_gateway_handler.go`
+- `backend/internal/repository/account_repo.go`
+- `backend/internal/repository/account_repo_codex_overdraft.go`
+- `backend/internal/repository/account_repo_schedulable_projection_test.go`
+- `backend/internal/service/account_test_codex_overdraft.go`
+- `backend/internal/service/openai_account_runtime_block_fastpath.go`
+- `backend/internal/service/openai_account_scheduler.go`
+- `backend/internal/service/openai_codex_quota_overdraft.go`
+- `backend/internal/service/openai_codex_quota_overdraft_hooks.go`
+- `backend/internal/service/openai_codex_quota_overdraft_integration.go`
+- `backend/internal/service/openai_codex_quota_overdraft_probe.go`
+- `backend/internal/service/openai_codex_quota_overdraft_test.go`
+- `backend/internal/service/openai_codex_quota_overdraft_probe_test.go`
+- `backend/internal/service/openai_gateway_forward.go`
+- `backend/internal/service/openai_gateway_passthrough.go`
+- `backend/internal/service/openai_gateway_scheduling.go`
+- `backend/internal/service/openai_gateway_service.go`
+- `backend/internal/service/openai_gateway_usage.go`
+- `backend/internal/service/openai_ws_forwarder_ingress.go`
+- `backend/internal/service/openai_ws_forwarder_support.go`
+- `backend/internal/service/openai_ws_forwarder_v2.go`
+- `backend/internal/service/openai_ws_v2_passthrough_adapter.go`
+- `backend/internal/service/ratelimit_service.go`
+- `backend/internal/service/account_usage_service.go`
+- `backend/internal/service/account_test_service.go`
+- `backend/internal/service/account_test_service_openai_test.go`
+- `backend/internal/service/account_test_service_openai_compact_test.go`
+- `backend/internal/service/wire.go`
+
+前端、配置示例和测试:
+
+- `frontend/src/types/index.ts`
+- `frontend/src/components/account/CodexOverdraftStats.vue`
+- `frontend/src/components/account/CodexOverdraftStatus.vue`
+- `frontend/src/components/account/UsageProgressBar.vue`
+- `frontend/src/components/account/AccountUsageCell.vue`
+- `frontend/src/components/account/__tests__/UsageProgressBar.spec.ts`
+- `frontend/src/components/account/__tests__/AccountUsageCell.spec.ts`
+- `frontend/src/i18n/locales/zh/dashboard.ts`
+- `frontend/src/i18n/locales/en/dashboard.ts`
+- `deploy/config.example.yaml`
+
+## 官方更新后的重新实施
+
+普通 `git pull` 不会静默丢弃未提交修改:能自动合并时会保留,冲突时会停止。安装脚本若强制重置、删除目录后重拉或重新解压,则可能覆盖本定制。
+
+1. 更新前保存补丁:`git diff > codex-quota-overdraft.patch`
+2. 拉取官方代码并处理冲突。
+3. 尝试恢复补丁:`git apply --3way codex-quota-overdraft.patch`
+4. 无法自动应用时,按本文件“完整行为”和“修改文件”逐项重新接入。
+5. 在 `backend` 执行 `go generate ./cmd/server`,重新生成 Wire。
+6. 确认实际 `config.yaml` 仍有 `gateway.codex_quota_overdraft_enabled: true`。
+7. 运行验证:
+
+```bash
+cd backend
+gofmt -w internal/service/openai_codex_quota_overdraft*.go
+go test -tags=unit ./internal/repository ./internal/handler ./internal/service \
+ -run 'CodexQuotaOverdraft|AccountTestService_OpenAI|AccountSchedulingThreshold|SchedulerSnapshot|SchedulableAccountQueryScopesCodex' -count=1
+go test ./internal/repository ./internal/handler ./internal/service ./cmd/server -run '^$' -count=1
+
+cd ../frontend
+pnpm install --frozen-lockfile
+pnpm run typecheck
+pnpm exec vitest run \
+ src/components/account/__tests__/UsageProgressBar.spec.ts \
+ src/components/account/__tests__/AccountUsageCell.spec.ts
+
+cd ..
+git diff --check
+```
+
+人工核对所有 Responses 出站路径仍在最终发送前注入,并检查日志关键字:
+
+```text
+codex_quota_overdraft_probe_passed
+codex_quota_overdraft_probe_failed
+codex_quota_overdraft_probe_inconclusive
+codex_quota_overdraft_probe_attempt
+codex_quota_overdraft_business_passed
+codex_quota_overdraft_business_exhausted
+codex_quota_overdraft_pause_applied
+codex_quota_overdraft_stale_rate_limit_cleared
+```
+
+数据库状态可这样检查:
+
+```sql
+SELECT id, name, extra->'codex_quota_overdraft_probe'
+FROM accounts
+WHERE platform = 'openai' AND type = 'oauth';
+```
diff --git a/DEV_GUIDE.md b/DEV_GUIDE.md
index 349c24449159cdb222d4d415c8d09ddca307bce3..eef1c394ec4d3110fce157fdcc611d9e969a8e98 100644
--- a/DEV_GUIDE.md
+++ b/DEV_GUIDE.md
@@ -53,7 +53,7 @@ npm install -g pnpm
### CI 要求
-- Go 版本必须是 **1.26.5**:三个 workflow 都用 `go-version-file: backend/go.mod` 取版本,随后硬断言 `go version | grep -q 'go1.26.5'`。升级 Go 时要同时改 `backend/go.mod` 和 `backend-ci.yml`(两处)、`release.yml`、`security-scan.yml` 里的这句断言,否则 CI 会在版本校验步骤直接失败。
+- Go 版本必须是 **1.26.6**:三个 workflow 都用 `go-version-file: backend/go.mod` 取版本,随后硬断言 `go version | grep -q 'go1.26.6'`。升级 Go 时要同时改 `backend/go.mod` 和 `backend-ci.yml`(两处)、`release.yml`、`security-scan.yml` 里的这句断言,否则 CI 会在版本校验步骤直接失败。
- 前端使用 `pnpm install --frozen-lockfile`,必须提交 `pnpm-lock.yaml`
### 本地测试命令
diff --git a/Dockerfile b/Dockerfile
index b2dfd7034f769abd7e8640090282e6b05a2af4a1..defdbaf0886aff73a04cb2590675984cef834675 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,7 +8,7 @@
# =============================================================================
ARG NODE_IMAGE=node:24-alpine
-ARG GOLANG_IMAGE=golang:1.26.5-alpine
+ARG GOLANG_IMAGE=golang:1.26.6-alpine
ARG ALPINE_IMAGE=alpine:3.21
ARG POSTGRES_IMAGE=postgres:18-alpine
ARG GOPROXY=https://goproxy.cn,direct
@@ -56,6 +56,7 @@ FROM --platform=${BUILDPLATFORM} ${GOLANG_IMAGE} AS backend-builder
ARG VERSION=
ARG COMMIT=docker
ARG DATE
+ARG BUILD_TYPE=source
ARG GOPROXY
ARG GOSUMDB
# Populated by buildx from the --platform target (e.g. linux/amd64).
@@ -79,20 +80,22 @@ RUN --mount=type=cache,id=sub2api-gomod,target=/go/pkg/mod \
# Copy backend source first
COPY backend/ ./
+COPY FORK_VERSION /app/FORK_VERSION
# Copy frontend dist from previous stage (must be after backend copy to avoid being overwritten)
COPY --from=frontend-builder /app/backend/internal/web/dist ./internal/web/dist
-# Build the binary (BuildType=release for CI builds, embed frontend)
-# Version precedence: build arg VERSION > exact git tag > cmd/server/VERSION
+# Build the binary and embed the frontend. Fork source builds read FORK_VERSION;
+# CI release builds still override VERSION and BUILD_TYPE explicitly.
RUN --mount=type=cache,id=sub2api-gomod,target=/go/pkg/mod \
--mount=type=cache,id=sub2api-gobuild,target=/root/.cache/go-build \
VERSION_VALUE="${VERSION}" && \
+ if [ -z "${VERSION_VALUE}" ] && [ -s /app/FORK_VERSION ]; then VERSION_VALUE="$(tr -d '\r\n' < /app/FORK_VERSION)"; fi && \
if [ -z "${VERSION_VALUE}" ]; then VERSION_VALUE="$(./scripts/resolve-version.sh)"; fi && \
DATE_VALUE="${DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" && \
CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \
-tags embed \
- -ldflags="-s -w -X main.Version=${VERSION_VALUE} -X main.Commit=${COMMIT} -X main.Date=${DATE_VALUE} -X main.BuildType=release" \
+ -ldflags="-s -w -X main.Version=${VERSION_VALUE} -X main.Commit=${COMMIT} -X main.Date=${DATE_VALUE} -X main.BuildType=${BUILD_TYPE}" \
-trimpath \
-o /app/sub2api \
./cmd/server
@@ -108,9 +111,10 @@ FROM ${POSTGRES_IMAGE} AS pg-client
FROM ${ALPINE_IMAGE}
# Labels
-LABEL maintainer="Wei-Shaw <github.com/Wei-Shaw>"
-LABEL description="Sub2API - AI API Gateway Platform"
-LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api"
+LABEL maintainer="DeanZFC <github.com/DeanZFC>"
+LABEL description="sub2api-overdraft - Sub2API fork with Codex quota overdraft support"
+LABEL org.opencontainers.image.source="https://github.com/DeanZFC/sub2api-overdraft"
+LABEL org.opencontainers.image.licenses="LGPL-3.0-or-later"
# Install runtime dependencies
RUN apk add --no-cache \
diff --git a/FORK_VERSION b/FORK_VERSION
new file mode 100644
index 0000000000000000000000000000000000000000..0dc05cc29dd70bee2cb85194d847a7a4839559fc
--- /dev/null
+++ b/FORK_VERSION
@@ -0,0 +1 @@
+0.1.177-overdraft.6
diff --git a/README.md b/README.md
index f4ad2fa6b028bdc70ac0b502104a345dd8d940c1..34d6dfeb494f08476a4fd912d9ee397170781330 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,54 @@
<div align="center">
-<img src="assets/logo.svg" alt="Sub2API Logo" width="128" />
+<img src="assets/logo.svg" alt="sub2api-overdraft Logo" width="128" />
-# Sub2API
+# sub2api-overdraft
-[![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8.svg)](https://golang.org/)
+[![Go](https://img.shields.io/badge/Go-1.26.6-00ADD8.svg)](https://golang.org/)
[![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/)
[![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](https://www.docker.com/)
-<a href="https://trendshift.io/repositories/21823" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21823" alt="Wei-Shaw%2Fsub2api | Trendshift" width="250" height="55"/></a>
-
-**AI API Gateway Platform for Subscription Quota Distribution**
+**AI API gateway with Codex 5h / 7d quota overdraft probing, tracking, and recovery**
English | [中文](README_CN.md) | [日本語](README_JA.md)
</div>
+> [!IMPORTANT]
+> This is an unofficial fork of [Wei-Shaw/sub2api](https://github.com/Wei-Shaw/sub2api), not an official Sub2API release. The upstream install script and `weishaw/sub2api:latest` image do not contain the overdraft feature. Build this fork from source as documented below.
+
+## Fork Features
+
+- Pre-arms ordinary OAuth text traffic with the overdraft payload at 95% usage and uses successful business traffic as direct evidence after usage reaches 100%.
+- Marks an injected request's explicit quota 429 as terminal for that cycle; when business evidence is unavailable, runs at most one independent probe per quota cycle.
+- 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.
+- Treats transient 429s, timeouts, network failures, and 5xx responses as inconclusive without automatic background retries.
+- 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).
+
+Quick start:
+
+```bash
+git clone https://github.com/DeanZFC/sub2api-overdraft.git
+cd sub2api-overdraft/deploy
+cp .env.example .env
+# Set POSTGRES_PASSWORD, JWT_SECRET, and TOTP_ENCRYPTION_KEY in .env
+mkdir -p data postgres_data redis_data
+docker compose \
+ -f docker-compose.local.yml \
+ -f docker-compose.overdraft.yml \
+ up -d --build
+```
+
+This fork remains licensed under [GNU LGPL-3.0](LICENSE) and preserves upstream attribution. The overdraft behavior may conflict with upstream provider terms and may incur real usage or account restrictions. Operators are responsible for compliance and risk.
+
+The remaining feature, deployment, sponsor, and license text is inherited from the upstream Sub2API documentation. Upstream sponsorship does not imply sponsorship or endorsement of this fork.
+
## ⚠️ Important Notice
Please read the following carefully before using this project:
@@ -206,7 +237,7 @@ Community projects that extend or integrate with Sub2API:
| Component | Technology |
|-----------|------------|
-| Backend | Go 1.26.5, Gin, Ent |
+| Backend | Go 1.26.6, Gin, Ent |
| Frontend | Vue 3.4+, Vite 5+, TailwindCSS |
| Database | PostgreSQL 15+ |
| Cache/Queue | Redis 7+ |
diff --git a/README_CN.md b/README_CN.md
index 2d2a57a9ff3a363618001dda3ffbfae84bcd0f41..e1c99262012ec6094c84d6e3c52fe3c734527c2f 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -1,23 +1,86 @@
<div align="center">
-<img src="assets/logo.svg" alt="Sub2API Logo" width="128" />
+<img src="assets/logo.svg" alt="sub2api-overdraft Logo" width="128" />
-# Sub2API
+# sub2api-overdraft
-[![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8.svg)](https://golang.org/)
+[![Go](https://img.shields.io/badge/Go-1.26.6-00ADD8.svg)](https://golang.org/)
[![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/)
[![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](https://www.docker.com/)
-<a href="https://trendshift.io/repositories/21823" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21823" alt="Wei-Shaw%2Fsub2api | Trendshift" width="250" height="55"/></a>
-
-**AI API 网关平台 - 订阅配额分发管理**
+**带 Codex 5h / 7d 额度透支探测、统计与恢复的 AI API 网关**
[English](README.md) | 中文 | [日本語](README_JA.md)
</div>
+> [!IMPORTANT]
+> 这是基于 [Wei-Shaw/sub2api](https://github.com/Wei-Shaw/sub2api) 的非官方 Fork,不是 Sub2API 官方发行版。官方安装脚本和 `weishaw/sub2api:latest` 镜像不包含本项目的透支功能,请按本仓库的源码构建文档部署。
+
+## 本 Fork 增加的功能
+
+- Codex 5h / 7d 用量达到 95% 后,为普通 OAuth 文本业务请求注入透支请求形态;达到 100% 后直接使用真实业务结果确认透支状态。
+- 注入业务请求成功即记为 `passed`;返回明确额度 429 即记为 `failed` 并切号冷却。缺少业务证据时,同一额度周期最多补充 1 次独立探测。
+- 确认成功后继续参与账号调度,并分别统计 5h / 7d 透支期请求数、Token 和金额。
+- 管理页面显示“透支探测中”“透支中”“已确认限额”“探测无法确认”和“额度已恢复”。
+- 网络、超时、5xx 和普通瞬时 429 不会被误判为额度耗尽;401/403、账号禁用和其他风控仍使用原有策略。
+- 多实例通过 PostgreSQL 原子领取(atomic claim)去重;状态保存在现有 `accounts.extra`,无需新增数据表。
+- 可通过一个配置开关立即关闭,恢复上游 Sub2API 的调度和请求行为。
+- 管理后台从 `DeanZFC/sub2api-overdraft` 的 `codex-overdraft` 分支检查更新,不再使用官方 Sub2API 的版本结果。
+
+## 快速部署
+
+完整步骤、现有服务器迁移、Nginx、验证、升级和故障排查请阅读:
+
+**[sub2api-overdraft 部署与运维指南](CODEX_OVERDRAFT_DEPLOYMENT_CN.md)**
+
+最短部署流程:
+
+```bash
+git clone https://github.com/DeanZFC/sub2api-overdraft.git
+cd sub2api-overdraft/deploy
+cp .env.example .env
+chmod 600 .env
+# 编辑 .env,至少设置 POSTGRES_PASSWORD、JWT_SECRET 和 TOTP_ENCRYPTION_KEY
+mkdir -p data postgres_data redis_data
+docker compose \
+ -f docker-compose.local.yml \
+ -f docker-compose.overdraft.yml \
+ up -d --build
+```
+
+源码中的 `deploy/config.example.yaml` 只是模板。运行配置通常位于 `deploy/data/config.yaml`。透支开关为:
+
+```yaml
+gateway:
+ codex_quota_overdraft_enabled: true
+```
+
+公开的 `docker-compose.overdraft.yml` 已通过环境变量默认开启该功能。
+
+源码镜像会把根目录的 `FORK_VERSION` 写入版本信息。管理后台检测到 Fork 新版本后只提示使用 `git pull` 更新源码,不会下载官方二进制覆盖透支功能。完整更新命令见部署指南的“日常升级本 Fork”。
+
+## 如何确认透支成功
+
+账号额度达到 100% 后检查日志:
+
+```bash
+docker logs --since 30m sub2api 2>&1 | \
+ grep -E 'codex_quota_overdraft_(probe|state|pause|stale_rate_limit)'
+```
+
+出现 `codex_quota_overdraft_business_passed` 或 `codex_quota_overdraft_probe_passed`,页面显示“透支中”,并且后续真实业务请求成功,即可确认透支功能完整生效。注入业务请求返回明确额度 429 时会出现 `codex_quota_overdraft_business_exhausted`,状态、账号暂停和调度通知会原子提交。网络错误、5xx、超时和普通瞬时 429 不会判定透支结束;独立探测为 `inconclusive` 后同周期不自动重试。OpenAI OAuth 常规文本“测试账号连接”也使用同一请求形态和状态机;API Key、Shadow、图片和 Compact 测试除外。额度未达到 95% 时没有透支注入,未达到 100% 时没有探测日志,均属正常现象。
+
+## 来源、许可证与风险
+
+- 上游项目:[Wei-Shaw/sub2api](https://github.com/Wei-Shaw/sub2api)
+- 透支逻辑参考:[Mxucc/cpa-account-config-manager](https://github.com/Mxucc/cpa-account-config-manager)
+- 许可证:[GNU LGPL-3.0](LICENSE),保留上游版权和许可证声明。
+- 本功能不保证上游一定允许超额调用,探测和后续请求可能产生真实用量,也可能触发账号限制。请自行核对上游服务条款并承担使用风险。
+
+下面的功能、部署和赞助信息继承自上游 Sub2API 文档。上游赞助关系不代表这些组织赞助或认可本 Fork;需要透支功能时,请以上方本 Fork 部署指南为准。
## ⚠️ 重要提醒
@@ -208,7 +271,7 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的
| 组件 | 技术 |
|------|------|
-| 后端 | Go 1.26.5, Gin, Ent |
+| 后端 | Go 1.26.6, Gin, Ent |
| 前端 | Vue 3.4+, Vite 5+, TailwindCSS |
| 数据库 | PostgreSQL 15+ |
| 缓存/队列 | Redis 7+ |
@@ -273,12 +336,9 @@ sudo systemctl enable sub2api
#### 升级
-可以直接在 **管理后台** 左上角点击 **检测更新** 按钮进行在线升级。
+官方发行版可以直接在管理后台进行二进制在线升级。本 Fork 使用源码构建,管理后台会读取 `DeanZFC/sub2api-overdraft` 的 `FORK_VERSION` 检测新版本,并提示使用 `git pull` 后重新构建;不会在线替换二进制。
-网页升级功能支持:
-- 自动检测新版本
-- 一键下载并应用更新
-- 支持回滚
+本 Fork 的升级命令见 [日常升级本 Fork](CODEX_OVERDRAFT_DEPLOYMENT_CN.md#日常升级本-fork)。源码构建不支持页面内一键升级和在线回退,避免误装官方二进制而丢失透支功能。
#### 常用命令
diff --git a/README_JA.md b/README_JA.md
index 09aa77ee48aec83440ee76054887b2627c1e3792..e623063b29825d75c63323fc737b3bc6b1568fc8 100644
--- a/README_JA.md
+++ b/README_JA.md
@@ -1,23 +1,48 @@
<div align="center">
-<img src="assets/logo.svg" alt="Sub2API Logo" width="128" />
+<img src="assets/logo.svg" alt="sub2api-overdraft Logo" width="128" />
-# Sub2API
+# sub2api-overdraft
-[![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8.svg)](https://golang.org/)
+[![Go](https://img.shields.io/badge/Go-1.26.6-00ADD8.svg)](https://golang.org/)
[![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/)
[![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](https://www.docker.com/)
-<a href="https://trendshift.io/repositories/21823" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21823" alt="Wei-Shaw%2Fsub2api | Trendshift" width="250" height="55"/></a>
-
-**サブスクリプションクォータ配分のための AI API ゲートウェイプラットフォーム**
+**Codex 5h / 7d クォータのオーバードラフト検証・集計・回復機能を追加した AI API ゲートウェイ**
[English](README.md) | [中文](README_CN.md) | 日本語
</div>
+> [!IMPORTANT]
+> これは [Wei-Shaw/sub2api](https://github.com/Wei-Shaw/sub2api) の非公式 Fork であり、Sub2API の公式リリースではありません。公式インストールスクリプトと `weishaw/sub2api:latest` イメージには、本 Fork のオーバードラフト機能は含まれていません。
+
+## Fork の追加機能
+
+- Codex の 5h または 7d クォータが 100% に達した後、最大 5 回の実リクエストで利用可否を検証します。
+- 検証成功後もアカウントをスケジュール対象に保ち、リクエスト数、Token、コスト、回復時刻を表示します。
+- `pending`、`passed`、`failed`、`inconclusive`、`recovered` の状態を管理画面と PostgreSQL に保存します。
+- 判定不能な検証は 1 分、3 分、10 分後に再試行し、再起動後も PostgreSQL の保存状態から再開します。
+- PostgreSQL の atomic claim により複数インスタンスで重複検証を防ぎ、最終失敗・アカウント停止・スケジューラ通知を同一トランザクションで保存します。追加の schema migration は不要です。
+
+ソースビルド、既存環境からの移行、検証、更新、ロールバック、Nginx、トラブルシューティングについては、**[中国語のデプロイ・運用ガイド](CODEX_OVERDRAFT_DEPLOYMENT_CN.md)** を参照してください。
+
+```bash
+git clone https://github.com/DeanZFC/sub2api-overdraft.git
+cd sub2api-overdraft/deploy
+cp .env.example .env
+# .env に POSTGRES_PASSWORD、JWT_SECRET、TOTP_ENCRYPTION_KEY を設定
+mkdir -p data postgres_data redis_data
+docker compose \
+ -f docker-compose.local.yml \
+ -f docker-compose.overdraft.yml \
+ up -d --build
+```
+
+本 Fork は [GNU LGPL-3.0](LICENSE) を継承し、上流の著作権表示を保持します。以下の機能、デプロイ、スポンサー情報は上流 Sub2API の文書を継承したものであり、上流のスポンサーが本 Fork を支援または承認していることを意味しません。
+
## ⚠️ 重要なお知らせ
本プロジェクトをご利用になる前に、以下の内容を必ずよくお読みください:
@@ -207,7 +232,7 @@ Sub2API を拡張・統合するコミュニティプロジェクト:
| コンポーネント | 技術 |
|-----------|------------|
-| バックエンド | Go 1.26.5, Gin, Ent |
+| バックエンド | Go 1.26.6, Gin, Ent |
| フロントエンド | Vue 3.4+, Vite 5+, TailwindCSS |
| データベース | PostgreSQL 15+ |
| キャッシュ/キュー | Redis 7+ |
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 9976abe46b5494b1bed2a5ea1ec712137f812791..1efbd3859da876eaf6c3843080f6c9dc5ad3e33e 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.26.5-alpine
+FROM golang:1.26.6-alpine
WORKDIR /app
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index 2c5d5cf545fe0b0cff187b31c9bac0550cc86b4e..12faf126aa731109be02afb9f4b9c8e6a7d6e8b7 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -922,6 +922,9 @@ type GatewayConfig struct {
// CodexImageGenerationBridgeEnabled: 是否为 Codex `/v1/responses` 自动注入 image_generation 工具和桥接指令。
// 默认关闭,避免纯文本 Codex 请求被意外改写;显式携带 image_generation 工具的请求仍按分组能力转发。
CodexImageGenerationBridgeEnabled bool `mapstructure:"codex_image_generation_bridge_enabled"`
+ // CodexQuotaOverdraftEnabled: 是否启用 OpenAI OAuth Codex 5h/7d 额度透支。
+ // 包括请求注入、五次真实复核、调度门控和透支期用量统计;默认关闭。
+ CodexQuotaOverdraftEnabled bool `mapstructure:"codex_quota_overdraft_enabled"`
// ForcedCodexInstructionsTemplateFile: 服务端强制附加到 Codex 顶层 instructions 的模板文件路径。
// 模板渲染后会直接覆盖最终 instructions;若需要保留客户端 system 转换结果,请在模板中显式引用 {{ .ExistingInstructions }}。
ForcedCodexInstructionsTemplateFile string `mapstructure:"forced_codex_instructions_template_file"`
@@ -2276,6 +2279,7 @@ func setDefaults() {
viper.SetDefault("gateway.disable_codex_identity_enforcement", false)
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_passthrough_allow_timeout_headers", false)
viper.SetDefault("gateway.openai_compact_model", "gpt-5.4")
viper.SetDefault("gateway.live.max_session_duration_seconds", 3600)
diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go
index 42dc13112a791aad5f0ce460a54630c6fade0d7a..51add5121e138a40b43ed2b51556d0c49853cfce 100644
--- a/backend/internal/handler/openai_chat_completions.go
+++ b/backend/internal/handler/openai_chat_completions.go
@@ -149,7 +149,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
var oauth429FailoverState service.OpenAIOAuth429FailoverState
// 分组利润控制:chat completions 文本入口请求级装门并固定 pricingAt。
- ccPricingCtx, pricingAt := h.gatewayService.WithOpenAIRequestPricingContext(c.Request.Context(), apiKey.GroupID)
+ ccPricingCtx := service.WithCodexQuotaOverdraftScheduling(c.Request.Context())
+ ccPricingCtx, pricingAt := h.gatewayService.WithOpenAIRequestPricingContext(ccPricingCtx, apiKey.GroupID)
c.Request = c.Request.WithContext(ccPricingCtx)
for {
@@ -343,9 +344,9 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
}
}
if result != nil {
- h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, result.FirstTokenMs)
+ h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, result.FirstTokenMs, c.Request.Context())
} else {
- h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, nil)
+ h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, nil, c.Request.Context())
}
userAgent := c.GetHeader("User-Agent")
diff --git a/backend/internal/handler/openai_gateway_compact_body_signal_test.go b/backend/internal/handler/openai_gateway_compact_body_signal_test.go
index 370abaeba55d47e04c6d2d1a08291cf39755a612..ca1f94a359e8736d186b8635afec8f5806bcf827 100644
--- a/backend/internal/handler/openai_gateway_compact_body_signal_test.go
+++ b/backend/internal/handler/openai_gateway_compact_body_signal_test.go
@@ -115,6 +115,31 @@ func TestNormalizeOpenAIResponsesCompactRequest_RemoteV2PathAliasesStayOnRespons
}
}
+func TestShouldEnableCodexQuotaOverdraftForResponsesExcludesCompactAndImage(t *testing.T) {
+ tests := []struct {
+ name string
+ legacyCompact bool
+ nativeV2 bool
+ imageIntent bool
+ want bool
+ }{
+ {name: "ordinary_responses", want: true},
+ {name: "legacy_compact", legacyCompact: true},
+ {name: "native_compact_v2", nativeV2: true},
+ {name: "image_generation", imageIntent: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.Equal(t, tt.want, shouldEnableCodexQuotaOverdraftForResponses(
+ tt.legacyCompact,
+ tt.nativeV2,
+ tt.imageIntent,
+ ))
+ })
+ }
+}
+
func TestOpenAIResponsesCompactionRoutingFlags(t *testing.T) {
h := &OpenAIGatewayHandler{}
tests := []struct {
diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go
index 1695eccc1ba5c0c993b62f6f6adb95b2e8fd6eb3..aad3c87514a54bd138c57c56c135d40bc0e16f36 100644
--- a/backend/internal/handler/openai_gateway_handler.go
+++ b/backend/internal/handler/openai_gateway_handler.go
@@ -192,6 +192,10 @@ func openAIResponsesRequiredCapabilityForRequest(imageIntent bool, needsResponse
return openAIResponsesRequiredCapability(imageIntent, platform)
}
+func shouldEnableCodexQuotaOverdraftForResponses(legacyCompact, nativeV2, imageIntent bool) bool {
+ return !legacyCompact && !nativeV2 && !imageIntent
+}
+
func allowOpenAICompatibleMessagesDispatch(apiKey *service.APIKey) bool {
if apiKey == nil || apiKey.Group == nil {
return true
@@ -470,7 +474,11 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
// D 与计费高峰因子,选号、槽位终检与全部 failover 重入共用同一门与阈值。
// 生图意图只影响能力路由与图片计费,不关门:混合 /v1/responses 请求的
// token 计费部分仍受利润门保护,独立图片/视频端点才在门外。
- pricingCtx, pricingAt := h.gatewayService.WithOpenAIRequestPricingContext(c.Request.Context(), apiKey.GroupID)
+ requestCtx := c.Request.Context()
+ if shouldEnableCodexQuotaOverdraftForResponses(legacyCompact, nativeV2, imageIntent) {
+ requestCtx = service.WithCodexQuotaOverdraftScheduling(requestCtx)
+ }
+ pricingCtx, pricingAt := h.gatewayService.WithOpenAIRequestPricingContext(requestCtx, apiKey.GroupID)
c.Request = c.Request.WithContext(pricingCtx)
for {
@@ -709,9 +717,9 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
if account.Type == service.AccountTypeOAuth && !account.IsShadow() {
h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(c.Request.Context(), account.ID, result.ResponseHeaders)
}
- h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), result.FirstTokenMs)
+ h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), result.FirstTokenMs, c.Request.Context())
} else {
- h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), nil)
+ h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), nil, c.Request.Context())
}
// 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context)
@@ -1034,7 +1042,8 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
effectiveMappedModel := preferredMappedModel
// 分组利润控制:Messages 文本入口同样请求级装门并固定 pricingAt。
- msgPricingCtx, pricingAt := h.gatewayService.WithOpenAIRequestPricingContext(c.Request.Context(), apiKey.GroupID)
+ msgPricingCtx := service.WithCodexQuotaOverdraftScheduling(c.Request.Context())
+ msgPricingCtx, pricingAt := h.gatewayService.WithOpenAIRequestPricingContext(msgPricingCtx, apiKey.GroupID)
c.Request = c.Request.WithContext(msgPricingCtx)
for {
@@ -1232,9 +1241,9 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
}
}
if result != nil {
- h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), true, result.FirstTokenMs)
+ h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), true, result.FirstTokenMs, c.Request.Context())
} else {
- h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), true, nil)
+ h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), true, nil, c.Request.Context())
}
userAgent := c.GetHeader("User-Agent")
@@ -1753,6 +1762,10 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, service.ImageGenerationPermissionMessage())
return
}
+ if !imageIntent {
+ ctx = service.WithCodexQuotaOverdraftScheduling(ctx)
+ c.Request = c.Request.WithContext(ctx)
+ }
// F5a: 握手层会话屏蔽检查。WS 握手无 body,显式标识仅来自握手 header
// (session_id / conversation_id);无标识则放行,连接内仍有本地 flag 兜底。
@@ -2191,7 +2204,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
if scheduleModel == "" {
scheduleModel = turnRequestedModel
}
- h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, scheduleModel, openAIForwardSucceededForScheduling(result), result.FirstTokenMs)
+ h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, scheduleModel, openAIForwardSucceededForScheduling(result), result.FirstTokenMs, ctx)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go
index 010442eb6bcf3b80d4e7d65c27d8b9dd966259d4..1b9b0958c6d0b2b8120f6553ecbcb45a7ea640cf 100644
--- a/backend/internal/repository/account_repo.go
+++ b/backend/internal/repository/account_repo.go
@@ -56,6 +56,7 @@ var schedulerNeutralExtraKeyPrefixes = []string{
"codex_secondary_",
"codex_5h_",
"codex_7d_",
+ "codex_quota_overdraft_",
"codex_reset_credit_",
"passive_usage_",
"upstream_billing_probe",
@@ -1832,7 +1833,7 @@ func (r *accountRepository) BindGroups(ctx context.Context, accountID int64, gro
}
func (r *accountRepository) ListSchedulable(ctx context.Context) ([]service.Account, error) {
- accounts, err := r.schedulableAccountsQuery(time.Now()).All(ctx)
+ accounts, err := r.schedulableAccountsQuery(ctx, time.Now()).All(ctx)
if err != nil {
return nil, err
}
@@ -1840,7 +1841,7 @@ func (r *accountRepository) ListSchedulable(ctx context.Context) ([]service.Acco
}
func (r *accountRepository) ListSchedulableAccountLoads(ctx context.Context) ([]service.AccountWithConcurrency, error) {
- accounts, err := r.schedulableAccountsQuery(time.Now()).
+ accounts, err := r.schedulableAccountsQuery(ctx, time.Now()).
Select(
dbaccount.FieldID,
dbaccount.FieldConcurrency,
@@ -1866,12 +1867,12 @@ func (r *accountRepository) ListSchedulableAccountLoads(ctx context.Context) ([]
return loads, nil
}
-func (r *accountRepository) schedulableAccountsQuery(now time.Time) *dbent.AccountQuery {
+func (r *accountRepository) schedulableAccountsQuery(ctx context.Context, now time.Time) *dbent.AccountQuery {
return r.client.Account.Query().
Where(
dbaccount.StatusEQ(service.StatusActive),
dbaccount.SchedulableEQ(true),
- tempUnschedulablePredicate(),
+ tempUnschedulablePredicate(ctx),
notExpiredPredicate(now),
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
@@ -1977,7 +1978,7 @@ func (r *accountRepository) ListSchedulableByPlatform(ctx context.Context, platf
dbaccount.PlatformEQ(platform),
dbaccount.StatusEQ(service.StatusActive),
dbaccount.SchedulableEQ(true),
- tempUnschedulablePredicate(),
+ tempUnschedulablePredicate(ctx),
notExpiredPredicate(now),
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
@@ -2011,7 +2012,7 @@ func (r *accountRepository) ListSchedulableByPlatforms(ctx context.Context, plat
dbaccount.PlatformIn(platforms...),
dbaccount.StatusEQ(service.StatusActive),
dbaccount.SchedulableEQ(true),
- tempUnschedulablePredicate(),
+ tempUnschedulablePredicate(ctx),
notExpiredPredicate(now),
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
@@ -2032,7 +2033,7 @@ func (r *accountRepository) ListSchedulableUngroupedByPlatform(ctx context.Conte
dbaccount.StatusEQ(service.StatusActive),
dbaccount.SchedulableEQ(true),
dbaccount.Not(dbaccount.HasAccountGroups()),
- tempUnschedulablePredicate(),
+ tempUnschedulablePredicate(ctx),
notExpiredPredicate(now),
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
@@ -2056,7 +2057,7 @@ func (r *accountRepository) ListSchedulableUngroupedByPlatforms(ctx context.Cont
dbaccount.StatusEQ(service.StatusActive),
dbaccount.SchedulableEQ(true),
dbaccount.Not(dbaccount.HasAccountGroups()),
- tempUnschedulablePredicate(),
+ tempUnschedulablePredicate(ctx),
notExpiredPredicate(now),
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
@@ -3029,7 +3030,7 @@ func (r *accountRepository) queryAccountsByGroup(ctx context.Context, groupID in
if !opts.ignoreTransientState {
now := time.Now()
preds = append(preds,
- tempUnschedulablePredicate(),
+ tempUnschedulablePredicate(ctx),
notExpiredPredicate(now),
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
@@ -3134,13 +3135,15 @@ func (r *accountRepository) accountsToService(ctx context.Context, accounts []*d
return outAccounts, nil
}
-func tempUnschedulablePredicate() dbpredicate.Account {
+func tempUnschedulablePredicate(ctx context.Context) dbpredicate.Account {
return dbpredicate.Account(func(s *entsql.Selector) {
col := s.C("temp_unschedulable_until")
- s.Where(entsql.Or(
+ predicates := []*entsql.Predicate{
entsql.IsNull(col),
entsql.LTE(col, entsql.Expr("NOW()")),
- ))
+ }
+ predicates = extendCodexQuotaOverdraftTempUnschedulablePredicates(ctx, s, predicates)
+ s.Where(entsql.Or(predicates...))
})
}
diff --git a/backend/internal/repository/account_repo_codex_overdraft.go b/backend/internal/repository/account_repo_codex_overdraft.go
new file mode 100644
index 0000000000000000000000000000000000000000..46c63d17beb8e7dc6eca287b2575ae70faf763d0
--- /dev/null
+++ b/backend/internal/repository/account_repo_codex_overdraft.go
@@ -0,0 +1,232 @@
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+
+ entsql "entgo.io/ent/dialect/sql"
+)
+
+// ClaimCodexQuotaOverdraftProbe atomically reserves one quota cycle. A cycle is
+// claimed at most once across all sub2api replicas.
+func (r *accountRepository) ClaimCodexQuotaOverdraftProbe(
+ ctx context.Context,
+ id int64,
+ state *service.CodexQuotaOverdraftProbeState,
+) (bool, error) {
+ if state == nil || strings.TrimSpace(state.CycleKey) == "" {
+ return false, nil
+ }
+ payload, err := json.Marshal(state)
+ if err != nil {
+ return false, err
+ }
+ result, err := r.sql.ExecContext(ctx, `
+ UPDATE accounts
+ SET extra = COALESCE(extra, '{}'::jsonb) || jsonb_build_object($1::text, $2::jsonb),
+ updated_at = NOW()
+ WHERE id = $3
+ AND deleted_at IS NULL
+ AND COALESCE(extra #>> '{codex_quota_overdraft_probe,cycle_key}', '') <> $4
+ `, service.CodexQuotaOverdraftProbeExtraKey, string(payload), id, state.CycleKey)
+ if err != nil {
+ return false, err
+ }
+ affected, err := result.RowsAffected()
+ if err != nil || affected == 0 {
+ return false, err
+ }
+ r.syncSchedulerAccountSnapshot(ctx, id)
+ return true, nil
+}
+
+// PersistCodexQuotaOverdraftProbeUnlessFailed stores a non-failure result while
+// preserving a terminal failure already confirmed for the same quota cycle.
+func (r *accountRepository) PersistCodexQuotaOverdraftProbeUnlessFailed(
+ ctx context.Context,
+ id int64,
+ state *service.CodexQuotaOverdraftProbeState,
+) (bool, error) {
+ if state == nil || state.Status == "failed" || strings.TrimSpace(state.CycleKey) == "" {
+ return false, nil
+ }
+ payload, err := json.Marshal(state)
+ if err != nil {
+ return false, err
+ }
+ result, err := r.sql.ExecContext(ctx, `
+ UPDATE accounts
+ SET extra = COALESCE(extra, '{}'::jsonb) || jsonb_build_object($1::text, $2::jsonb),
+ updated_at = NOW()
+ WHERE id = $3
+ AND deleted_at IS NULL
+ AND (
+ COALESCE(extra #>> '{codex_quota_overdraft_probe,cycle_key}', '') <> $4
+ OR COALESCE(extra #>> '{codex_quota_overdraft_probe,status}', '') <> 'failed'
+ )
+ `, service.CodexQuotaOverdraftProbeExtraKey, string(payload), id, state.CycleKey)
+ if err != nil {
+ return false, err
+ }
+ affected, err := result.RowsAffected()
+ if err != nil || affected == 0 {
+ return false, err
+ }
+ r.syncSchedulerAccountSnapshot(ctx, id)
+ return true, nil
+}
+
+// ClearCodexQuotaOverdraftPauseIfState clears stale scheduling state only when
+// the persisted overdraft result is still the expected available state.
+func (r *accountRepository) ClearCodexQuotaOverdraftPauseIfState(
+ ctx context.Context,
+ id int64,
+ cycleKey string,
+ status string,
+ expectedTempReason string,
+) (bool, bool, error) {
+ if id <= 0 || strings.TrimSpace(cycleKey) == "" || (status != "passed" && status != "recovered") {
+ return false, false, nil
+ }
+ beginner, ok := r.sql.(codexQuotaOverdraftTxBeginner)
+ if !ok {
+ return false, false, errors.New("account repository does not support transactions")
+ }
+ tx, err := beginner.BeginTx(ctx, nil)
+ if err != nil {
+ return false, false, err
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ var clearedRateLimit, clearedTemp bool
+ err = tx.QueryRowContext(ctx, `
+ WITH target AS (
+ SELECT id,
+ (rate_limited_at IS NOT NULL OR rate_limit_reset_at IS NOT NULL) AS clear_rate_limit,
+ ($4::text <> '' AND COALESCE(temp_unschedulable_reason = $4, FALSE)) AS clear_temp
+ FROM accounts
+ WHERE id = $1
+ AND deleted_at IS NULL
+ AND extra #>> '{codex_quota_overdraft_probe,cycle_key}' = $2
+ AND extra #>> '{codex_quota_overdraft_probe,status}' = $3
+ FOR UPDATE
+ )
+ UPDATE accounts AS a
+ SET rate_limited_at = CASE WHEN target.clear_rate_limit THEN NULL ELSE a.rate_limited_at END,
+ rate_limit_reset_at = CASE WHEN target.clear_rate_limit THEN NULL ELSE a.rate_limit_reset_at END,
+ temp_unschedulable_until = CASE WHEN target.clear_temp THEN NULL ELSE a.temp_unschedulable_until END,
+ temp_unschedulable_reason = CASE WHEN target.clear_temp THEN NULL ELSE a.temp_unschedulable_reason END,
+ updated_at = NOW()
+ FROM target
+ WHERE a.id = target.id
+ AND (target.clear_rate_limit OR target.clear_temp)
+ RETURNING target.clear_rate_limit, target.clear_temp
+ `, id, cycleKey, status, expectedTempReason).Scan(&clearedRateLimit, &clearedTemp)
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, false, nil
+ }
+ if err != nil {
+ return false, false, err
+ }
+ if err := enqueueSchedulerOutbox(ctx, tx, service.SchedulerOutboxEventAccountChanged, &id, nil, nil); err != nil {
+ return false, false, err
+ }
+ if err := tx.Commit(); err != nil {
+ return false, false, err
+ }
+ r.syncSchedulerAccountSnapshotDetached(ctx, id)
+ return clearedRateLimit, clearedTemp, 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')
+ OR ($7::boolean AND extra #>> '{codex_quota_overdraft_probe,status}' IN ('passed', 'inconclusive', 'recovered'))
+ )
+ `, service.CodexQuotaOverdraftProbeExtraKey, string(payload), until, reason, id, state.CycleKey, state.ReasonCode == "business_quota_limited")
+ 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
+}
+
+func extendCodexQuotaOverdraftTempUnschedulablePredicates(
+ ctx context.Context,
+ s *entsql.Selector,
+ predicates []*entsql.Predicate,
+) []*entsql.Predicate {
+ if !service.CodexQuotaOverdraftSchedulingEnabled(ctx) {
+ return predicates
+ }
+ reasonCol := s.C("temp_unschedulable_reason")
+ return append(predicates, entsql.And(
+ entsql.EQ(s.C("platform"), service.PlatformOpenAI),
+ entsql.EQ(s.C("type"), service.AccountTypeOAuth),
+ entsql.IsNull(s.C("parent_account_id")),
+ entsql.Contains(reasonCol, `"source":"`+service.AccountSchedulingThresholdReasonSource+`"`),
+ ))
+}
diff --git a/backend/internal/repository/account_repo_codex_overdraft_test.go b/backend/internal/repository/account_repo_codex_overdraft_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..3ad7054a54c340399f3f79bd11a5e5e315ed29f3
--- /dev/null
+++ b/backend/internal/repository/account_repo_codex_overdraft_test.go
@@ -0,0 +1,170 @@
+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: 1,
+ Limit: 1,
+ 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, false).
+ 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: 1,
+ Limit: 1,
+ 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 TestPersistCodexQuotaOverdraftProbeUnlessFailedPreservesTerminalFailure(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ now := time.Now().UTC()
+ state := &service.CodexQuotaOverdraftProbeState{
+ Status: "passed",
+ CycleKey: "5h:quota-cycle",
+ QuotaWindow: "5h",
+ Limit: 1,
+ StartedAt: now,
+ }
+ mock.ExpectExec(`(?s)UPDATE accounts.*status}', ''\) <> 'failed'`).
+ WithArgs(service.CodexQuotaOverdraftProbeExtraKey, sqlmock.AnyArg(), int64(77), state.CycleKey).
+ WillReturnResult(sqlmock.NewResult(0, 0))
+
+ repo := newAccountRepositoryWithSQL(nil, db, nil)
+ persisted, err := repo.PersistCodexQuotaOverdraftProbeUnlessFailed(context.Background(), 77, state)
+
+ require.NoError(t, err)
+ require.False(t, persisted)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestClearCodexQuotaOverdraftPauseIfStateIsAtomic(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ reason := service.BuildTempUnschedReasonPayload("codex_quota_overdraft", "quota exhausted")
+ mock.ExpectBegin()
+ mock.ExpectQuery(`(?s)WITH target AS.*codex_quota_overdraft_probe,cycle_key.*FOR UPDATE.*RETURNING target.clear_rate_limit, target.clear_temp`).
+ WithArgs(int64(77), "5h:quota-cycle", "passed", reason).
+ WillReturnRows(sqlmock.NewRows([]string{"clear_rate_limit", "clear_temp"}).AddRow(true, true))
+ 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)
+ clearedRate, clearedTemp, err := repo.ClearCodexQuotaOverdraftPauseIfState(
+ context.Background(),
+ 77,
+ "5h:quota-cycle",
+ "passed",
+ reason,
+ )
+
+ require.NoError(t, err)
+ require.True(t, clearedRate)
+ require.True(t, clearedTemp)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestFinalizeCodexQuotaOverdraftBusinessFailureCanReplacePassedState(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",
+ Limit: 1,
+ ReasonCode: "business_quota_limited",
+ StartedAt: now,
+ RecoverAt: &until,
+ }
+ reason := service.BuildTempUnschedReasonPayload("codex_quota_overdraft", "quota exhausted")
+
+ mock.ExpectBegin()
+ mock.ExpectExec(`(?s)UPDATE accounts.*'passed', 'inconclusive', 'recovered'`).
+ WithArgs(service.CodexQuotaOverdraftProbeExtraKey, sqlmock.AnyArg(), until, reason, int64(77), state.CycleKey, true).
+ 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())
+}
diff --git a/backend/internal/repository/account_repo_schedulable_projection_test.go b/backend/internal/repository/account_repo_schedulable_projection_test.go
index 76d7e0bdc5fe272bbb4aa1cf24e543ec9acb4a43..328e9f2853005aa9d9287f0de6bca64dbf5f1e2c 100644
--- a/backend/internal/repository/account_repo_schedulable_projection_test.go
+++ b/backend/internal/repository/account_repo_schedulable_projection_test.go
@@ -9,6 +9,7 @@ import (
"github.com/DATA-DOG/go-sqlmock"
dbent "github.com/Wei-Shaw/sub2api/ent"
_ "github.com/Wei-Shaw/sub2api/ent/runtime"
+ "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
"entgo.io/ent/dialect"
@@ -80,3 +81,33 @@ func TestListSchedulableAccountLoadsUsesSingleProjectionQuery(t *testing.T) {
require.True(t, hasOrder, "projection query must preserve schedulable account order: %s", normalized)
require.Contains(t, orderClause, `"priority" ASC`)
}
+
+func TestSchedulableAccountQueryScopesCodexQuotaOverdraftToMarkedContext(t *testing.T) {
+ service.SetCodexQuotaOverdraftEnabled(true)
+ t.Cleanup(func() { service.SetCodexQuotaOverdraftEnabled(false) })
+
+ buildQuery := func(ctx context.Context) string {
+ var capturedSQL string
+ db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(captureEntQueryMatcher{actual: &capturedSQL}))
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+ driver := entsql.OpenDB(dialect.Postgres, db)
+ client := dbent.NewClient(dbent.Driver(driver))
+ t.Cleanup(func() { _ = client.Close() })
+ repo := newAccountRepositoryWithSQL(client, db, nil)
+ mock.ExpectQuery("schedulable query").WillReturnRows(sqlmock.NewRows([]string{"id", "concurrency", "load_factor"}))
+ _, err = repo.ListSchedulableAccountLoads(ctx)
+ require.NoError(t, err)
+ require.NoError(t, mock.ExpectationsWereMet())
+ return normalizeSQLWhitespace(capturedSQL)
+ }
+
+ ordinarySQL := buildQuery(context.Background())
+ require.NotContains(t, ordinarySQL, `"temp_unschedulable_reason" LIKE`)
+
+ overdraftSQL := buildQuery(service.WithCodexQuotaOverdraftScheduling(context.Background()))
+ require.Contains(t, overdraftSQL, `"temp_unschedulable_reason" LIKE`)
+ require.Contains(t, overdraftSQL, `"platform" =`)
+ require.Contains(t, overdraftSQL, `"type" =`)
+ require.Contains(t, overdraftSQL, `"parent_account_id" IS NULL`)
+}
diff --git a/backend/internal/repository/github_release_service.go b/backend/internal/repository/github_release_service.go
index 282a215361b33d99f62d6e0fd259d21f5c58e164..f3898d23cc60256a37a4f7ada67f366f4e5f3175 100644
--- a/backend/internal/repository/github_release_service.go
+++ b/backend/internal/repository/github_release_service.go
@@ -2,6 +2,7 @@ package repository
import (
"context"
+ "encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -112,6 +113,10 @@ func (c *githubReleaseClientError) FetchRecentReleases(ctx context.Context, repo
return nil, c.err
}
+func (c *githubReleaseClientError) FetchRepositoryFile(ctx context.Context, repo, ref, filePath string) ([]byte, error) {
+ return nil, c.err
+}
+
func (c *githubReleaseClientError) DownloadFile(ctx context.Context, url, dest string, maxSize int64) error {
return c.err
}
@@ -178,6 +183,56 @@ func (c *githubReleaseClient) FetchRecentReleases(ctx context.Context, repo stri
return releases, nil
}
+func (c *githubReleaseClient) FetchRepositoryFile(ctx context.Context, repo, ref, filePath string) ([]byte, error) {
+ repo = strings.Trim(strings.TrimSpace(repo), "/")
+ ref = strings.TrimSpace(ref)
+ filePath = strings.Trim(strings.TrimSpace(filePath), "/")
+ if repo == "" || ref == "" || filePath == "" || strings.Contains(filePath, "..") {
+ return nil, fmt.Errorf("invalid GitHub repository file reference")
+ }
+
+ endpoint := fmt.Sprintf("https://api.github.com/repos/%s/contents/%s", repo, filePath)
+ parsed, err := url.Parse(endpoint)
+ if err != nil {
+ return nil, err
+ }
+ query := parsed.Query()
+ query.Set("ref", ref)
+ parsed.RawQuery = query.Encode()
+ req, err := c.newAPIRequest(ctx, parsed.String())
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
+ }
+
+ var payload struct {
+ Encoding string `json:"encoding"`
+ Content string `json:"content"`
+ }
+ if err := json.NewDecoder(io.LimitReader(resp.Body, 256<<10)).Decode(&payload); err != nil {
+ return nil, err
+ }
+ if payload.Encoding != "base64" {
+ return nil, fmt.Errorf("unsupported GitHub file encoding %q", payload.Encoding)
+ }
+ decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(payload.Content, "\n", ""))
+ if err != nil {
+ return nil, fmt.Errorf("decode GitHub repository file: %w", err)
+ }
+ if len(decoded) > 64<<10 {
+ return nil, fmt.Errorf("GitHub repository file is too large")
+ }
+ return decoded, nil
+}
+
func (c *githubReleaseClient) DownloadFile(ctx context.Context, url, dest string, maxSize int64) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
diff --git a/backend/internal/repository/github_release_service_test.go b/backend/internal/repository/github_release_service_test.go
index 3f93f0dfb607f32f078434f1ae1f1e75a05696be..fd7c28dd7a7cd18d07e0165ac6e1b8c950ceb947 100644
--- a/backend/internal/repository/github_release_service_test.go
+++ b/backend/internal/repository/github_release_service_test.go
@@ -3,6 +3,7 @@ package repository
import (
"bytes"
"context"
+ "encoding/base64"
"io"
"net/http"
"net/http/httptest"
@@ -393,6 +394,39 @@ func (s *GitHubReleaseServiceSuite) TestFetchRecentReleases_Success() {
require.Equal(s.T(), "v1.0.0", releases[2].TagName)
}
+func (s *GitHubReleaseServiceSuite) TestFetchRepositoryFile_Success() {
+ encoded := base64.StdEncoding.EncodeToString([]byte("0.1.176-overdraft.1\n"))
+ s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(s.T(), "/repos/DeanZFC/sub2api-overdraft/contents/FORK_VERSION", r.URL.Path)
+ require.Equal(s.T(), "codex-overdraft", r.URL.Query().Get("ref"))
+ require.Equal(s.T(), "application/vnd.github.v3+json", r.Header.Get("Accept"))
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"encoding":"base64","content":"` + encoded + `"}`))
+ }))
+ s.client = &githubReleaseClient{
+ httpClient: &http.Client{Transport: &testTransport{testServerURL: s.srv.URL}},
+ downloadHTTPClient: &http.Client{},
+ }
+
+ content, err := s.client.FetchRepositoryFile(
+ context.Background(),
+ "DeanZFC/sub2api-overdraft",
+ "codex-overdraft",
+ "FORK_VERSION",
+ )
+
+ require.NoError(s.T(), err)
+ require.Equal(s.T(), "0.1.176-overdraft.1\n", string(content))
+}
+
+func (s *GitHubReleaseServiceSuite) TestFetchRepositoryFile_RejectsTraversal() {
+ s.client = newTestGitHubReleaseClient()
+
+ _, err := s.client.FetchRepositoryFile(context.Background(), "test/repo", "main", "../secret")
+
+ require.Error(s.T(), err)
+}
+
func (s *GitHubReleaseServiceSuite) TestFetchRecentReleases_Non200() {
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
diff --git a/backend/internal/service/account_test_codex_overdraft.go b/backend/internal/service/account_test_codex_overdraft.go
new file mode 100644
index 0000000000000000000000000000000000000000..f95531958856421159816bbf2c789e25530266a0
--- /dev/null
+++ b/backend/internal/service/account_test_codex_overdraft.go
@@ -0,0 +1,67 @@
+package service
+
+import (
+ "context"
+ "net/http"
+ "time"
+)
+
+type codexQuotaOverdraftAccountTestCoordinator interface {
+ ObserveAccount(account *Account, preferredModel string)
+ ObserveBusinessSuccess(account *Account, preferredModel string)
+ HandleQuota429(ctx context.Context, account *Account, headers http.Header, body []byte, preferredModel string) bool
+}
+
+func (s *AccountTestService) SetCodexQuotaOverdraftCoordinator(coordinator *CodexQuotaOverdraftCoordinator) {
+ if s != nil {
+ s.codexQuotaOverdraft = coordinator
+ }
+}
+
+func (s *AccountTestService) prepareCodexQuotaOverdraftTestRequest(
+ ctx context.Context,
+ account *Account,
+ payload []byte,
+) (context.Context, []byte, bool) {
+ if !s.codexQuotaOverdraftTestEnabled(account) || !codexQuotaOverdraftInjectionEligible(account, time.Now().UTC()) {
+ return ctx, payload, false
+ }
+ ctx = WithCodexQuotaOverdraftScheduling(ctx)
+ updated, changed, err := injectCodexQuotaOverdraft(payload)
+ if err != nil || !changed {
+ return ctx, payload, false
+ }
+ markCodexQuotaOverdraftInjected(ctx, account.ID)
+ return ctx, updated, true
+}
+
+func (s *AccountTestService) handleCodexQuotaOverdraftTest429(
+ ctx context.Context,
+ account *Account,
+ headers http.Header,
+ body []byte,
+ preferredModel string,
+) bool {
+ return s.codexQuotaOverdraft != nil && s.codexQuotaOverdraftTestEnabled(account) &&
+ s.codexQuotaOverdraft.HandleQuota429(ctx, account, headers, body, preferredModel)
+}
+
+func (s *AccountTestService) observeCodexQuotaOverdraftTestResult(
+ account *Account,
+ preferredModel string,
+ injected bool,
+) {
+ if s.codexQuotaOverdraft == nil || !s.codexQuotaOverdraftTestEnabled(account) {
+ return
+ }
+ if injected {
+ s.codexQuotaOverdraft.ObserveBusinessSuccess(account, preferredModel)
+ return
+ }
+ s.codexQuotaOverdraft.ObserveAccount(account, preferredModel)
+}
+
+func (s *AccountTestService) codexQuotaOverdraftTestEnabled(account *Account) bool {
+ return s != nil && s.cfg != nil && s.cfg.Gateway.CodexQuotaOverdraftEnabled &&
+ isCodexQuotaOverdraftAccount(account)
+}
diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go
index 496ef57aed85879bcfd65564dbf3fa81a81a8223..6d4857873080c73527aaa26c01e48aa17ec5c59b 100644
--- a/backend/internal/service/account_test_service.go
+++ b/backend/internal/service/account_test_service.go
@@ -146,6 +146,7 @@ type AccountTestService struct {
cfg *config.Config
settingService *SettingService
tlsFPProfileService *TLSFingerprintProfileService
+ codexQuotaOverdraft codexQuotaOverdraftAccountTestCoordinator
agentIdentityTaskMu sync.Mutex
agentIdentityWS agentIdentityWSConnectionInvalidator
// grokWSDialer is optional; realtime account tests use the default OpenAI-style
@@ -694,6 +695,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
}
payload := createOpenAITestPayload(upstreamTestModelID, isOAuth)
payloadBytes, _ := json.Marshal(payload)
+ ctx, payloadBytes, overdraftInjected := s.prepareCodexQuotaOverdraftTestRequest(ctx, account, payloadBytes)
// Send test_start event once. A task-invalid Agent Identity response may
// restart this probe after registering a replacement task.
@@ -777,7 +779,9 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
return s.testOpenAIAccountConnection(c, account, modelID, prompt, mode)
}
if resp.StatusCode == http.StatusTooManyRequests {
- s.reconcileOpenAI429State(ctx, account, resp.Header, body)
+ if !s.handleCodexQuotaOverdraftTest429(ctx, account, resp.Header, body, upstreamTestModelID) {
+ s.reconcileOpenAI429State(ctx, account, resp.Header, body)
+ }
}
// 401 Unauthorized: 标记账号为永久错误
if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil {
@@ -786,9 +790,12 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
}
return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body)))
}
-
// Process SSE stream
- return s.processOpenAIStream(c, resp.Body)
+ if err := s.processOpenAIStream(c, resp.Body); err != nil {
+ return err
+ }
+ s.observeCodexQuotaOverdraftTestResult(account, upstreamTestModelID, overdraftInjected)
+ return nil
}
// testGrokAccountConnection routes Grok admin connectivity tests by explicit mode first,
@@ -2568,6 +2575,7 @@ func createOpenAITestPayload(modelID string, isOAuth bool) map[string]any {
"model": modelID,
"input": []map[string]any{
{
+ "type": "message",
"role": "user",
"content": []map[string]any{
{
diff --git a/backend/internal/service/account_test_service_openai_compact_test.go b/backend/internal/service/account_test_service_openai_compact_test.go
index 5b4772508439d89e8823c643f61f6f5b8ae67339..8d1f1d7529e42597288f75da3a819d6a40ae6924 100644
--- a/backend/internal/service/account_test_service_openai_compact_test.go
+++ b/backend/internal/service/account_test_service_openai_compact_test.go
@@ -50,6 +50,7 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactOAuthSuccessPersi
svc := &AccountTestService{
accountRepo: repo,
httpUpstream: upstream,
+ cfg: &config.Config{Gateway: config.GatewayConfig{CodexQuotaOverdraftEnabled: true}},
}
rec := httptest.NewRecorder()
diff --git a/backend/internal/service/account_test_service_openai_test.go b/backend/internal/service/account_test_service_openai_test.go
index 2cd63ebe3fa98ac99643ce3aa54965eff4d7c008..bc41a4362013036d8092147bd302cd93f1cd8c40 100644
--- a/backend/internal/service/account_test_service_openai_test.go
+++ b/backend/internal/service/account_test_service_openai_test.go
@@ -73,6 +73,42 @@ type openAIAccountTestRepo struct {
setErrorMsg string
}
+type accountTestOverdraftCoordinatorStub struct {
+ observeCalls int
+ businessCalls int
+ observedAccount *Account
+ observedModel string
+ handleCalls int
+ handledAccount *Account
+ handledModel string
+ handleResult bool
+}
+
+func (s *accountTestOverdraftCoordinatorStub) ObserveAccount(account *Account, preferredModel string) {
+ s.observeCalls++
+ s.observedAccount = account
+ s.observedModel = preferredModel
+}
+
+func (s *accountTestOverdraftCoordinatorStub) ObserveBusinessSuccess(account *Account, preferredModel string) {
+ s.businessCalls++
+ s.observedAccount = account
+ s.observedModel = preferredModel
+}
+
+func (s *accountTestOverdraftCoordinatorStub) HandleQuota429(
+ _ context.Context,
+ account *Account,
+ _ http.Header,
+ _ []byte,
+ preferredModel string,
+) bool {
+ s.handleCalls++
+ s.handledAccount = account
+ s.handledModel = preferredModel
+ return s.handleResult
+}
+
func (r *openAIAccountTestRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error {
r.updatedExtra = updates
return nil
@@ -137,6 +173,52 @@ func TestAccountTestService_OpenAISuccessPersistsSnapshotFromHeaders(t *testing.
require.Contains(t, recorder.Body.String(), "test_complete")
}
+func TestAccountTestService_OpenAIOAuthOverdraftTestInjectsAndObservesSnapshot(t *testing.T) {
+ ctx, _ := newTestContext()
+
+ resp := newJSONResponse(http.StatusOK, "")
+ resp.Body = io.NopCloser(strings.NewReader("data: {\"type\":\"response.completed\"}\n\n"))
+ resp.Header.Set("x-codex-secondary-used-percent", "100")
+ resp.Header.Set("x-codex-secondary-reset-after-seconds", "18000")
+ resp.Header.Set("x-codex-secondary-window-minutes", "300")
+
+ repo := &openAIAccountTestRepo{}
+ upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
+ coordinator := &accountTestOverdraftCoordinatorStub{}
+ svc := &AccountTestService{
+ accountRepo: repo,
+ httpUpstream: upstream,
+ cfg: &config.Config{Gateway: config.GatewayConfig{CodexQuotaOverdraftEnabled: true}},
+ codexQuotaOverdraft: coordinator,
+ }
+ account := &Account{
+ ID: 96,
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{"access_token": "test-token"},
+ Extra: map[string]any{
+ "codex_5h_used_percent": 95,
+ "codex_5h_reset_at": time.Now().Add(5 * time.Hour).Format(time.RFC3339),
+ },
+ }
+
+ err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
+ require.NoError(t, err)
+ require.Len(t, upstream.requests, 1)
+ body, err := io.ReadAll(upstream.requests[0].Body)
+ require.NoError(t, err)
+ require.Equal(t, "message", gjson.GetBytes(body, "input.0.type").String())
+ require.Equal(t, "custom_tool_call", gjson.GetBytes(body, "input.1.type").String())
+ require.Equal(t, "custom_tool_call_output", gjson.GetBytes(body, "input.2.type").String())
+ require.Equal(t, gjson.GetBytes(body, "input.1.call_id").String(), gjson.GetBytes(body, "input.2.call_id").String())
+ require.Zero(t, coordinator.observeCalls)
+ require.Equal(t, 1, coordinator.businessCalls)
+ require.Same(t, account, coordinator.observedAccount)
+ require.Equal(t, "gpt-5.4", coordinator.observedModel)
+ require.Equal(t, 100.0, account.Extra["codex_5h_used_percent"])
+}
+
func TestAccountTestService_OpenAIOAuthTestNormalizesGPT56Alias(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := newTestContext()
@@ -163,6 +245,8 @@ func TestAccountTestService_OpenAIOAuthTestNormalizesGPT56Alias(t *testing.T) {
body, err := io.ReadAll(upstream.requests[0].Body)
require.NoError(t, err)
require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(body, "model").String())
+ require.Equal(t, "message", gjson.GetBytes(body, "input.0.type").String())
+ require.False(t, gjson.GetBytes(body, "input.1").Exists())
}
func TestAccountTestService_OpenAIShadowUsesParentCredentialsAndShadowModel(t *testing.T) {
@@ -209,7 +293,13 @@ func TestAccountTestService_OpenAIShadowUsesParentCredentialsAndShadowModel(t *t
},
}
upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
- svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
+ coordinator := &accountTestOverdraftCoordinatorStub{}
+ svc := &AccountTestService{
+ accountRepo: repo,
+ httpUpstream: upstream,
+ cfg: &config.Config{Gateway: config.GatewayConfig{CodexQuotaOverdraftEnabled: true}},
+ codexQuotaOverdraft: coordinator,
+ }
err := svc.TestAccountConnection(ctx, shadow.ID, "gpt-5.3-codex-spark", "", "")
require.NoError(t, err)
@@ -220,6 +310,9 @@ func TestAccountTestService_OpenAIShadowUsesParentCredentialsAndShadowModel(t *t
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
require.Equal(t, "gpt-5.3-codex-spark", gjson.GetBytes(body, "model").String())
+ require.False(t, gjson.GetBytes(body, "input.1").Exists())
+ require.Zero(t, coordinator.observeCalls)
+ require.Zero(t, coordinator.handleCalls)
require.Contains(t, recorder.Body.String(), `"success":true`)
}
@@ -284,6 +377,72 @@ func TestAccountTestService_OpenAI429PersistsSnapshotAndRateLimitState(t *testin
require.NotNil(t, account.RateLimitResetAt)
}
+func TestAccountTestService_OpenAIQuota429UsesOverdraftCoordinatorWithoutRateLimitWrite(t *testing.T) {
+ ctx, _ := newTestContext()
+
+ resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached","resets_at":1777283883}}`)
+ resp.Header.Set("x-codex-secondary-used-percent", "100")
+ resp.Header.Set("x-codex-secondary-reset-after-seconds", "18000")
+ resp.Header.Set("x-codex-secondary-window-minutes", "300")
+
+ repo := &openAIAccountTestRepo{}
+ upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
+ coordinator := &accountTestOverdraftCoordinatorStub{handleResult: true}
+ svc := &AccountTestService{
+ accountRepo: repo,
+ httpUpstream: upstream,
+ cfg: &config.Config{Gateway: config.GatewayConfig{CodexQuotaOverdraftEnabled: true}},
+ codexQuotaOverdraft: coordinator,
+ }
+ account := &Account{
+ ID: 97,
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Status: StatusActive,
+ Concurrency: 1,
+ Credentials: map[string]any{"access_token": "test-token"},
+ }
+
+ err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
+ require.Error(t, err)
+ require.Equal(t, 1, coordinator.handleCalls)
+ require.Same(t, account, coordinator.handledAccount)
+ require.Equal(t, "gpt-5.4", coordinator.handledModel)
+ require.Zero(t, repo.rateLimitedID)
+ require.Nil(t, repo.rateLimitedAt)
+ require.Nil(t, account.RateLimitResetAt)
+}
+
+func TestAccountTestService_OpenAI429FallsBackWhenOverdraftDoesNotHandle(t *testing.T) {
+ ctx, _ := newTestContext()
+
+ resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"rate_limit_exceeded","message":"too many requests","resets_in_seconds":60}}`)
+ repo := &openAIAccountTestRepo{}
+ upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
+ coordinator := &accountTestOverdraftCoordinatorStub{}
+ svc := &AccountTestService{
+ accountRepo: repo,
+ httpUpstream: upstream,
+ cfg: &config.Config{Gateway: config.GatewayConfig{CodexQuotaOverdraftEnabled: true}},
+ codexQuotaOverdraft: coordinator,
+ }
+ account := &Account{
+ ID: 98,
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Status: StatusActive,
+ Concurrency: 1,
+ Credentials: map[string]any{"access_token": "test-token"},
+ }
+
+ err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
+ require.Error(t, err)
+ require.Equal(t, 1, coordinator.handleCalls)
+ require.Equal(t, account.ID, repo.rateLimitedID)
+ require.NotNil(t, repo.rateLimitedAt)
+ require.NotNil(t, account.RateLimitResetAt)
+}
+
func TestAccountTestService_OpenAI429BodyOnlyPersistsRateLimitAndClearsStaleError(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := newTestContext()
@@ -432,8 +591,9 @@ func TestAccountTestService_OpenAIAPIKeyResponsesUsesCodexProbeHeaders(t *testin
resp.Body = io.NopCloser(strings.NewReader("data: {\"type\":\"response.completed\"}\n\n"))
upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
svc := &AccountTestService{
- httpUpstream: upstream,
- cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ httpUpstream: upstream,
+ cfg: &config.Config{Gateway: config.GatewayConfig{CodexQuotaOverdraftEnabled: true}, Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ codexQuotaOverdraft: &accountTestOverdraftCoordinatorStub{},
}
account := &Account{
ID: 95,
@@ -453,6 +613,10 @@ func TestAccountTestService_OpenAIAPIKeyResponsesUsesCodexProbeHeaders(t *testin
req := upstream.requests[0]
require.Equal(t, "https://compat-upstream.example/v1/responses", req.URL.String())
requireOpenAICodexProbeHeaders(t, req.Header)
+ body, err := io.ReadAll(req.Body)
+ require.NoError(t, err)
+ require.Equal(t, "message", gjson.GetBytes(body, "input.0.type").String())
+ require.False(t, gjson.GetBytes(body, "input.1").Exists())
}
func TestAccountTestService_OpenAIAPIKeyResponsesUnsupportedUsesChatCompletionsPath(t *testing.T) {
diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go
index 084d153ff2c03cce8d245be000e6b415280188b7..b489cdb84e00eb13cec909662f588ae1f533b17d 100644
--- a/backend/internal/service/account_usage_service.go
+++ b/backend/internal/service/account_usage_service.go
@@ -153,6 +153,10 @@ type UsageProgress struct {
WindowStats *WindowStats `json:"window_stats,omitempty"` // 窗口期统计(从窗口开始到当前的使用量)
UsedRequests int64 `json:"used_requests,omitempty"`
LimitRequests int64 `json:"limit_requests,omitempty"`
+ OverdraftActive bool `json:"overdraft_active,omitempty"`
+ OverdraftStats *WindowStats `json:"overdraft_stats,omitempty"`
+ OverdraftStarted *time.Time `json:"overdraft_started_at,omitempty"`
+ OverdraftRecover *time.Time `json:"overdraft_recover_at,omitempty"`
}
// AntigravityModelQuota Antigravity 单个模型的配额信息
@@ -182,18 +186,19 @@ type AICredit struct {
// UsageInfo 账号使用量信息
type UsageInfo struct {
- Source string `json:"source,omitempty"` // "passive" or "active"
- UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间
- FiveHour *UsageProgress `json:"five_hour"` // 5小时窗口
- SevenDay *UsageProgress `json:"seven_day,omitempty"` // 7天窗口
- SevenDaySonnet *UsageProgress `json:"seven_day_sonnet,omitempty"` // 7天Sonnet窗口
- SevenDayFable *UsageProgress `json:"seven_day_fable,omitempty"` // 7天Fable窗口(响应头 7d_oi)
- GeminiSharedDaily *UsageProgress `json:"gemini_shared_daily,omitempty"` // Gemini shared pool RPD (Google One / Code Assist)
- GeminiProDaily *UsageProgress `json:"gemini_pro_daily,omitempty"` // Gemini Pro 日配额
- GeminiFlashDaily *UsageProgress `json:"gemini_flash_daily,omitempty"` // Gemini Flash 日配额
- GeminiSharedMinute *UsageProgress `json:"gemini_shared_minute,omitempty"` // Gemini shared pool RPM (Google One / Code Assist)
- GeminiProMinute *UsageProgress `json:"gemini_pro_minute,omitempty"` // Gemini Pro RPM
- GeminiFlashMinute *UsageProgress `json:"gemini_flash_minute,omitempty"` // Gemini Flash RPM
+ Source string `json:"source,omitempty"` // "passive" or "active"
+ UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间
+ FiveHour *UsageProgress `json:"five_hour"` // 5小时窗口
+ SevenDay *UsageProgress `json:"seven_day,omitempty"` // 7天窗口
+ SevenDaySonnet *UsageProgress `json:"seven_day_sonnet,omitempty"` // 7天Sonnet窗口
+ SevenDayFable *UsageProgress `json:"seven_day_fable,omitempty"` // 7天Fable窗口(响应头 7d_oi)
+ CodexQuotaOverdraft *CodexQuotaOverdraftProbeState `json:"codex_quota_overdraft,omitempty"`
+ GeminiSharedDaily *UsageProgress `json:"gemini_shared_daily,omitempty"` // Gemini shared pool RPD (Google One / Code Assist)
+ GeminiProDaily *UsageProgress `json:"gemini_pro_daily,omitempty"` // Gemini Pro 日配额
+ GeminiFlashDaily *UsageProgress `json:"gemini_flash_daily,omitempty"` // Gemini Flash 日配额
+ GeminiSharedMinute *UsageProgress `json:"gemini_shared_minute,omitempty"` // Gemini shared pool RPM (Google One / Code Assist)
+ GeminiProMinute *UsageProgress `json:"gemini_pro_minute,omitempty"` // Gemini Pro RPM
+ GeminiFlashMinute *UsageProgress `json:"gemini_flash_minute,omitempty"` // Gemini Flash RPM
// Antigravity 多模型配额
AntigravityQuota map[string]*AntigravityModelQuota `json:"antigravity_quota,omitempty"`
@@ -302,6 +307,7 @@ type AccountUsageService struct {
cache *UsageCache
identityCache IdentityCache
tlsFPProfileService *TLSFingerprintProfileService
+ codexQuotaOverdraft *CodexQuotaOverdraftCoordinator
agentIdentityTaskMu sync.Mutex
agentIdentityWS agentIdentityWSConnectionInvalidator
}
@@ -711,6 +717,7 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou
}
applyExtraToUsage(usage, account.Extra, now)
+ usage.CodexQuotaOverdraft, _ = codexQuotaOverdraftStateFromAccount(account)
if (force || shouldRefreshOpenAICodexSnapshot(account, usage, now)) && s.shouldProbeOpenAICodexSnapshot(account.ID, now, force) {
if account.IsShadow() {
@@ -741,6 +748,10 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou
}
}
+ if s.codexQuotaOverdraft != nil {
+ s.codexQuotaOverdraft.ObserveAccount(account, "")
+ }
+ usage.CodexQuotaOverdraft, _ = codexQuotaOverdraftStateFromAccount(account)
if s.usageLogRepo == nil {
return usage, nil
}
@@ -759,6 +770,8 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou
usage.SevenDay.WindowStats = windowStatsFromAccountStats(stats)
}
+ applyCodexQuotaOverdraftUsage(ctx, s.usageLogRepo, account, usage, now)
+
return usage, nil
}
diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go
index ace8366a0eb0911f4854c027767f749e15646e5a..f5aa41b27ff248636acc6b552a2df600ad8b393a 100644
--- a/backend/internal/service/openai_account_runtime_block_fastpath.go
+++ b/backend/internal/service/openai_account_runtime_block_fastpath.go
@@ -73,6 +73,9 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont
return false
}
stateCtx = withTempUnschedulableModel(stateCtx, canonicalModel)
+ if s.handleCodexQuotaOverdraftUpstream429(stateCtx, account, statusCode, headers, responseBody, canonicalModel) {
+ return false
+ }
if s.rateLimitService != nil && len(canonicalModel) > 0 && s.rateLimitService.HandleUpstreamModelNotFound(stateCtx, account, canonicalModel[0], statusCode, responseBody) {
return true
}
diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go
index 6db0db766d1c2e08be411b716437c8eb1a092a3b..239e7854f4fae524bfeb4c483701be4040b02ce8 100644
--- a/backend/internal/service/openai_account_scheduler.go
+++ b/backend/internal/service/openai_account_scheduler.go
@@ -1395,7 +1395,7 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance(
filtered := make([]*Account, 0, len(accounts))
loadReq := make([]AccountWithConcurrency, 0, len(accounts))
for i := range accounts {
- account := &accounts[i]
+ account := normalizeCodexQuotaOverdraftAccountForScheduling(ctx, &accounts[i])
if req.ExcludedIDs != nil {
if _, excluded := req.ExcludedIDs[account.ID]; excluded {
filterStats.exclude("excluded")
@@ -2299,9 +2299,10 @@ func (s *OpenAIGatewayService) isOpenAIAccountTransportCompatible(account *Accou
return s.getOpenAIWSProtocolResolver().Resolve(account).Transport == requiredTransport
}
-func (s *OpenAIGatewayService) ReportOpenAIAccountScheduleResult(accountID int64, model string, success bool, firstTokenMs *int) {
+func (s *OpenAIGatewayService) ReportOpenAIAccountScheduleResult(accountID int64, model string, success bool, firstTokenMs *int, requestCtx ...context.Context) {
if success {
s.clearOpenAIAccountModelTransientState(accountID, normalizeOpenAIAccountModelTransientModel(model))
+ s.observeCodexQuotaOverdraftScheduleSuccess(accountID, model, requestCtx)
}
scheduler := s.getOpenAIAccountScheduler(context.Background())
if scheduler == nil {
diff --git a/backend/internal/service/openai_codex_quota_overdraft.go b/backend/internal/service/openai_codex_quota_overdraft.go
new file mode 100644
index 0000000000000000000000000000000000000000..6274818b94a771f6f604c7c8f19593f0eb5114a7
--- /dev/null
+++ b/backend/internal/service/openai_codex_quota_overdraft.go
@@ -0,0 +1,295 @@
+package service
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/tidwall/sjson"
+)
+
+const (
+ codexQuotaOverdraftCallIDPrefix = "call_sub2api_overdraft_"
+ codexQuotaOverdraftExecInput = `const r = await tools.exec_command({"cmd":"true","yield_time_ms":1000,"max_output_tokens":1000}); text(r.output);`
+ codexQuotaOverdraftMaxBodyBytes = 32 << 20
+ codexQuotaOverdraftPrearmPercent = 95
+)
+
+var codexQuotaOverdraftEnabled atomic.Bool
+
+// SetCodexQuotaOverdraftEnabled publishes the process-wide scheduling switch.
+// Request mutation still reads the gateway instance config directly.
+func SetCodexQuotaOverdraftEnabled(enabled bool) {
+ codexQuotaOverdraftEnabled.Store(enabled)
+}
+
+// CodexQuotaOverdraftEnabled is exported for repository scheduling predicates.
+func CodexQuotaOverdraftEnabled() bool {
+ return codexQuotaOverdraftEnabled.Load()
+}
+
+func isCodexQuotaOverdraftAccount(account *Account) bool {
+ return account != nil &&
+ account.Platform == PlatformOpenAI &&
+ account.Type == AccountTypeOAuth &&
+ !account.IsShadow()
+}
+
+type codexQuotaOverdraftSchedulingCtxKey struct{}
+
+type codexQuotaOverdraftRequestState struct {
+ injectedAccounts sync.Map
+}
+
+// WithCodexQuotaOverdraftScheduling marks normal text-generation requests as
+// eligible for the experimental quota-overdraft behavior. The process-wide
+// configuration switch is still checked at every scheduling and mutation gate.
+func WithCodexQuotaOverdraftScheduling(ctx context.Context) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if codexQuotaOverdraftRequestStateFromContext(ctx) != nil {
+ return ctx
+ }
+ return context.WithValue(ctx, codexQuotaOverdraftSchedulingCtxKey{}, &codexQuotaOverdraftRequestState{})
+}
+
+// CodexQuotaOverdraftSchedulingEnabled reports whether the global switch and
+// the request-scoped endpoint marker are both enabled.
+func CodexQuotaOverdraftSchedulingEnabled(ctx context.Context) bool {
+ if !CodexQuotaOverdraftEnabled() || ctx == nil {
+ return false
+ }
+ return codexQuotaOverdraftRequestStateFromContext(ctx) != nil
+}
+
+func codexQuotaOverdraftSchedulingEnabled(ctx context.Context) bool {
+ return CodexQuotaOverdraftSchedulingEnabled(ctx)
+}
+
+func codexQuotaOverdraftRequestStateFromContext(ctx context.Context) *codexQuotaOverdraftRequestState {
+ if ctx == nil {
+ return nil
+ }
+ state, _ := ctx.Value(codexQuotaOverdraftSchedulingCtxKey{}).(*codexQuotaOverdraftRequestState)
+ return state
+}
+
+func markCodexQuotaOverdraftInjected(ctx context.Context, accountID int64) {
+ if accountID <= 0 {
+ return
+ }
+ if state := codexQuotaOverdraftRequestStateFromContext(ctx); state != nil {
+ state.injectedAccounts.Store(accountID, struct{}{})
+ }
+}
+
+func codexQuotaOverdraftWasInjected(ctx context.Context, accountID int64) bool {
+ if accountID <= 0 {
+ return false
+ }
+ state := codexQuotaOverdraftRequestStateFromContext(ctx)
+ if state == nil {
+ return false
+ }
+ _, ok := state.injectedAccounts.Load(accountID)
+ return ok
+}
+
+func codexQuotaOverdraftInjectionEligible(account *Account, now time.Time) bool {
+ if !isCodexQuotaOverdraftAccount(account) {
+ return false
+ }
+ state, _ := codexQuotaOverdraftStateFromAccount(account)
+ if state != nil && state.RecoverAt != nil && state.RecoverAt.After(now) {
+ switch state.Status {
+ case codexQuotaOverdraftProbePending, codexQuotaOverdraftProbePassed, codexQuotaOverdraftProbeInconclusive:
+ return true
+ case codexQuotaOverdraftProbeFailed:
+ return false
+ }
+ }
+ windowEligible := func(usedKey, resetKey string) bool {
+ if parseExtraFloat64(account.Extra[usedKey]) < codexQuotaOverdraftPrearmPercent {
+ return false
+ }
+ resetAt := codexQuotaOverdraftResetAt(account.Extra[resetKey], now)
+ return resetAt == nil || resetAt.After(now)
+ }
+ return windowEligible("codex_5h_used_percent", "codex_5h_reset_at") ||
+ windowEligible("codex_7d_used_percent", "codex_7d_reset_at")
+}
+
+func (s *OpenAIGatewayService) shouldInjectCodexQuotaOverdraft(ctx context.Context, account *Account, compact bool) bool {
+ return codexQuotaOverdraftSchedulingEnabled(ctx) && !compact &&
+ s != nil && s.cfg != nil && s.cfg.Gateway.CodexQuotaOverdraftEnabled &&
+ codexQuotaOverdraftInjectionEligible(account, time.Now().UTC())
+}
+
+func (s *OpenAIGatewayService) prepareCodexQuotaOverdraftBody(ctx context.Context, account *Account, compact bool, body []byte) []byte {
+ if !s.shouldInjectCodexQuotaOverdraft(ctx, account, compact) {
+ return body
+ }
+ updated, changed, _ := injectCodexQuotaOverdraft(body)
+ if changed {
+ markCodexQuotaOverdraftInjected(ctx, account.ID)
+ return updated
+ }
+ if codexQuotaOverdraftBodyHasInjection(body) {
+ markCodexQuotaOverdraftInjected(ctx, account.ID)
+ }
+ return body
+}
+
+func (s *OpenAIGatewayService) prepareCodexQuotaOverdraftPayload(ctx context.Context, account *Account, payload map[string]any) map[string]any {
+ if !s.shouldInjectCodexQuotaOverdraft(ctx, account, false) || payload == nil {
+ return payload
+ }
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return payload
+ }
+ updated, changed, _ := injectCodexQuotaOverdraft(raw)
+ if changed {
+ markCodexQuotaOverdraftInjected(ctx, account.ID)
+ } else if codexQuotaOverdraftBodyHasInjection(raw) {
+ markCodexQuotaOverdraftInjected(ctx, account.ID)
+ }
+ if !changed {
+ return payload
+ }
+ var out map[string]any
+ if err := json.Unmarshal(updated, &out); err != nil {
+ return payload
+ }
+ return out
+}
+
+type codexQuotaOverdraftDocument struct {
+ Input []json.RawMessage `json:"input"`
+}
+
+type codexQuotaOverdraftInputItem struct {
+ Type string `json:"type"`
+ Role string `json:"role"`
+ CallID string `json:"call_id"`
+}
+
+func codexQuotaOverdraftBodyHasInjection(body []byte) bool {
+ var document codexQuotaOverdraftDocument
+ if len(body) == 0 || json.Unmarshal(body, &document) != nil {
+ return false
+ }
+ return codexQuotaOverdraftInputHasInjection(document.Input)
+}
+
+func codexQuotaOverdraftInputHasInjection(input []json.RawMessage) bool {
+ for _, raw := range input {
+ var item codexQuotaOverdraftInputItem
+ if err := json.Unmarshal(raw, &item); err == nil &&
+ item.Type == "custom_tool_call" &&
+ strings.HasPrefix(item.CallID, codexQuotaOverdraftCallIDPrefix) {
+ return true
+ }
+ }
+ return false
+}
+
+// injectCodexQuotaOverdraft appends the same no-op custom tool call pair used by
+// cpa-account-config-manager. Unsupported request shapes fail open unchanged.
+func injectCodexQuotaOverdraft(body []byte) ([]byte, bool, error) {
+ if len(body) == 0 || len(body) > codexQuotaOverdraftMaxBodyBytes {
+ return body, false, nil
+ }
+
+ var document codexQuotaOverdraftDocument
+ if err := json.Unmarshal(body, &document); err != nil {
+ return body, false, nil
+ }
+ if len(document.Input) == 0 {
+ return body, false, nil
+ }
+
+ if codexQuotaOverdraftInputHasInjection(document.Input) {
+ return body, false, nil
+ }
+
+ var last codexQuotaOverdraftInputItem
+ if err := json.Unmarshal(document.Input[len(document.Input)-1], &last); err != nil || last.Type != "message" || last.Role != "user" {
+ return body, false, nil
+ }
+
+ callID, ok := newCodexQuotaOverdraftCallID()
+ if !ok {
+ return body, false, nil
+ }
+ call, err := json.Marshal(map[string]any{
+ "type": "custom_tool_call",
+ "name": "exec",
+ "call_id": callID,
+ "input": codexQuotaOverdraftExecInput,
+ })
+ if err != nil {
+ return body, false, nil
+ }
+ output, err := json.Marshal(map[string]any{
+ "type": "custom_tool_call_output",
+ "call_id": callID,
+ "output": []map[string]string{{
+ "type": "input_text",
+ "text": "Script completed\nWall time 0.0 seconds\nOutput:\n",
+ }},
+ })
+ if err != nil {
+ return body, false, nil
+ }
+
+ document.Input = append(document.Input, call, output)
+ updatedInput, err := json.Marshal(document.Input)
+ if err != nil {
+ return body, false, nil
+ }
+ updated, err := sjson.SetRawBytes(body, "input", updatedInput)
+ if err != nil {
+ return body, false, nil
+ }
+ if len(updated) > codexQuotaOverdraftMaxBodyBytes {
+ return body, false, nil
+ }
+ return updated, true, nil
+}
+
+func normalizeCodexQuotaOverdraftAccountForScheduling(ctx context.Context, account *Account) *Account {
+ if !codexQuotaOverdraftSchedulingEnabled(ctx) || !isCodexQuotaOverdraftAccount(account) ||
+ !codexQuotaOverdraftSchedulingAllowed(account, time.Now().UTC()) ||
+ account.TempUnschedulableUntil == nil || !time.Now().Before(*account.TempUnschedulableUntil) ||
+ !IsAccountSchedulingThresholdReason(account.TempUnschedulableReason) {
+ return account
+ }
+ clone := *account
+ clone.TempUnschedulableUntil = nil
+ clone.TempUnschedulableReason = ""
+ return &clone
+}
+
+func normalizeCodexQuotaOverdraftAccountsForScheduling(ctx context.Context, accounts []Account) []Account {
+ for i := range accounts {
+ if normalized := normalizeCodexQuotaOverdraftAccountForScheduling(ctx, &accounts[i]); normalized != &accounts[i] {
+ accounts[i] = *normalized
+ }
+ }
+ return accounts
+}
+
+func newCodexQuotaOverdraftCallID() (string, bool) {
+ var random [12]byte
+ if _, err := rand.Read(random[:]); err != nil {
+ return "", false
+ }
+ return codexQuotaOverdraftCallIDPrefix + hex.EncodeToString(random[:]), true
+}
diff --git a/backend/internal/service/openai_codex_quota_overdraft_hooks.go b/backend/internal/service/openai_codex_quota_overdraft_hooks.go
new file mode 100644
index 0000000000000000000000000000000000000000..40fbd181ce5c575ec41824b98268452e215650d2
--- /dev/null
+++ b/backend/internal/service/openai_codex_quota_overdraft_hooks.go
@@ -0,0 +1,122 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+)
+
+func codexQuotaOverdraftBypassesSchedulingThreshold(ctx context.Context, account *Account) bool {
+ return codexQuotaOverdraftSchedulingEnabled(ctx) && isCodexQuotaOverdraftAccount(account) &&
+ codexQuotaOverdraftSchedulingAllowed(account, time.Now().UTC())
+}
+
+func (s *RateLimitService) notifyCodexQuotaOverdraftAwareSchedulingBlock(
+ account *Account,
+ until time.Time,
+) {
+ if !CodexQuotaOverdraftEnabled() || !isCodexQuotaOverdraftAccount(account) {
+ s.notifyAccountSchedulingBlocked(account, until, "account_scheduling_threshold")
+ }
+}
+
+func (s *OpenAIGatewayService) listCodexQuotaOverdraftSchedulableAccounts(
+ ctx context.Context,
+ groupID *int64,
+ platform string,
+) ([]Account, bool, error) {
+ if !CodexQuotaOverdraftSchedulingEnabled(ctx) || platform != PlatformOpenAI || s.accountRepo == nil {
+ return nil, false, nil
+ }
+ var accounts []Account
+ var err error
+ if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
+ accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
+ } else if groupID != nil {
+ accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, platform)
+ } else {
+ accounts, err = s.accountRepo.ListSchedulableUngroupedByPlatform(ctx, platform)
+ }
+ if err != nil {
+ return nil, true, fmt.Errorf("query overdraft accounts failed: %w", err)
+ }
+ accounts = normalizeCodexQuotaOverdraftAccountsForScheduling(ctx, accounts)
+ return s.filterOpenAIAccountsBySchedulingThreshold(ctx, accounts), true, nil
+}
+
+func (s *OpenAIGatewayService) handleCodexQuotaOverdraftUpstream429(
+ ctx context.Context,
+ account *Account,
+ statusCode int,
+ headers http.Header,
+ responseBody []byte,
+ canonicalModel []string,
+) bool {
+ if statusCode != http.StatusTooManyRequests || s.codexQuotaOverdraft == nil {
+ return false
+ }
+ preferredModel := ""
+ if len(canonicalModel) > 0 {
+ preferredModel = canonicalModel[0]
+ }
+ return s.codexQuotaOverdraft.HandleQuota429(ctx, account, headers, responseBody, preferredModel)
+}
+
+func (s *OpenAIGatewayService) processCodexQuotaOverdraftUsageSnapshot(
+ ctx context.Context,
+ accountID int64,
+ now time.Time,
+ updates map[string]any,
+) {
+ persistSnapshot := codexQuotaOverdraftSnapshotPrearmReached(updates) || s.getCodexSnapshotThrottle().Allow(accountID, now)
+ businessSuccess := codexQuotaOverdraftWasInjected(ctx, accountID)
+
+ go func() {
+ updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ var account *Account
+ if !persistSnapshot && s.codexQuotaOverdraft != nil {
+ current, err := s.accountRepo.GetByID(updateCtx, accountID)
+ if err == nil && current != nil {
+ account = current
+ state, hasState := codexQuotaOverdraftStateFromAccount(current)
+ _, wasExhausted := codexQuotaOverdraftSignalFromAccount(current, state, now)
+ persistSnapshot = wasExhausted || hasState && state.Status != codexQuotaOverdraftProbeRecovered
+ }
+ }
+ if persistSnapshot {
+ if err := s.accountRepo.UpdateExtra(updateCtx, accountID, updates); err != nil {
+ return
+ }
+ }
+ if s.codexQuotaOverdraft == nil {
+ return
+ }
+ if account == nil {
+ current, err := s.accountRepo.GetByID(updateCtx, accountID)
+ if err != nil || current == nil {
+ return
+ }
+ account = current
+ }
+ mergeAccountExtra(account, updates)
+ if businessSuccess {
+ s.codexQuotaOverdraft.observeBusinessSuccess(account, "")
+ } else {
+ s.codexQuotaOverdraft.observeAccount(account, "")
+ }
+ }()
+}
+
+func (s *OpenAIGatewayService) observeCodexQuotaOverdraftScheduleSuccess(
+ accountID int64,
+ model string,
+ requestCtx []context.Context,
+) {
+ if len(requestCtx) > 0 && s.codexQuotaOverdraft != nil && codexQuotaOverdraftWasInjected(requestCtx[0], accountID) {
+ s.codexQuotaOverdraft.ObserveBusinessSuccessByID(accountID, model)
+ }
+}
diff --git a/backend/internal/service/openai_codex_quota_overdraft_integration.go b/backend/internal/service/openai_codex_quota_overdraft_integration.go
new file mode 100644
index 0000000000000000000000000000000000000000..36388781aebdd4a23521048cec58cd642c2dfc60
--- /dev/null
+++ b/backend/internal/service/openai_codex_quota_overdraft_integration.go
@@ -0,0 +1,44 @@
+package service
+
+func (s *OpenAIGatewayService) SetCodexQuotaOverdraftCoordinator(coordinator *CodexQuotaOverdraftCoordinator) {
+ if s != nil {
+ s.codexQuotaOverdraft = coordinator
+ }
+}
+
+func (s *AccountUsageService) SetCodexQuotaOverdraftCoordinator(coordinator *CodexQuotaOverdraftCoordinator) {
+ if s != nil {
+ s.codexQuotaOverdraft = coordinator
+ }
+}
+
+// codexQuotaOverdraftCoordinator returns the single coordinator owned by the
+// OpenAI gateway. Keeping construction here avoids adding a fork-only provider
+// to the generated Wire graph.
+func (s *OpenAIGatewayService) codexQuotaOverdraftCoordinator(
+ tlsFPProfileService *TLSFingerprintProfileService,
+) *CodexQuotaOverdraftCoordinator {
+ if s == nil {
+ return nil
+ }
+ s.codexQuotaOverdraftOnce.Do(func() {
+ if s.codexQuotaOverdraft != nil {
+ return
+ }
+ var tempUnschedCache TempUnschedCache
+ if s.rateLimitService != nil {
+ tempUnschedCache = s.rateLimitService.tempUnschedCache
+ }
+ s.codexQuotaOverdraft = NewCodexQuotaOverdraftCoordinator(
+ s.accountRepo,
+ s.httpUpstream,
+ s.openAITokenProvider,
+ tlsFPProfileService,
+ s.cfg,
+ tempUnschedCache,
+ s,
+ s.rateLimitService,
+ )
+ })
+ return s.codexQuotaOverdraft
+}
diff --git a/backend/internal/service/openai_codex_quota_overdraft_probe.go b/backend/internal/service/openai_codex_quota_overdraft_probe.go
new file mode 100644
index 0000000000000000000000000000000000000000..92cc225e63d45aab93bba10c98d9d8e51c34d2d2
--- /dev/null
+++ b/backend/internal/service/openai_codex_quota_overdraft_probe.go
@@ -0,0 +1,1356 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
+)
+
+const (
+ CodexQuotaOverdraftProbeExtraKey = "codex_quota_overdraft_probe"
+
+ codexQuotaOverdraftProbePending = "pending"
+ codexQuotaOverdraftProbePassed = "passed"
+ codexQuotaOverdraftProbeFailed = "failed"
+ codexQuotaOverdraftProbeInconclusive = "inconclusive"
+ codexQuotaOverdraftProbeRecovered = "recovered"
+
+ codexQuotaOverdraftProbeAttemptLimit = 1
+ codexQuotaOverdraftProbeAttemptTimeout = 20 * time.Second
+ codexQuotaOverdraftProbePlanTimeout = codexQuotaOverdraftProbeAttemptLimit * codexQuotaOverdraftProbeAttemptTimeout
+ codexQuotaOverdraftProbeBodyLimit = 256 << 10
+ codexQuotaOverdraftPauseSource = "codex_quota_overdraft"
+ codexQuotaOverdraftPersistAttempts = 3
+
+ codexQuotaOverdraftFallbackModel = "gpt-5.5"
+ codexQuotaOverdraftCompatibilityModel = "gpt-5.4-mini"
+)
+
+// CodexQuotaOverdraftProbeState is persisted in accounts.extra so quota-cycle
+// decisions survive restarts and are visible through the account usage API.
+type CodexQuotaOverdraftProbeState struct {
+ Status string `json:"status"`
+ QuotaWindow string `json:"quota_window"`
+ CycleKey string `json:"cycle_key"`
+ Attempts int `json:"attempts"`
+ Limit int `json:"limit"`
+ Model string `json:"model,omitempty"`
+ ReasonCode string `json:"reason_code,omitempty"`
+ 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"`
+ OverdraftStartedAt *time.Time `json:"overdraft_started_at,omitempty"`
+ FiveHourStartedAt *time.Time `json:"five_hour_overdraft_started_at,omitempty"`
+ SevenDayStartedAt *time.Time `json:"seven_day_overdraft_started_at,omitempty"`
+ ObservedRateLimit *time.Time `json:"observed_rate_limit_reset_at,omitempty"`
+}
+
+type codexQuotaOverdraftSignal struct {
+ Window string
+ CycleKey string
+ RecoverAt time.Time
+ FiveHourRecoverAt *time.Time
+ SevenDayRecoverAt *time.Time
+}
+
+type codexQuotaOverdraftProbeResult struct {
+ Status string
+ ReasonCode string
+ StatusCode int
+ Model string
+ Headers http.Header
+ Body []byte
+}
+
+type codexQuotaOverdraftProbeClaimer interface {
+ ClaimCodexQuotaOverdraftProbe(context.Context, int64, *CodexQuotaOverdraftProbeState) (bool, error)
+}
+
+type codexQuotaOverdraftFailedFinalizer interface {
+ FinalizeCodexQuotaOverdraftProbeFailed(context.Context, int64, *CodexQuotaOverdraftProbeState, time.Time, string) (bool, error)
+}
+
+type codexQuotaOverdraftNonFailedPersister interface {
+ PersistCodexQuotaOverdraftProbeUnlessFailed(context.Context, int64, *CodexQuotaOverdraftProbeState) (bool, error)
+}
+
+type codexQuotaOverdraftPauseClearer interface {
+ ClearCodexQuotaOverdraftPauseIfState(context.Context, int64, string, string, string) (bool, bool, error)
+}
+
+// CodexQuotaOverdraftCoordinator implements the bounded real-request gate used
+// by cpa-account-config-manager before it disables a quota-exhausted account.
+type CodexQuotaOverdraftCoordinator struct {
+ accountRepo AccountRepository
+ httpUpstream HTTPUpstream
+ openAITokenProvider *OpenAITokenProvider
+ tlsFPProfileService *TLSFingerprintProfileService
+ cfg *config.Config
+ tempUnschedCache TempUnschedCache
+ runtimeBlocker AccountRuntimeBlocker
+ rateLimitService *RateLimitService
+ agentIdentityWS agentIdentityWSConnectionInvalidator
+ agentIdentityTaskMu sync.Mutex
+ running sync.Map
+ now func() time.Time
+ probeAttemptForTest func(context.Context, *Account, string) codexQuotaOverdraftProbeResult
+}
+
+func NewCodexQuotaOverdraftCoordinator(
+ accountRepo AccountRepository,
+ httpUpstream HTTPUpstream,
+ openAITokenProvider *OpenAITokenProvider,
+ tlsFPProfileService *TLSFingerprintProfileService,
+ cfg *config.Config,
+ tempUnschedCache TempUnschedCache,
+ runtimeBlocker AccountRuntimeBlocker,
+ rateLimitService *RateLimitService,
+) *CodexQuotaOverdraftCoordinator {
+ coordinator := &CodexQuotaOverdraftCoordinator{
+ accountRepo: accountRepo,
+ httpUpstream: httpUpstream,
+ openAITokenProvider: openAITokenProvider,
+ tlsFPProfileService: tlsFPProfileService,
+ cfg: cfg,
+ tempUnschedCache: tempUnschedCache,
+ runtimeBlocker: runtimeBlocker,
+ rateLimitService: rateLimitService,
+ now: time.Now,
+ }
+ coordinator.agentIdentityWS, _ = runtimeBlocker.(agentIdentityWSConnectionInvalidator)
+ return coordinator
+}
+func (c *CodexQuotaOverdraftCoordinator) enabled() bool {
+ return c != nil && c.cfg != nil && c.cfg.Gateway.CodexQuotaOverdraftEnabled &&
+ c.accountRepo != nil && c.httpUpstream != nil
+}
+
+// ObserveAccount starts a probe when a persisted 5h/7d snapshot first reaches
+// 100%, and closes an old overdraft cycle after the quota window recovers.
+func (c *CodexQuotaOverdraftCoordinator) ObserveAccount(account *Account, preferredModel string) {
+ if !c.enabled() || !isCodexQuotaOverdraftAccount(account) || account.ID <= 0 {
+ return
+ }
+ accountCopy := cloneCodexQuotaOverdraftAccount(account)
+ go c.observeAccount(accountCopy, preferredModel)
+}
+
+// ObserveBusinessSuccess treats a successful request that actually carried the
+// overdraft payload as stronger evidence than a synthetic probe.
+func (c *CodexQuotaOverdraftCoordinator) ObserveBusinessSuccess(account *Account, preferredModel string) {
+ if !c.enabled() || !isCodexQuotaOverdraftAccount(account) || account.ID <= 0 {
+ return
+ }
+ accountCopy := cloneCodexQuotaOverdraftAccount(account)
+ go c.observeBusinessSuccess(accountCopy, preferredModel)
+}
+
+func (c *CodexQuotaOverdraftCoordinator) ObserveBusinessSuccessByID(accountID int64, preferredModel string) {
+ if !c.enabled() || accountID <= 0 {
+ return
+ }
+ go func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ account, err := c.accountRepo.GetByID(ctx, accountID)
+ cancel()
+ if err != nil || !isCodexQuotaOverdraftAccount(account) {
+ return
+ }
+ c.observeBusinessSuccess(account, preferredModel)
+ }()
+}
+
+func (c *CodexQuotaOverdraftCoordinator) observeBusinessSuccess(account *Account, preferredModel string) {
+ if account == nil {
+ return
+ }
+ now := c.currentTime()
+ current, _ := codexQuotaOverdraftStateFromAccount(account)
+ signal, exhausted := codexQuotaOverdraftSignalFromAccount(account, current, now)
+ if !exhausted {
+ return
+ }
+ if current != nil && current.Status == codexQuotaOverdraftProbePassed && codexQuotaOverdraftStateCoversSignal(current, signal) {
+ return
+ }
+ if current != nil && current.Status == codexQuotaOverdraftProbeFailed && codexQuotaOverdraftStateCoversSignal(current, signal) {
+ return
+ }
+ state := &CodexQuotaOverdraftProbeState{
+ Status: codexQuotaOverdraftProbePassed,
+ QuotaWindow: signal.Window,
+ CycleKey: signal.CycleKey,
+ Attempts: 1,
+ Limit: codexQuotaOverdraftProbeAttemptLimit,
+ Model: strings.TrimSpace(preferredModel),
+ ReasonCode: "business_request_ok",
+ StartedAt: now,
+ TestedAt: codexQuotaOverdraftTimePtr(now),
+ RecoverAt: codexQuotaOverdraftTimePtr(signal.RecoverAt),
+ FiveHourRecoverAt: cloneTimePtr(signal.FiveHourRecoverAt),
+ SevenDayRecoverAt: cloneTimePtr(signal.SevenDayRecoverAt),
+ ObservedRateLimit: cloneTimePtr(account.RateLimitResetAt),
+ }
+ carryCodexQuotaOverdraftWindowStarts(state, current, signal, now)
+ startCodexQuotaOverdraftWindows(state, signal, now)
+ if !c.persistNonFailedState(account.ID, state) {
+ return
+ }
+ mergeAccountExtra(account, map[string]any{CodexQuotaOverdraftProbeExtraKey: state})
+ c.clearQuotaPause(account.ID, state)
+ slog.Info("codex_quota_overdraft_business_passed",
+ "account_id", account.ID,
+ "model", state.Model,
+ "quota_window", signal.Window,
+ "cycle_key", signal.CycleKey,
+ )
+}
+
+func (c *CodexQuotaOverdraftCoordinator) observeAccount(account *Account, preferredModel string) {
+ if account == nil {
+ return
+ }
+ now := c.currentTime()
+ state, _ := codexQuotaOverdraftStateFromAccount(account)
+ if state != nil && clearRecoveredCodexQuotaOverdraftWindows(state, account, now) {
+ c.persistState(account.ID, state)
+ mergeAccountExtra(account, map[string]any{CodexQuotaOverdraftProbeExtraKey: state})
+ }
+ signal, exhausted := codexQuotaOverdraftSignalFromAccount(account, state, now)
+ if !exhausted {
+ c.recoverCycle(account, state, now)
+ return
+ }
+ c.startProbe(account, signal, preferredModel)
+}
+
+// HandleQuota429 intercepts only definite subscription-quota 429s. Ordinary
+// transient 429s continue through the existing rate-limit policy unchanged.
+func (c *CodexQuotaOverdraftCoordinator) HandleQuota429(
+ ctx context.Context,
+ account *Account,
+ headers http.Header,
+ body []byte,
+ preferredModel string,
+) bool {
+ if !c.enabled() || !isCodexQuotaOverdraftAccount(account) || account.ID <= 0 ||
+ !codexQuotaOverdraftResponseIsQuotaLimited(headers, body) {
+ return false
+ }
+
+ accountCopy := cloneCodexQuotaOverdraftAccount(account)
+ if snapshot := ParseCodexRateLimitHeaders(headers); snapshot != nil {
+ updates := buildCodexUsageExtraUpdates(snapshot, c.currentTime())
+ mergeAccountExtra(accountCopy, updates)
+ persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second)
+ _ = c.accountRepo.UpdateExtra(persistCtx, account.ID, updates)
+ cancel()
+ }
+ state, _ := codexQuotaOverdraftStateFromAccount(accountCopy)
+ signal, exhausted := codexQuotaOverdraftSignalFromAccount(accountCopy, state, c.currentTime())
+ if !exhausted {
+ signal = codexQuotaOverdraftFallbackSignal(headers, body, state, c.currentTime())
+ }
+ if signal.CycleKey == "" {
+ return false
+ }
+ if codexQuotaOverdraftWasInjected(ctx, account.ID) {
+ return c.finishBusinessQuotaFailure(accountCopy, signal, preferredModel)
+ }
+ c.startProbe(accountCopy, signal, preferredModel)
+ return true
+}
+
+func (c *CodexQuotaOverdraftCoordinator) finishBusinessQuotaFailure(
+ account *Account,
+ signal codexQuotaOverdraftSignal,
+ preferredModel string,
+) bool {
+ if account == nil || signal.CycleKey == "" {
+ return false
+ }
+ now := c.currentTime()
+ current, hasCurrent := codexQuotaOverdraftStateFromAccount(account)
+ if hasCurrent && codexQuotaOverdraftStateCoversSignal(current, signal) && current.Status == codexQuotaOverdraftProbeFailed {
+ return c.ensureFailedPause(account, current)
+ }
+ if !hasCurrent || !codexQuotaOverdraftStateCoversSignal(current, signal) {
+ current = &CodexQuotaOverdraftProbeState{
+ Status: codexQuotaOverdraftProbePending,
+ QuotaWindow: signal.Window,
+ CycleKey: signal.CycleKey,
+ Limit: codexQuotaOverdraftProbeAttemptLimit,
+ StartedAt: now,
+ RecoverAt: codexQuotaOverdraftTimePtr(signal.RecoverAt),
+ FiveHourRecoverAt: cloneTimePtr(signal.FiveHourRecoverAt),
+ SevenDayRecoverAt: cloneTimePtr(signal.SevenDayRecoverAt),
+ ObservedRateLimit: cloneTimePtr(account.RateLimitResetAt),
+ }
+ claimed, err := c.claimProbe(account.ID, current)
+ if err != nil {
+ slog.Warn("codex_quota_overdraft_business_failure_claim_failed", "account_id", account.ID, "error", err)
+ return false
+ }
+ if !claimed {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ latest, loadErr := c.accountRepo.GetByID(ctx, account.ID)
+ cancel()
+ latestState, ok := codexQuotaOverdraftStateFromAccount(latest)
+ if loadErr != nil || !ok || !codexQuotaOverdraftStateCoversSignal(latestState, signal) {
+ return false
+ }
+ account = latest
+ current = latestState
+ if current.Status == codexQuotaOverdraftProbeFailed {
+ return c.ensureFailedPause(account, current)
+ }
+ }
+ }
+ state := *current
+ state.Status = codexQuotaOverdraftProbeFailed
+ if state.Attempts < 1 {
+ state.Attempts = 1
+ }
+ state.Model = strings.TrimSpace(preferredModel)
+ state.ReasonCode = "business_quota_limited"
+ state.TestedAt = codexQuotaOverdraftTimePtr(now)
+ state.RetryAt = nil
+ state.RecoverAt = codexQuotaOverdraftTimePtr(signal.RecoverAt)
+ state.FiveHourRecoverAt = cloneTimePtr(signal.FiveHourRecoverAt)
+ state.SevenDayRecoverAt = cloneTimePtr(signal.SevenDayRecoverAt)
+ if !c.ensureFailedPause(account, &state) {
+ return false
+ }
+ slog.Warn("codex_quota_overdraft_business_exhausted",
+ "account_id", account.ID,
+ "model", state.Model,
+ "quota_window", signal.Window,
+ "recover_at", state.RecoverAt,
+ )
+ return true
+}
+
+func (c *CodexQuotaOverdraftCoordinator) startProbe(account *Account, signal codexQuotaOverdraftSignal, preferredModel string) {
+ if account == nil || signal.CycleKey == "" {
+ return
+ }
+ current, hasCurrent := codexQuotaOverdraftStateFromAccount(account)
+ if hasCurrent && codexQuotaOverdraftStateCoversSignal(current, signal) {
+ switch current.Status {
+ case codexQuotaOverdraftProbePassed:
+ return
+ case codexQuotaOverdraftProbeFailed:
+ c.ensureFailedPause(account, current)
+ return
+ case codexQuotaOverdraftProbePending, codexQuotaOverdraftProbeInconclusive:
+ return
+ }
+ }
+
+ startedAt := c.currentTime().UTC()
+ state := &CodexQuotaOverdraftProbeState{
+ Status: codexQuotaOverdraftProbePending,
+ QuotaWindow: signal.Window,
+ CycleKey: signal.CycleKey,
+ Limit: codexQuotaOverdraftProbeAttemptLimit,
+ StartedAt: startedAt,
+ RecoverAt: codexQuotaOverdraftTimePtr(signal.RecoverAt),
+ FiveHourRecoverAt: cloneTimePtr(signal.FiveHourRecoverAt),
+ SevenDayRecoverAt: cloneTimePtr(signal.SevenDayRecoverAt),
+ ObservedRateLimit: cloneTimePtr(account.RateLimitResetAt),
+ }
+ carryCodexQuotaOverdraftWindowStarts(state, current, signal, startedAt)
+ claimed, err := c.claimProbe(account.ID, state)
+ if err != nil {
+ slog.Warn("codex_quota_overdraft_probe_claim_failed", "account_id", account.ID, "error", err)
+ return
+ }
+ if !claimed {
+ return
+ }
+ mergeAccountExtra(account, map[string]any{CodexQuotaOverdraftProbeExtraKey: state})
+
+ taskKey := fmt.Sprintf("%d:%s", account.ID, signal.CycleKey)
+ if _, loaded := c.running.LoadOrStore(taskKey, struct{}{}); loaded {
+ return
+ }
+ go func() {
+ defer c.running.Delete(taskKey)
+ c.runProbePlan(account.ID, signal, preferredModel, state)
+ }()
+}
+
+func (c *CodexQuotaOverdraftCoordinator) claimProbe(accountID int64, state *CodexQuotaOverdraftProbeState) (bool, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if claimer, ok := c.accountRepo.(codexQuotaOverdraftProbeClaimer); ok {
+ return claimer.ClaimCodexQuotaOverdraftProbe(ctx, accountID, state)
+ }
+ if err := c.accountRepo.UpdateExtra(ctx, accountID, map[string]any{CodexQuotaOverdraftProbeExtraKey: state}); err != nil {
+ return false, err
+ }
+ return true, nil
+}
+
+func (c *CodexQuotaOverdraftCoordinator) runProbePlan(
+ accountID int64,
+ signal codexQuotaOverdraftSignal,
+ preferredModel string,
+ state *CodexQuotaOverdraftProbeState,
+) {
+ planCtx, cancel := context.WithTimeout(context.Background(), codexQuotaOverdraftProbePlanTimeout)
+ defer cancel()
+
+ account, err := c.accountRepo.GetByID(planCtx, accountID)
+ if err != nil || !isCodexQuotaOverdraftAccount(account) {
+ c.finishInconclusive(accountID, state, "upstream_unavailable")
+ return
+ }
+ models := codexQuotaOverdraftProbeModels(preferredModel)
+ lastReason := "invalid_response"
+ quotaLimitedAttempts := 0
+ for attempt := 0; attempt < codexQuotaOverdraftProbeAttemptLimit; attempt++ {
+ if planCtx.Err() != nil {
+ c.finishInconclusive(accountID, state, "request_timeout")
+ return
+ }
+ model := models[attempt%len(models)]
+ attemptCtx, attemptCancel := context.WithTimeout(planCtx, codexQuotaOverdraftProbeAttemptTimeout)
+ result := c.runProbeAttempt(attemptCtx, account, model)
+ attemptCancel()
+ state.Attempts = attempt + 1
+ state.Model = result.Model
+ state.ReasonCode = result.ReasonCode
+ testedAt := c.currentTime().UTC()
+ state.TestedAt = &testedAt
+ lastReason = result.ReasonCode
+ 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,
+ )
+ if result.Status == "available" {
+ state.Status = codexQuotaOverdraftProbePassed
+ state.ReasonCode = "model_response_ok"
+ state.RetryAt = nil
+ state.RetryCount = 0
+ startCodexQuotaOverdraftWindows(state, signal, testedAt)
+ if !c.persistNonFailedState(accountID, state) {
+ return
+ }
+ 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)
+ 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)
+ return
+ }
+ }
+ if quotaLimitedAttempts < codexQuotaOverdraftProbeAttemptLimit {
+ c.finishInconclusive(accountID, state, lastReason)
+ return
+ }
+
+ state.Status = codexQuotaOverdraftProbeFailed
+ state.ReasonCode = lastReason
+ state.RetryAt = nil
+ state.RetryCount = 0
+ 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,
+) {
+ now := c.currentTime().UTC()
+ state.Status = codexQuotaOverdraftProbeInconclusive
+ state.ReasonCode = reason
+ state.TestedAt = &now
+ state.RetryAt = nil
+ state.RetryCount = 0
+ if !c.persistNonFailedState(accountID, state) {
+ return
+ }
+ slog.Warn("codex_quota_overdraft_probe_inconclusive",
+ "account_id", accountID,
+ "attempts", state.Attempts,
+ "reason", reason,
+ )
+}
+
+func (c *CodexQuotaOverdraftCoordinator) runProbeAttempt(ctx context.Context, account *Account, model string) codexQuotaOverdraftProbeResult {
+ if c.probeAttemptForTest != nil {
+ return c.probeAttemptForTest(ctx, account, model)
+ }
+ result := codexQuotaOverdraftProbeResult{Model: model}
+ upstreamModel := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(model))
+ payload := map[string]any{
+ "model": upstreamModel,
+ "input": []map[string]any{{
+ "type": "message",
+ "role": "user",
+ "content": []map[string]string{{
+ "type": "input_text",
+ "text": "hi",
+ }},
+ }},
+ "instructions": "Reply with OK only.",
+ "stream": true,
+ "store": false,
+ }
+ payloadBytes, err := json.Marshal(payload)
+ if err != nil {
+ result.Status, result.ReasonCode = "inconclusive", "experimental_probe_unavailable"
+ return result
+ }
+ payloadBytes, changed, err := injectCodexQuotaOverdraft(payloadBytes)
+ if err != nil || !changed {
+ result.Status, result.ReasonCode = "inconclusive", "experimental_probe_unavailable"
+ return result
+ }
+
+ req, err := http.NewRequestWithContext(WithHTTPUpstreamProfile(ctx, HTTPUpstreamProfileOpenAI), http.MethodPost, chatgptCodexAPIURL, bytes.NewReader(payloadBytes))
+ if err != nil {
+ result.Status, result.ReasonCode = "inconclusive", "upstream_unavailable"
+ return result
+ }
+ req.Host = "chatgpt.com"
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "text/event-stream")
+ req.Header.Set("OpenAI-Beta", "responses=experimental")
+ req.Header.Set("Originator", openai.CodexDefaultOriginator)
+ req.Header.Set("Version", openAICodexProbeVersion)
+ req.Header.Set("User-Agent", codexCLIUserAgent)
+
+ if account.IsOpenAIAgentIdentity() {
+ authHeaders, authErr := buildAgentIdentityAuthenticationHeaders(ctx, c.accountRepo, c.agentIdentityWS, &c.agentIdentityTaskMu, account)
+ if authErr != nil {
+ result.Status, result.ReasonCode = "inconclusive", "authentication_unavailable"
+ return result
+ }
+ for key, values := range authHeaders {
+ for _, value := range values {
+ req.Header.Add(key, value)
+ }
+ }
+ } else {
+ token := account.GetOpenAIAccessToken()
+ if c.openAITokenProvider != nil {
+ if refreshed, tokenErr := c.openAITokenProvider.GetAccessToken(ctx, account); tokenErr == nil {
+ token = refreshed
+ } else if strings.TrimSpace(token) == "" {
+ result.Status, result.ReasonCode = "inconclusive", "authentication_unavailable"
+ return result
+ }
+ }
+ if strings.TrimSpace(token) == "" {
+ result.Status, result.ReasonCode = "inconclusive", "authentication_unavailable"
+ return result
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ setOpenAIChatGPTAccountHeaders(req.Header, account)
+ enforceCodexIdentityHeadersWithUA(req.Header, account.GetOpenAIUserAgent())
+ account.ApplyHeaderOverrides(req.Header)
+
+ proxyURL := ""
+ if account.ProxyID != nil && account.Proxy != nil {
+ proxyURL = account.Proxy.URL()
+ }
+ var tlsProfile = (*tlsfingerprint.Profile)(nil)
+ if c.tlsFPProfileService != nil {
+ tlsProfile = c.tlsFPProfileService.ResolveTLSProfile(account)
+ }
+ resp, err := c.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, tlsProfile)
+ if err != nil {
+ result.Status = "inconclusive"
+ if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
+ result.ReasonCode = "request_timeout"
+ } else {
+ result.ReasonCode = "upstream_unavailable"
+ }
+ return result
+ }
+ defer func() { _ = resp.Body.Close() }()
+ result.StatusCode = resp.StatusCode
+ result.Headers = resp.Header.Clone()
+ body, readErr := io.ReadAll(io.LimitReader(resp.Body, codexQuotaOverdraftProbeBodyLimit+1))
+ if readErr != nil || len(body) > codexQuotaOverdraftProbeBodyLimit {
+ result.Status, result.ReasonCode = "inconclusive", "invalid_response"
+ return result
+ }
+ result.Body = body
+ result.Status, result.ReasonCode = classifyCodexQuotaOverdraftProbe(resp.StatusCode, resp.Header, body)
+ return result
+}
+
+func classifyCodexQuotaOverdraftProbe(statusCode int, headers http.Header, body []byte) (string, string) {
+ if statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices {
+ 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"`)) {
+ return "available", "model_response_ok"
+ }
+ return "retry", "invalid_response"
+ }
+ switch statusCode {
+ case http.StatusUnauthorized, http.StatusForbidden:
+ return "authentication_failed", "authentication_failed"
+ case http.StatusPaymentRequired:
+ lower := strings.ToLower(string(body))
+ if strings.Contains(lower, "deactivated_workspace") || strings.Contains(lower, "account_deactivated") {
+ return "authentication_failed", "account_deactivated"
+ }
+ if codexQuotaOverdraftResponseIsQuotaLimited(headers, body) {
+ return "retry", "quota_limited"
+ }
+ return "retry", "invalid_response"
+ case http.StatusTooManyRequests:
+ if codexQuotaOverdraftResponseIsQuotaLimited(headers, body) {
+ return "retry", "quota_limited"
+ }
+ return "inconclusive", "transient_failure"
+ 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 "retry", "model_not_found"
+ }
+ return "inconclusive", "invalid_response"
+ }
+}
+
+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
+}
+
+// persistNonFailedState keeps an already-confirmed quota failure terminal for
+// the current cycle, even when a concurrent success or probe finishes later.
+func (c *CodexQuotaOverdraftCoordinator) persistNonFailedState(accountID int64, state *CodexQuotaOverdraftProbeState) bool {
+ if state == nil || state.Status == codexQuotaOverdraftProbeFailed {
+ return false
+ }
+ persister, ok := c.accountRepo.(codexQuotaOverdraftNonFailedPersister)
+ if !ok {
+ return c.persistState(accountID, state)
+ }
+ var (
+ persisted bool
+ lastErr error
+ )
+ for attempt := 1; attempt <= codexQuotaOverdraftPersistAttempts; attempt++ {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ persisted, lastErr = persister.PersistCodexQuotaOverdraftProbeUnlessFailed(ctx, accountID, state)
+ cancel()
+ if lastErr == nil {
+ return persisted
+ }
+ 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) bool {
+ if account == nil || state == nil || state.RecoverAt == nil || !state.RecoverAt.After(c.currentTime()) {
+ 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, "injected overdraft request 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
+ }
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if c.tempUnschedCache != nil {
+ _ = c.tempUnschedCache.SetTempUnsched(ctx, account.ID, &TempUnschedState{
+ UntilUnix: state.RecoverAt.Unix(),
+ TriggeredAtUnix: c.currentTime().Unix(),
+ StatusCode: http.StatusTooManyRequests,
+ ErrorMessage: "injected overdraft request confirmed quota exhaustion",
+ })
+ }
+ 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) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ account, err := c.accountRepo.GetByID(ctx, accountID)
+ if err != nil || account == nil {
+ return
+ }
+ persisted, ok := codexQuotaOverdraftStateFromAccount(account)
+ if !ok || state == nil || persisted.CycleKey != state.CycleKey || persisted.Status != state.Status ||
+ (state.Status != codexQuotaOverdraftProbePassed && state.Status != codexQuotaOverdraftProbeRecovered) {
+ return
+ }
+ expectedTempReason := ""
+ if IsAccountSchedulingThresholdReason(account.TempUnschedulableReason) || codexQuotaOverdraftPauseReason(account.TempUnschedulableReason) {
+ expectedTempReason = account.TempUnschedulableReason
+ }
+ if clearer, ok := c.accountRepo.(codexQuotaOverdraftPauseClearer); ok {
+ clearedRateLimit, clearedTemp, clearErr := clearer.ClearCodexQuotaOverdraftPauseIfState(
+ ctx,
+ accountID,
+ state.CycleKey,
+ state.Status,
+ expectedTempReason,
+ )
+ if clearErr != nil {
+ slog.Warn("codex_quota_overdraft_stale_rate_limit_clear_failed", "account_id", accountID, "error", clearErr)
+ return
+ }
+ if clearedRateLimit {
+ slog.Info("codex_quota_overdraft_stale_rate_limit_cleared", "account_id", accountID, "probe_status", state.Status)
+ }
+ if clearedTemp && c.tempUnschedCache != nil {
+ _ = c.tempUnschedCache.DeleteTempUnsched(ctx, accountID)
+ }
+ if (clearedRateLimit || clearedTemp) && c.runtimeBlocker != nil {
+ c.runtimeBlocker.ClearAccountSchedulingBlock(accountID)
+ }
+ return
+ }
+ clearedSchedulingState := false
+ if account.RateLimitResetAt != nil {
+ staleResetAt := account.RateLimitResetAt.UTC()
+ if err := c.accountRepo.ClearRateLimit(ctx, accountID); err != nil {
+ slog.Warn("codex_quota_overdraft_stale_rate_limit_clear_failed", "account_id", accountID, "reset_at", staleResetAt, "error", err)
+ } else {
+ clearedSchedulingState = true
+ slog.Info("codex_quota_overdraft_stale_rate_limit_cleared", "account_id", accountID, "reset_at", staleResetAt, "probe_status", state.Status)
+ }
+ }
+ if expectedTempReason != "" {
+ _ = c.accountRepo.ClearTempUnschedulable(ctx, accountID)
+ clearedSchedulingState = true
+ if c.tempUnschedCache != nil {
+ _ = c.tempUnschedCache.DeleteTempUnsched(ctx, accountID)
+ }
+ }
+ if clearedSchedulingState && c.runtimeBlocker != nil {
+ c.runtimeBlocker.ClearAccountSchedulingBlock(accountID)
+ }
+}
+
+func (c *CodexQuotaOverdraftCoordinator) recoverCycle(account *Account, state *CodexQuotaOverdraftProbeState, now time.Time) {
+ if account == nil || state == nil || state.Status == "" || state.Status == codexQuotaOverdraftProbeRecovered {
+ return
+ }
+ state.Status = codexQuotaOverdraftProbeRecovered
+ state.ReasonCode = "quota_recovered"
+ state.RetryAt = nil
+ state.OverdraftStartedAt = nil
+ state.FiveHourStartedAt = nil
+ state.SevenDayStartedAt = nil
+ testedAt := now.UTC()
+ state.TestedAt = &testedAt
+ c.persistState(account.ID, state)
+ c.clearQuotaPause(account.ID, state)
+}
+
+func (c *CodexQuotaOverdraftCoordinator) currentTime() time.Time {
+ if c != nil && c.now != nil {
+ return c.now().UTC()
+ }
+ return time.Now().UTC()
+}
+
+func codexQuotaOverdraftProbeModels(preferred string) []string {
+ models := []string{strings.TrimSpace(preferred), codexQuotaOverdraftFallbackModel, codexQuotaOverdraftCompatibilityModel}
+ if models[0] == "" {
+ models[0] = openai.DefaultTestModel
+ }
+ out := make([]string, 0, len(models))
+ seen := make(map[string]struct{}, len(models))
+ for _, model := range models {
+ key := strings.ToLower(strings.TrimSpace(model))
+ if key == "" {
+ continue
+ }
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ out = append(out, model)
+ }
+ return out
+}
+
+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",
+ "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
+ }
+ }
+ if parsedPayload && codexQuotaOverdraftJSONHasTransientRateLimitEvidence(payload, 0) {
+ return false
+ }
+ if snapshot := ParseCodexRateLimitHeaders(headers); snapshot != nil {
+ if normalized := snapshot.Normalize(); normalized != nil &&
+ (normalized.Used5hPercent != nil && *normalized.Used5hPercent >= 100 ||
+ normalized.Used7dPercent != nil && *normalized.Used7dPercent >= 100) {
+ return true
+ }
+ }
+ 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 codexQuotaOverdraftJSONHasTransientRateLimitEvidence(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), "-", "_"))
+ if normalizedKey == "type" || normalizedKey == "code" || normalizedKey == "reason" || normalizedKey == "error_code" {
+ 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":
+ return true
+ }
+ }
+ }
+ if codexQuotaOverdraftJSONHasTransientRateLimitEvidence(raw, depth+1) {
+ return true
+ }
+ }
+ case []any:
+ for _, item := range typed {
+ if codexQuotaOverdraftJSONHasTransientRateLimitEvidence(item, depth+1) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func codexQuotaOverdraftSignalFromAccount(account *Account, state *CodexQuotaOverdraftProbeState, now time.Time) (codexQuotaOverdraftSignal, bool) {
+ if account == nil || len(account.Extra) == 0 {
+ return codexQuotaOverdraftSignal{}, false
+ }
+ fiveUsed := parseExtraFloat64(account.Extra["codex_5h_used_percent"])
+ sevenUsed := parseExtraFloat64(account.Extra["codex_7d_used_percent"])
+ fiveReset := codexQuotaOverdraftResetAt(account.Extra["codex_5h_reset_at"], now)
+ sevenReset := codexQuotaOverdraftResetAt(account.Extra["codex_7d_reset_at"], now)
+ if state != nil {
+ fiveReset = stabilizeCodexQuotaOverdraftReset(fiveReset, state.FiveHourRecoverAt, now)
+ sevenReset = stabilizeCodexQuotaOverdraftReset(sevenReset, state.SevenDayRecoverAt, now)
+ }
+ fiveExhausted := fiveUsed >= 100 && (fiveReset == nil || fiveReset.After(now))
+ sevenExhausted := sevenUsed >= 100 && (sevenReset == nil || sevenReset.After(now))
+ if !fiveExhausted && !sevenExhausted {
+ return codexQuotaOverdraftSignal{}, false
+ }
+ if fiveExhausted && fiveReset == nil {
+ fallback := now.Add(5 * time.Hour)
+ if state != nil && state.FiveHourRecoverAt != nil && state.FiveHourRecoverAt.After(now) {
+ fallback = *state.FiveHourRecoverAt
+ }
+ fiveReset = &fallback
+ }
+ if sevenExhausted && sevenReset == nil {
+ fallback := now.Add(7 * 24 * time.Hour)
+ if state != nil && state.SevenDayRecoverAt != nil && state.SevenDayRecoverAt.After(now) {
+ fallback = *state.SevenDayRecoverAt
+ }
+ sevenReset = &fallback
+ }
+
+ signal := codexQuotaOverdraftSignal{FiveHourRecoverAt: fiveReset, SevenDayRecoverAt: sevenReset}
+ switch {
+ case fiveExhausted && sevenExhausted:
+ signal.Window = "multiple"
+ signal.CycleKey = fmt.Sprintf("5h:%d|7d:%d", fiveReset.Unix(), sevenReset.Unix())
+ signal.RecoverAt = laterTime(*fiveReset, *sevenReset)
+ case fiveExhausted:
+ signal.Window = "5h"
+ signal.CycleKey = fmt.Sprintf("5h:%d", fiveReset.Unix())
+ signal.RecoverAt = *fiveReset
+ signal.SevenDayRecoverAt = nil
+ default:
+ signal.Window = "7d"
+ signal.CycleKey = fmt.Sprintf("7d:%d", sevenReset.Unix())
+ signal.RecoverAt = *sevenReset
+ signal.FiveHourRecoverAt = nil
+ }
+ return signal, true
+}
+
+func clearRecoveredCodexQuotaOverdraftWindows(state *CodexQuotaOverdraftProbeState, account *Account, now time.Time) bool {
+ if state == nil || account == nil {
+ return false
+ }
+ changed := false
+ fiveUsed := parseExtraFloat64(account.Extra["codex_5h_used_percent"])
+ sevenUsed := parseExtraFloat64(account.Extra["codex_7d_used_percent"])
+ if state.FiveHourStartedAt != nil && (fiveUsed < 100 || state.FiveHourRecoverAt == nil || !state.FiveHourRecoverAt.After(now)) {
+ state.FiveHourStartedAt = nil
+ state.FiveHourRecoverAt = nil
+ changed = true
+ }
+ if state.SevenDayStartedAt != nil && (sevenUsed < 100 || state.SevenDayRecoverAt == nil || !state.SevenDayRecoverAt.After(now)) {
+ state.SevenDayStartedAt = nil
+ state.SevenDayRecoverAt = nil
+ changed = true
+ }
+ state.OverdraftStartedAt = earlierCodexQuotaOverdraftStart(state.FiveHourStartedAt, state.SevenDayStartedAt)
+ return changed
+}
+
+func codexQuotaOverdraftFallbackSignal(headers http.Header, body []byte, state *CodexQuotaOverdraftProbeState, now time.Time) codexQuotaOverdraftSignal {
+ recoverAt := now.Add(5 * time.Hour)
+ if resetAt := calculateOpenAI429ResetTime(headers); resetAt != nil && resetAt.After(now) {
+ recoverAt = *resetAt
+ } else if resetUnix := parseOpenAIRateLimitResetTime(body); resetUnix != nil {
+ if parsed := time.Unix(*resetUnix, 0).UTC(); parsed.After(now) {
+ recoverAt = parsed
+ }
+ } else if state != nil && state.RecoverAt != nil && state.RecoverAt.After(now) {
+ recoverAt = *state.RecoverAt
+ }
+ return codexQuotaOverdraftSignal{
+ Window: "multiple",
+ CycleKey: fmt.Sprintf("multiple:%d", recoverAt.Unix()),
+ RecoverAt: recoverAt,
+ FiveHourRecoverAt: codexQuotaOverdraftTimePtr(recoverAt),
+ SevenDayRecoverAt: codexQuotaOverdraftTimePtr(recoverAt),
+ }
+}
+
+func codexQuotaOverdraftStateFromAccount(account *Account) (*CodexQuotaOverdraftProbeState, bool) {
+ if account == nil || account.Extra == nil {
+ return nil, false
+ }
+ raw, ok := account.Extra[CodexQuotaOverdraftProbeExtraKey]
+ if !ok || raw == nil {
+ return nil, false
+ }
+ encoded, err := json.Marshal(raw)
+ if err != nil {
+ return nil, false
+ }
+ var state CodexQuotaOverdraftProbeState
+ if err := json.Unmarshal(encoded, &state); err != nil || state.CycleKey == "" {
+ return nil, false
+ }
+ return &state, true
+}
+
+func codexQuotaOverdraftSchedulingAllowed(account *Account, now time.Time) bool {
+ if !codexQuotaOverdraftInjectionEligible(account, now) {
+ return false
+ }
+ state, _ := codexQuotaOverdraftStateFromAccount(account)
+ signal, exhausted := codexQuotaOverdraftSignalFromAccount(account, state, now)
+ if !exhausted || state == nil || !codexQuotaOverdraftStateCoversSignal(state, signal) {
+ return true
+ }
+ return state.Status != codexQuotaOverdraftProbeFailed
+}
+
+func codexQuotaOverdraftSnapshotPrearmReached(updates map[string]any) bool {
+ if len(updates) == 0 {
+ return false
+ }
+ return parseExtraFloat64(updates["codex_5h_used_percent"]) >= codexQuotaOverdraftPrearmPercent ||
+ parseExtraFloat64(updates["codex_7d_used_percent"]) >= codexQuotaOverdraftPrearmPercent
+}
+
+func applyCodexQuotaOverdraftUsage(
+ ctx context.Context,
+ repo UsageLogRepository,
+ account *Account,
+ usage *UsageInfo,
+ now time.Time,
+) {
+ if repo == nil || account == nil || usage == nil {
+ return
+ }
+ state, ok := codexQuotaOverdraftStateFromAccount(account)
+ if !ok || state.Status == codexQuotaOverdraftProbeRecovered {
+ return
+ }
+ apply := func(progress *UsageProgress, startedAt, recoverAt *time.Time) {
+ if progress == nil || progress.Utilization < 100 || startedAt == nil || recoverAt == nil || !recoverAt.After(now) {
+ return
+ }
+ stats, err := repo.GetAccountWindowStats(ctx, account.ID, *startedAt)
+ if err != nil {
+ return
+ }
+ progress.OverdraftActive = true
+ progress.OverdraftStats = windowStatsFromAccountStats(stats)
+ progress.OverdraftStarted = cloneTimePtr(startedAt)
+ progress.OverdraftRecover = cloneTimePtr(recoverAt)
+ }
+ fiveStarted, sevenStarted := codexQuotaOverdraftWindowStarts(state)
+ apply(usage.FiveHour, fiveStarted, state.FiveHourRecoverAt)
+ apply(usage.SevenDay, sevenStarted, state.SevenDayRecoverAt)
+}
+
+func stabilizeCodexQuotaOverdraftReset(current, persisted *time.Time, now time.Time) *time.Time {
+ if current == nil || persisted == nil || !persisted.After(now) {
+ return current
+ }
+ delta := current.Sub(*persisted)
+ if delta < 0 {
+ delta = -delta
+ }
+ if delta <= 2*time.Minute {
+ return cloneTimePtr(persisted)
+ }
+ return current
+}
+
+func codexQuotaOverdraftStateCoversSignal(state *CodexQuotaOverdraftProbeState, signal codexQuotaOverdraftSignal) bool {
+ if state == nil || signal.CycleKey == "" {
+ return false
+ }
+ if state.CycleKey == signal.CycleKey {
+ return true
+ }
+ resetMatches := func(current, persisted *time.Time) bool {
+ if current == nil || persisted == nil {
+ return false
+ }
+ delta := current.Sub(*persisted)
+ if delta < 0 {
+ delta = -delta
+ }
+ return delta <= 2*time.Minute
+ }
+ switch signal.Window {
+ case "5h":
+ return resetMatches(signal.FiveHourRecoverAt, state.FiveHourRecoverAt)
+ case "7d":
+ return resetMatches(signal.SevenDayRecoverAt, state.SevenDayRecoverAt)
+ case "multiple":
+ return resetMatches(signal.FiveHourRecoverAt, state.FiveHourRecoverAt) &&
+ resetMatches(signal.SevenDayRecoverAt, state.SevenDayRecoverAt)
+ default:
+ return false
+ }
+}
+
+func carryCodexQuotaOverdraftWindowStarts(target, current *CodexQuotaOverdraftProbeState, signal codexQuotaOverdraftSignal, now time.Time) {
+ if target == nil || current == nil {
+ return
+ }
+ if current.FiveHourRecoverAt != nil && current.FiveHourRecoverAt.After(now) && signal.FiveHourRecoverAt != nil {
+ delta := signal.FiveHourRecoverAt.Sub(*current.FiveHourRecoverAt)
+ if delta >= -2*time.Minute && delta <= 2*time.Minute {
+ target.FiveHourStartedAt = cloneTimePtr(current.FiveHourStartedAt)
+ }
+ }
+ if current.SevenDayRecoverAt != nil && current.SevenDayRecoverAt.After(now) && signal.SevenDayRecoverAt != nil {
+ delta := signal.SevenDayRecoverAt.Sub(*current.SevenDayRecoverAt)
+ if delta >= -2*time.Minute && delta <= 2*time.Minute {
+ target.SevenDayStartedAt = cloneTimePtr(current.SevenDayStartedAt)
+ }
+ }
+ target.OverdraftStartedAt = earlierCodexQuotaOverdraftStart(target.FiveHourStartedAt, target.SevenDayStartedAt)
+}
+
+func startCodexQuotaOverdraftWindows(state *CodexQuotaOverdraftProbeState, signal codexQuotaOverdraftSignal, testedAt time.Time) {
+ if state == nil {
+ return
+ }
+ switch signal.Window {
+ case "5h":
+ if state.FiveHourStartedAt == nil {
+ state.FiveHourStartedAt = codexQuotaOverdraftTimePtr(testedAt)
+ }
+ case "7d":
+ if state.SevenDayStartedAt == nil {
+ state.SevenDayStartedAt = codexQuotaOverdraftTimePtr(testedAt)
+ }
+ default:
+ if state.FiveHourStartedAt == nil {
+ state.FiveHourStartedAt = codexQuotaOverdraftTimePtr(testedAt)
+ }
+ if state.SevenDayStartedAt == nil {
+ state.SevenDayStartedAt = codexQuotaOverdraftTimePtr(testedAt)
+ }
+ }
+ state.OverdraftStartedAt = earlierCodexQuotaOverdraftStart(state.FiveHourStartedAt, state.SevenDayStartedAt)
+}
+
+func codexQuotaOverdraftWindowStarts(state *CodexQuotaOverdraftProbeState) (*time.Time, *time.Time) {
+ if state == nil {
+ return nil, nil
+ }
+ fiveStarted := cloneTimePtr(state.FiveHourStartedAt)
+ sevenStarted := cloneTimePtr(state.SevenDayStartedAt)
+ if fiveStarted == nil && sevenStarted == nil && state.OverdraftStartedAt != nil {
+ switch state.QuotaWindow {
+ case "5h":
+ fiveStarted = cloneTimePtr(state.OverdraftStartedAt)
+ case "7d":
+ sevenStarted = cloneTimePtr(state.OverdraftStartedAt)
+ default:
+ fiveStarted = cloneTimePtr(state.OverdraftStartedAt)
+ sevenStarted = cloneTimePtr(state.OverdraftStartedAt)
+ }
+ }
+ return fiveStarted, sevenStarted
+}
+
+func earlierCodexQuotaOverdraftStart(left, right *time.Time) *time.Time {
+ switch {
+ case left == nil:
+ return cloneTimePtr(right)
+ case right == nil:
+ return cloneTimePtr(left)
+ case left.Before(*right):
+ return cloneTimePtr(left)
+ default:
+ return cloneTimePtr(right)
+ }
+}
+
+func codexQuotaOverdraftPauseReason(reason string) bool {
+ payload, ok := parseTempUnschedReasonPayload(reason)
+ return ok && payload.Source == codexQuotaOverdraftPauseSource
+}
+
+func codexQuotaOverdraftResetAt(raw any, now time.Time) *time.Time {
+ if raw == nil {
+ return nil
+ }
+ parsed, err := parseTime(strings.TrimSpace(fmt.Sprint(raw)))
+ if err != nil {
+ return nil
+ }
+ parsed = parsed.UTC()
+ return &parsed
+}
+
+func cloneCodexQuotaOverdraftAccount(account *Account) *Account {
+ if account == nil {
+ return nil
+ }
+ clone := *account
+ clone.Extra = shallowCopyMap(account.Extra)
+ clone.Credentials = shallowCopyMap(account.Credentials)
+ return &clone
+}
+
+func laterTime(left, right time.Time) time.Time {
+ if right.After(left) {
+ return right
+ }
+ return left
+}
+
+func codexQuotaOverdraftTimePtr(value time.Time) *time.Time {
+ value = value.UTC()
+ return &value
+}
diff --git a/backend/internal/service/openai_codex_quota_overdraft_probe_test.go b/backend/internal/service/openai_codex_quota_overdraft_probe_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..31ea45f69f093ceee1b0bf1c2e26d0d1e9598fd6
--- /dev/null
+++ b/backend/internal/service/openai_codex_quota_overdraft_probe_test.go
@@ -0,0 +1,581 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/stretchr/testify/require"
+)
+
+type codexOverdraftProbeRepoStub struct {
+ AccountRepository
+ account *Account
+ states []*CodexQuotaOverdraftProbeState
+ tempPauseCalls int
+ clearTempCalls int
+ clearLimitCalls int
+}
+
+type codexOverdraftRuntimeBlockerStub struct {
+ clearCalls int
+}
+
+type codexOverdraftClaimConflictRepoStub struct {
+ *codexOverdraftProbeRepoStub
+}
+
+func (r *codexOverdraftClaimConflictRepoStub) ClaimCodexQuotaOverdraftProbe(
+ context.Context,
+ int64,
+ *CodexQuotaOverdraftProbeState,
+) (bool, error) {
+ return false, nil
+}
+
+func (b *codexOverdraftRuntimeBlockerStub) BlockAccountScheduling(*Account, time.Time, string) {}
+
+func (b *codexOverdraftRuntimeBlockerStub) ClearAccountSchedulingBlock(int64) {
+ b.clearCalls++
+}
+
+func (r *codexOverdraftProbeRepoStub) GetByID(context.Context, int64) (*Account, error) {
+ return r.account, nil
+}
+
+func (r *codexOverdraftProbeRepoStub) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error {
+ if r.account.Extra == nil {
+ r.account.Extra = make(map[string]any)
+ }
+ for key, value := range updates {
+ r.account.Extra[key] = value
+ }
+ if state, ok := updates[CodexQuotaOverdraftProbeExtraKey].(*CodexQuotaOverdraftProbeState); ok {
+ clone := *state
+ r.states = append(r.states, &clone)
+ }
+ return nil
+}
+
+func (r *codexOverdraftProbeRepoStub) SetTempUnschedulable(_ context.Context, _ int64, until time.Time, reason string) error {
+ r.tempPauseCalls++
+ r.account.TempUnschedulableUntil = codexQuotaOverdraftTimePtr(until)
+ r.account.TempUnschedulableReason = reason
+ return nil
+}
+
+func (r *codexOverdraftProbeRepoStub) ClearTempUnschedulable(context.Context, int64) error {
+ r.clearTempCalls++
+ r.account.TempUnschedulableUntil = nil
+ r.account.TempUnschedulableReason = ""
+ return nil
+}
+
+func (r *codexOverdraftProbeRepoStub) ClearRateLimit(context.Context, int64) error {
+ r.clearLimitCalls++
+ r.account.RateLimitResetAt = nil
+ return nil
+}
+
+func newCodexOverdraftProbeTestAccount(now time.Time) *Account {
+ return &Account{
+ ID: 77,
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Status: StatusActive,
+ Schedulable: true,
+ Extra: map[string]any{
+ "codex_5h_used_percent": 100,
+ "codex_5h_reset_at": now.Add(5 * time.Hour).Format(time.RFC3339),
+ },
+ }
+}
+
+func TestCodexQuotaOverdraftProbeUsesOnePreferredModel(t *testing.T) {
+ models := codexQuotaOverdraftProbeModels("gpt-5.4")
+ require.Equal(t, []string{"gpt-5.4", "gpt-5.5", "gpt-5.4-mini"}, models)
+
+ got := make([]string, 0, codexQuotaOverdraftProbeAttemptLimit)
+ for attempt := 0; attempt < codexQuotaOverdraftProbeAttemptLimit; attempt++ {
+ got = append(got, models[attempt%len(models)])
+ }
+ require.Equal(t, []string{"gpt-5.4"}, got)
+}
+
+func TestCodexQuotaOverdraftSignalsKeepFiveHourAndSevenDayCyclesSeparate(t *testing.T) {
+ now := time.Date(2026, time.August, 13, 14, 0, 0, 0, time.UTC)
+ fiveReset := now.Add(5 * time.Hour)
+ sevenReset := now.Add(6 * 24 * time.Hour)
+ account := newCodexOverdraftProbeTestAccount(now)
+
+ five, exhausted := codexQuotaOverdraftSignalFromAccount(account, nil, now)
+ require.True(t, exhausted)
+ require.Equal(t, "5h", five.Window)
+ require.Equal(t, "5h:"+formatCodexOverdraftUnix(fiveReset), five.CycleKey)
+ require.WithinDuration(t, fiveReset, five.RecoverAt, time.Second)
+
+ account.Extra["codex_7d_used_percent"] = 100
+ account.Extra["codex_7d_reset_at"] = sevenReset.Format(time.RFC3339)
+ multiple, exhausted := codexQuotaOverdraftSignalFromAccount(account, nil, now)
+ require.True(t, exhausted)
+ require.Equal(t, "multiple", multiple.Window)
+ require.WithinDuration(t, sevenReset, multiple.RecoverAt, time.Second, "多个额度周期必须等待最晚恢复时间")
+ require.Contains(t, multiple.CycleKey, "5h:")
+ require.Contains(t, multiple.CycleKey, "|7d:")
+}
+
+func TestCodexQuotaOverdraftSingleProbePassesOnSuccess(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, 1)
+ coordinator.probeAttemptForTest = func(_ context.Context, _ *Account, model string) codexQuotaOverdraftProbeResult {
+ models = append(models, model)
+ return codexQuotaOverdraftProbeResult{Status: "available", ReasonCode: "model_response_ok", StatusCode: http.StatusOK, Model: model}
+ }
+ signal, exhausted := codexQuotaOverdraftSignalFromAccount(account, nil, now)
+ require.True(t, exhausted)
+ state := newCodexOverdraftPendingState(signal, now)
+
+ coordinator.runProbePlan(account.ID, signal, "gpt-5.4", state)
+
+ require.Equal(t, codexQuotaOverdraftProbePassed, state.Status)
+ require.Equal(t, 1, state.Attempts)
+ require.Equal(t, []string{"gpt-5.4"}, models)
+ require.NotNil(t, state.FiveHourStartedAt)
+ require.Zero(t, repo.tempPauseCalls)
+}
+
+func TestCodexQuotaOverdraftSingleProbeQuotaFailurePauses(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: "quota_limited", StatusCode: http.StatusTooManyRequests, Model: model}
+ }
+ signal, _ := codexQuotaOverdraftSignalFromAccount(account, nil, now)
+ state := newCodexOverdraftPendingState(signal, now)
+
+ coordinator.runProbePlan(account.ID, signal, "gpt-5.4", state)
+
+ require.Equal(t, codexQuotaOverdraftProbeFailed, state.Status)
+ require.Equal(t, codexQuotaOverdraftProbeAttemptLimit, state.Attempts)
+ require.Len(t, models, codexQuotaOverdraftProbeAttemptLimit)
+ require.Equal(t, 1, repo.tempPauseCalls)
+ require.True(t, codexQuotaOverdraftPauseReason(account.TempUnschedulableReason))
+ require.False(t, codexQuotaOverdraftSchedulingAllowed(account, now), "同周期确认失败后不得继续绕过限额")
+}
+
+func TestCodexQuotaOverdraftBusinessSuccessAtExhaustionPassesWithoutProbe(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 }}
+
+ coordinator.observeBusinessSuccess(account, "gpt-5.4")
+
+ state, ok := codexQuotaOverdraftStateFromAccount(account)
+ require.True(t, ok)
+ require.Equal(t, codexQuotaOverdraftProbePassed, state.Status)
+ require.Equal(t, "business_request_ok", state.ReasonCode)
+ require.Equal(t, "gpt-5.4", state.Model)
+ require.Equal(t, 1, state.Attempts)
+ require.Equal(t, 1, state.Limit)
+ require.NotNil(t, state.FiveHourStartedAt)
+}
+
+func TestCodexQuotaOverdraftBusinessSuccessCannotReplaceFailedCycle(t *testing.T) {
+ now := time.Date(2026, time.August, 13, 14, 0, 0, 0, time.UTC)
+ account := newCodexOverdraftProbeTestAccount(now)
+ signal, _ := codexQuotaOverdraftSignalFromAccount(account, nil, now)
+ failed := newCodexOverdraftPendingState(signal, now)
+ failed.Status = codexQuotaOverdraftProbeFailed
+ failed.ReasonCode = "business_quota_limited"
+ account.Extra[CodexQuotaOverdraftProbeExtraKey] = failed
+ repo := &codexOverdraftProbeRepoStub{account: account}
+ coordinator := &CodexQuotaOverdraftCoordinator{accountRepo: repo, now: func() time.Time { return now }}
+
+ coordinator.observeBusinessSuccess(account, "gpt-5.4")
+
+ state, ok := codexQuotaOverdraftStateFromAccount(account)
+ require.True(t, ok)
+ require.Equal(t, codexQuotaOverdraftProbeFailed, state.Status)
+ require.Empty(t, repo.states)
+}
+
+func TestCodexQuotaOverdraftInjectedBusinessQuota429FailsImmediately(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,
+ httpUpstream: &queuedHTTPUpstream{},
+ cfg: &config.Config{Gateway: config.GatewayConfig{
+ CodexQuotaOverdraftEnabled: true,
+ }},
+ now: func() time.Time { return now },
+ }
+ ctx := WithCodexQuotaOverdraftScheduling(context.Background())
+ markCodexQuotaOverdraftInjected(ctx, account.ID)
+
+ handled := coordinator.HandleQuota429(
+ ctx,
+ account,
+ nil,
+ []byte(`{"error":{"type":"usage_limit_reached"}}`),
+ "gpt-5.4",
+ )
+
+ require.True(t, handled)
+ state, ok := codexQuotaOverdraftStateFromAccount(account)
+ require.True(t, ok)
+ require.Equal(t, codexQuotaOverdraftProbeFailed, state.Status)
+ require.Equal(t, "business_quota_limited", state.ReasonCode)
+ require.Equal(t, 1, repo.tempPauseCalls)
+}
+
+func TestCodexQuotaOverdraftTransient429DoesNotEnterQuotaCooldown(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,
+ httpUpstream: &queuedHTTPUpstream{},
+ cfg: &config.Config{Gateway: config.GatewayConfig{
+ CodexQuotaOverdraftEnabled: true,
+ }},
+ now: func() time.Time { return now },
+ }
+ ctx := WithCodexQuotaOverdraftScheduling(context.Background())
+ markCodexQuotaOverdraftInjected(ctx, account.ID)
+ headers := http.Header{}
+ headers.Set("x-codex-primary-used-percent", "100")
+ headers.Set("x-codex-primary-reset-after-seconds", "3600")
+ headers.Set("x-codex-primary-window-minutes", "300")
+
+ handled := coordinator.HandleQuota429(
+ ctx,
+ account,
+ headers,
+ []byte(`{"error":{"type":"rate_limit_exceeded","message":"too many requests"}}`),
+ "gpt-5.4",
+ )
+
+ require.False(t, handled)
+ require.Zero(t, repo.tempPauseCalls)
+ _, hasState := codexQuotaOverdraftStateFromAccount(account)
+ require.False(t, hasState)
+}
+
+func TestCodexQuotaOverdraftBusinessFailureReloadsAfterClaimConflict(t *testing.T) {
+ now := time.Date(2026, time.August, 13, 14, 0, 0, 0, time.UTC)
+ latest := newCodexOverdraftProbeTestAccount(now)
+ signal, _ := codexQuotaOverdraftSignalFromAccount(latest, nil, now)
+ passed := newCodexOverdraftPendingState(signal, now)
+ passed.Status = codexQuotaOverdraftProbePassed
+ latest.Extra[CodexQuotaOverdraftProbeExtraKey] = passed
+ baseRepo := &codexOverdraftProbeRepoStub{account: latest}
+ repo := &codexOverdraftClaimConflictRepoStub{codexOverdraftProbeRepoStub: baseRepo}
+ coordinator := &CodexQuotaOverdraftCoordinator{accountRepo: repo, now: func() time.Time { return now }}
+ stale := newCodexOverdraftProbeTestAccount(now)
+ delete(stale.Extra, CodexQuotaOverdraftProbeExtraKey)
+
+ handled := coordinator.finishBusinessQuotaFailure(stale, signal, "gpt-5.4")
+
+ require.True(t, handled)
+ state, ok := codexQuotaOverdraftStateFromAccount(latest)
+ require.True(t, ok)
+ require.Equal(t, codexQuotaOverdraftProbeFailed, state.Status)
+ require.Equal(t, 1, baseRepo.tempPauseCalls)
+}
+
+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{
+ {Status: "inconclusive", ReasonCode: "request_timeout", StatusCode: http.StatusGatewayTimeout, Model: "gpt-5.4"},
+ {Status: "inconclusive", ReasonCode: "upstream_unavailable", StatusCode: http.StatusServiceUnavailable, Model: "gpt-5.4"},
+ } {
+ t.Run(result.ReasonCode, func(t *testing.T) {
+ account := newCodexOverdraftProbeTestAccount(now)
+ repo := &codexOverdraftProbeRepoStub{account: account}
+ coordinator := &CodexQuotaOverdraftCoordinator{accountRepo: repo, now: func() time.Time { return now }}
+ coordinator.probeAttemptForTest = func(context.Context, *Account, string) codexQuotaOverdraftProbeResult { return result }
+ 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, 1, state.Attempts)
+ require.Nil(t, state.RetryAt)
+ require.Zero(t, state.RetryCount)
+ require.Zero(t, repo.tempPauseCalls)
+ })
+ }
+}
+
+func TestCodexQuotaOverdraftModelNotFoundIsInconclusiveWithoutRetry(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.Zero(t, state.RetryCount)
+ require.Nil(t, state.RetryAt)
+ require.Equal(t, []string{"gpt-5.4"}, models)
+ require.Zero(t, repo.tempPauseCalls)
+}
+
+func TestCodexQuotaOverdraftNewWindowPreservesExistingWindowBaseline(t *testing.T) {
+ now := time.Date(2026, time.August, 13, 14, 0, 0, 0, time.UTC)
+ fiveStarted := now.Add(-time.Hour)
+ fiveRecover := now.Add(4 * time.Hour)
+ sevenRecover := now.Add(6 * 24 * time.Hour)
+ current := &CodexQuotaOverdraftProbeState{
+ Status: codexQuotaOverdraftProbePassed,
+ QuotaWindow: "5h",
+ CycleKey: "5h:" + formatCodexOverdraftUnix(fiveRecover),
+ FiveHourRecoverAt: codexQuotaOverdraftTimePtr(fiveRecover),
+ FiveHourStartedAt: codexQuotaOverdraftTimePtr(fiveStarted),
+ }
+ signal := codexQuotaOverdraftSignal{
+ Window: "multiple",
+ CycleKey: "5h:" + formatCodexOverdraftUnix(fiveRecover) + "|7d:" + formatCodexOverdraftUnix(sevenRecover),
+ RecoverAt: sevenRecover,
+ FiveHourRecoverAt: codexQuotaOverdraftTimePtr(fiveRecover),
+ SevenDayRecoverAt: codexQuotaOverdraftTimePtr(sevenRecover),
+ }
+ target := newCodexOverdraftPendingState(signal, now)
+ carryCodexQuotaOverdraftWindowStarts(target, current, signal, now)
+ startCodexQuotaOverdraftWindows(target, signal, now)
+
+ require.NotNil(t, target.FiveHourStartedAt)
+ require.True(t, target.FiveHourStartedAt.Equal(fiveStarted), "已有 5h 透支统计起点不能因 7d 后续耗尽而重置")
+ require.NotNil(t, target.SevenDayStartedAt)
+ require.True(t, target.SevenDayStartedAt.Equal(now))
+}
+
+func TestCodexQuotaOverdraftFailedMultipleCycleStillCoversRemainingWindow(t *testing.T) {
+ now := time.Date(2026, time.August, 13, 14, 0, 0, 0, time.UTC)
+ fiveRecover := now.Add(5 * time.Hour)
+ sevenRecover := now.Add(6 * 24 * time.Hour)
+ state := &CodexQuotaOverdraftProbeState{
+ Status: codexQuotaOverdraftProbeFailed,
+ QuotaWindow: "multiple",
+ CycleKey: "5h:" + formatCodexOverdraftUnix(fiveRecover) + "|7d:" + formatCodexOverdraftUnix(sevenRecover),
+ FiveHourRecoverAt: codexQuotaOverdraftTimePtr(fiveRecover),
+ SevenDayRecoverAt: codexQuotaOverdraftTimePtr(sevenRecover),
+ }
+ remaining := codexQuotaOverdraftSignal{
+ Window: "7d",
+ CycleKey: "7d:" + formatCodexOverdraftUnix(sevenRecover),
+ RecoverAt: sevenRecover,
+ SevenDayRecoverAt: codexQuotaOverdraftTimePtr(sevenRecover),
+ }
+
+ require.True(t, codexQuotaOverdraftStateCoversSignal(state, remaining))
+}
+
+func TestCodexQuotaOverdraftRecoveryClearsWindowState(t *testing.T) {
+ now := time.Date(2026, time.August, 13, 14, 0, 0, 0, time.UTC)
+ account := newCodexOverdraftProbeTestAccount(now)
+ account.Extra["codex_5h_used_percent"] = 0
+ started := now.Add(-time.Hour)
+ recoverAt := now.Add(4 * time.Hour)
+ state := &CodexQuotaOverdraftProbeState{
+ Status: codexQuotaOverdraftProbePassed,
+ QuotaWindow: "5h",
+ CycleKey: "5h:" + formatCodexOverdraftUnix(recoverAt),
+ RecoverAt: codexQuotaOverdraftTimePtr(recoverAt),
+ FiveHourRecoverAt: codexQuotaOverdraftTimePtr(recoverAt),
+ OverdraftStartedAt: codexQuotaOverdraftTimePtr(started),
+ FiveHourStartedAt: codexQuotaOverdraftTimePtr(started),
+ }
+ account.Extra[CodexQuotaOverdraftProbeExtraKey] = state
+ repo := &codexOverdraftProbeRepoStub{account: account}
+ coordinator := &CodexQuotaOverdraftCoordinator{accountRepo: repo, now: func() time.Time { return now }}
+
+ coordinator.observeAccount(account, "gpt-5.4")
+
+ recovered, ok := codexQuotaOverdraftStateFromAccount(account)
+ require.True(t, ok)
+ require.Equal(t, codexQuotaOverdraftProbeRecovered, recovered.Status)
+ require.Equal(t, "quota_recovered", recovered.ReasonCode)
+ require.Nil(t, recovered.OverdraftStartedAt)
+ require.Nil(t, recovered.FiveHourStartedAt)
+ require.Nil(t, recovered.FiveHourRecoverAt)
+ require.Zero(t, repo.tempPauseCalls)
+}
+
+func TestCodexQuotaOverdraftAvailableStateClearsMismatchedStaleRateLimit(t *testing.T) {
+ now := time.Date(2026, time.August, 14, 10, 0, 0, 0, time.UTC)
+ for _, status := range []string{codexQuotaOverdraftProbePassed, codexQuotaOverdraftProbeRecovered} {
+ t.Run(status, func(t *testing.T) {
+ account := newCodexOverdraftProbeTestAccount(now)
+ account.RateLimitedAt = codexQuotaOverdraftTimePtr(now.Add(-time.Minute))
+ account.RateLimitResetAt = codexQuotaOverdraftTimePtr(now.Add(2 * time.Hour))
+ repo := &codexOverdraftProbeRepoStub{account: account}
+ blocker := &codexOverdraftRuntimeBlockerStub{}
+ coordinator := &CodexQuotaOverdraftCoordinator{accountRepo: repo, runtimeBlocker: blocker}
+ signal, _ := codexQuotaOverdraftSignalFromAccount(account, nil, now)
+ state := &CodexQuotaOverdraftProbeState{
+ Status: status,
+ CycleKey: signal.CycleKey,
+ QuotaWindow: signal.Window,
+ ObservedRateLimit: codexQuotaOverdraftTimePtr(now.Add(time.Hour)),
+ }
+ account.Extra[CodexQuotaOverdraftProbeExtraKey] = state
+
+ coordinator.clearQuotaPause(account.ID, state)
+
+ require.Equal(t, 1, repo.clearLimitCalls)
+ require.Nil(t, account.RateLimitResetAt)
+ require.Equal(t, 1, blocker.clearCalls)
+ })
+ }
+}
+
+func TestCodexQuotaOverdraftFailedStateDoesNotClearRateLimit(t *testing.T) {
+ now := time.Date(2026, time.August, 14, 10, 0, 0, 0, time.UTC)
+ account := newCodexOverdraftProbeTestAccount(now)
+ account.RateLimitResetAt = codexQuotaOverdraftTimePtr(now.Add(2 * time.Hour))
+ repo := &codexOverdraftProbeRepoStub{account: account}
+ blocker := &codexOverdraftRuntimeBlockerStub{}
+ coordinator := &CodexQuotaOverdraftCoordinator{accountRepo: repo, runtimeBlocker: blocker}
+ state := &CodexQuotaOverdraftProbeState{
+ Status: codexQuotaOverdraftProbeFailed,
+ ObservedRateLimit: codexQuotaOverdraftTimePtr(now.Add(time.Hour)),
+ }
+
+ coordinator.clearQuotaPause(account.ID, state)
+
+ require.Zero(t, repo.clearLimitCalls)
+ require.NotNil(t, account.RateLimitResetAt)
+ require.Zero(t, blocker.clearCalls)
+}
+
+func TestClassifyCodexQuotaOverdraftProbeResponses(t *testing.T) {
+ status, reason := classifyCodexQuotaOverdraftProbe(http.StatusOK, nil, []byte(`data: {"type":"response.completed"}`))
+ require.Equal(t, "available", status)
+ require.Equal(t, "model_response_ok", reason)
+
+ status, reason = classifyCodexQuotaOverdraftProbe(http.StatusTooManyRequests, nil, []byte(`{"error":{"type":"usage_limit_reached"}}`))
+ 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)
+
+ headers := http.Header{}
+ headers.Set("x-codex-primary-used-percent", "100")
+ headers.Set("x-codex-primary-reset-after-seconds", "3600")
+ headers.Set("x-codex-primary-window-minutes", "300")
+ status, reason = classifyCodexQuotaOverdraftProbe(http.StatusTooManyRequests, headers, []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)
+
+ status, reason = classifyCodexQuotaOverdraftProbe(http.StatusUnauthorized, nil, nil)
+ 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)
+}
+
+func TestCodexQuotaOverdraftProbePayloadAcceptsInjection(t *testing.T) {
+ payload := map[string]any{
+ "model": "gpt-5.4",
+ "input": []map[string]any{{
+ "type": "message",
+ "role": "user",
+ "content": []map[string]string{{"type": "input_text", "text": "hi"}},
+ }},
+ }
+ raw, err := json.Marshal(payload)
+ require.NoError(t, err)
+ updated, changed, err := injectCodexQuotaOverdraft(raw)
+ require.NoError(t, err)
+ require.True(t, changed)
+ require.Contains(t, string(updated), `"custom_tool_call"`)
+}
+
+func newCodexOverdraftPendingState(signal codexQuotaOverdraftSignal, now time.Time) *CodexQuotaOverdraftProbeState {
+ return &CodexQuotaOverdraftProbeState{
+ Status: codexQuotaOverdraftProbePending,
+ QuotaWindow: signal.Window,
+ CycleKey: signal.CycleKey,
+ Limit: codexQuotaOverdraftProbeAttemptLimit,
+ StartedAt: now,
+ RecoverAt: codexQuotaOverdraftTimePtr(signal.RecoverAt),
+ FiveHourRecoverAt: cloneTimePtr(signal.FiveHourRecoverAt),
+ SevenDayRecoverAt: cloneTimePtr(signal.SevenDayRecoverAt),
+ OverdraftStartedAt: nil,
+ }
+}
+
+func formatCodexOverdraftUnix(value time.Time) string {
+ return strconv.FormatInt(value.Unix(), 10)
+}
diff --git a/backend/internal/service/openai_codex_quota_overdraft_test.go b/backend/internal/service/openai_codex_quota_overdraft_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..0b62c1c014d8dbe8b3e8c7233d43b28ef33295c3
--- /dev/null
+++ b/backend/internal/service/openai_codex_quota_overdraft_test.go
@@ -0,0 +1,197 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCodexQuotaOverdraftInjection(t *testing.T) {
+ t.Cleanup(func() { SetCodexQuotaOverdraftEnabled(false) })
+ SetCodexQuotaOverdraftEnabled(true)
+ ctx := WithCodexQuotaOverdraftScheduling(context.Background())
+ svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{CodexQuotaOverdraftEnabled: true}}}
+ oauth := &Account{
+ ID: 77,
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Extra: map[string]any{
+ "codex_5h_used_percent": 95,
+ "codex_5h_reset_at": time.Now().Add(time.Hour).Format(time.RFC3339),
+ },
+ }
+ body := []byte(`{"model":"gpt-5.4","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}]}`)
+
+ updated := svc.prepareCodexQuotaOverdraftBody(ctx, oauth, false, body)
+ require.NotEqual(t, string(body), string(updated))
+ require.True(t, codexQuotaOverdraftWasInjected(ctx, oauth.ID))
+
+ var document codexQuotaOverdraftDocument
+ require.NoError(t, json.Unmarshal(updated, &document))
+ require.Len(t, document.Input, 3)
+ var call, output codexQuotaOverdraftInputItem
+ require.NoError(t, json.Unmarshal(document.Input[1], &call))
+ require.NoError(t, json.Unmarshal(document.Input[2], &output))
+ require.Equal(t, "custom_tool_call", call.Type)
+ require.Equal(t, "custom_tool_call_output", output.Type)
+ require.True(t, strings.HasPrefix(call.CallID, codexQuotaOverdraftCallIDPrefix))
+ require.Equal(t, call.CallID, output.CallID)
+
+ again := svc.prepareCodexQuotaOverdraftBody(ctx, oauth, false, updated)
+ require.Equal(t, string(updated), string(again), "重复处理不能再次注入")
+}
+
+func TestCodexQuotaOverdraftInjectionGuards(t *testing.T) {
+ t.Cleanup(func() { SetCodexQuotaOverdraftEnabled(false) })
+ body := []byte(`{"input":[{"type":"message","role":"user"}]}`)
+ oauth := &Account{
+ ID: 78,
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Extra: map[string]any{
+ "codex_5h_used_percent": 95,
+ "codex_5h_reset_at": time.Now().Add(time.Hour).Format(time.RFC3339),
+ },
+ }
+ enabledCfg := &config.Config{Gateway: config.GatewayConfig{CodexQuotaOverdraftEnabled: true}}
+ svc := &OpenAIGatewayService{cfg: enabledCfg}
+
+ SetCodexQuotaOverdraftEnabled(false)
+ require.Equal(t, string(body), string(svc.prepareCodexQuotaOverdraftBody(WithCodexQuotaOverdraftScheduling(context.Background()), oauth, false, body)))
+
+ SetCodexQuotaOverdraftEnabled(true)
+ underPrearm := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_5h_used_percent": 94}}
+ require.Equal(t, string(body), string(svc.prepareCodexQuotaOverdraftBody(WithCodexQuotaOverdraftScheduling(context.Background()), underPrearm, false, body)), "95% 以下不能注入")
+ require.Equal(t, string(body), string(svc.prepareCodexQuotaOverdraftBody(context.Background(), oauth, false, body)), "未标记的端点不能注入")
+ require.Equal(t, string(body), string(svc.prepareCodexQuotaOverdraftBody(WithCodexQuotaOverdraftScheduling(context.Background()), oauth, true, body)), "compact 不能注入")
+
+ ctx := WithCodexQuotaOverdraftScheduling(context.Background())
+ for _, account := range []*Account{
+ {Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
+ {Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: int64PtrForCodexQuotaOverdraftTest(1)},
+ } {
+ require.Equal(t, string(body), string(svc.prepareCodexQuotaOverdraftBody(ctx, account, false, body)))
+ }
+ agentIdentity := &Account{
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Credentials: map[string]any{openAIAuthModeCredentialKey: OpenAIAuthModeAgentIdentity},
+ Extra: map[string]any{
+ "codex_7d_used_percent": 95,
+ "codex_7d_reset_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
+ },
+ }
+ require.NotEqual(t, string(body), string(svc.prepareCodexQuotaOverdraftBody(ctx, agentIdentity, false, body)), "Agent Identity 必须支持透支请求注入")
+
+ notUserLast := []byte(`{"input":[{"type":"message","role":"assistant"}]}`)
+ require.Equal(t, string(notUserLast), string(svc.prepareCodexQuotaOverdraftBody(ctx, oauth, false, notUserLast)))
+ invalid := []byte(`{"input":`)
+ require.Equal(t, string(invalid), string(svc.prepareCodexQuotaOverdraftBody(ctx, oauth, false, invalid)))
+ oversized := make([]byte, codexQuotaOverdraftMaxBodyBytes+1)
+ require.Equal(t, oversized, svc.prepareCodexQuotaOverdraftBody(ctx, oauth, false, oversized))
+}
+
+func TestCodexQuotaOverdraftSchedulingDoesNotBypassThresholdBelowPrearm(t *testing.T) {
+ t.Cleanup(func() { SetCodexQuotaOverdraftEnabled(false) })
+ SetCodexQuotaOverdraftEnabled(true)
+ ctx := WithCodexQuotaOverdraftScheduling(context.Background())
+ account := &Account{
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Extra: map[string]any{
+ "codex_5h_used_percent": 94,
+ },
+ }
+ quotaCtx := withOpenAIQuotaAutoPauseSettings(ctx, OpsOpenAIAccountQuotaAutoPauseSettings{DefaultThreshold5h: 0.9})
+
+ paused, _ := shouldAutoPauseOpenAIAccountByQuota(quotaCtx, account)
+ require.True(t, paused)
+}
+
+func TestCodexQuotaOverdraftSchedulingOnlyBypassesQuotaThresholds(t *testing.T) {
+ t.Cleanup(func() { SetCodexQuotaOverdraftEnabled(false) })
+ SetCodexQuotaOverdraftEnabled(true)
+ ctx := WithCodexQuotaOverdraftScheduling(context.Background())
+ now := time.Now().UTC()
+ reset := now.Add(time.Hour)
+ account := &Account{
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Status: StatusActive,
+ Schedulable: true,
+ Extra: map[string]any{
+ "codex_5h_used_percent": 100,
+ "codex_5h_reset_at": reset.Format(time.RFC3339),
+ },
+ }
+ quotaCtx := withOpenAIQuotaAutoPauseSettings(ctx, OpsOpenAIAccountQuotaAutoPauseSettings{DefaultThreshold5h: 0.8})
+ paused, _ := shouldAutoPauseOpenAIAccountByQuota(quotaCtx, account)
+ require.False(t, paused)
+
+ account.RateLimitResetAt = &reset
+ require.False(t, account.IsSchedulableForModelWithContext(quotaCtx, "gpt-5.4"), "真实 429 限流仍必须生效")
+ account.RateLimitResetAt = nil
+ account.TempUnschedulableUntil = &reset
+ account.TempUnschedulableReason = BuildTempUnschedReasonPayload("oauth_401", "unauthorized")
+ require.Same(t, account, normalizeCodexQuotaOverdraftAccountForScheduling(quotaCtx, account), "其他临时暂停不能绕过")
+
+ account.TempUnschedulableReason = BuildAccountSchedulingThresholdReason("")
+ normalized := normalizeCodexQuotaOverdraftAccountForScheduling(quotaCtx, account)
+ require.NotSame(t, account, normalized)
+ require.Nil(t, normalized.TempUnschedulableUntil)
+ require.Empty(t, normalized.TempUnschedulableReason)
+ require.NotNil(t, account.TempUnschedulableUntil, "不能修改缓存或数据库账号原对象")
+}
+
+func TestRateLimitServiceCodexQuotaOverdraftDoesNotCreateRuntimeThresholdBlock(t *testing.T) {
+ t.Cleanup(func() { SetCodexQuotaOverdraftEnabled(false) })
+ SetCodexQuotaOverdraftEnabled(true)
+ accountSchedulingThresholdsSF.Forget(SettingKeyAccountSchedulingThresholds)
+ accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{})
+
+ settingsRepo := newMockSettingRepo()
+ settingsRepo.data[SettingKeyAccountSchedulingThresholds] = `{"openai":80}`
+ accountRepo := &rateLimitAccountRepoStub{}
+ runtimeBlocker := &runtimeBlockRecorder{}
+ rl := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil)
+ rl.SetSettingService(NewSettingService(settingsRepo, &config.Config{}))
+ rl.SetAccountRuntimeBlocker(runtimeBlocker)
+ reset := time.Now().UTC().Add(time.Hour)
+ account := &Account{
+ ID: 9001,
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Status: StatusActive,
+ Schedulable: true,
+ Extra: map[string]any{
+ "codex_7d_used_percent": 100,
+ "codex_7d_reset_at": reset.Format(time.RFC3339),
+ },
+ }
+
+ require.True(t, rl.ApplyAccountSchedulingThreshold(context.Background(), account))
+ require.Equal(t, 1, accountRepo.tempCalls, "普通端点仍应持久化阈值暂停")
+ require.Empty(t, runtimeBlocker.reasons, "阈值暂停不能误写为所有请求共享的 runtime blocker")
+}
+
+func int64PtrForCodexQuotaOverdraftTest(value int64) *int64 {
+ return &value
+}
+
+func TestCodexQuotaOverdraftCoordinatorOwnedByGateway(t *testing.T) {
+ gateway := &OpenAIGatewayService{}
+
+ first := gateway.codexQuotaOverdraftCoordinator(nil)
+ second := gateway.codexQuotaOverdraftCoordinator(&TLSFingerprintProfileService{})
+
+ require.NotNil(t, first)
+ require.Same(t, first, second)
+ require.Same(t, gateway, first.runtimeBlocker)
+}
diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go
index a3ba6ebab91096d6f453865eefebb138426b5c5a..a6919bf58df77079a1a1bc81ebf8476392f632ed 100644
--- a/backend/internal/service/openai_gateway_forward.go
+++ b/backend/internal/service/openai_gateway_forward.go
@@ -1035,6 +1035,7 @@ func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool {
}
func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string, isStream bool, promptCacheKey string, isCodexCLI bool) (*http.Request, error) {
+ body = s.prepareCodexQuotaOverdraftBody(ctx, account, isOpenAIResponsesCompactPath(c), body)
// Determine target URL based on account type
var targetURL string
switch account.Type {
diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go
index 1fac0440f6853905cf5fac604e5866d67e8dbd8b..36a96c1fe57612e59e34403893ffb956c6128b78 100644
--- a/backend/internal/service/openai_gateway_passthrough.go
+++ b/backend/internal/service/openai_gateway_passthrough.go
@@ -369,6 +369,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
body []byte,
token string,
) (*http.Request, error) {
+ body = s.prepareCodexQuotaOverdraftBody(ctx, account, isOpenAIResponsesCompactPath(c), body)
targetURL := openaiPlatformAPIURL
switch account.Type {
case AccountTypeOAuth:
diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go
index f7dff0909f6242046cc2b5bc7a29b6386a4b9614..2a6bf9ac6e806e3b4afc9fb338dd84c294a6ac7e 100644
--- a/backend/internal/service/openai_gateway_scheduling.go
+++ b/backend/internal/service/openai_gateway_scheduling.go
@@ -445,6 +445,9 @@ func shouldAutoPauseOpenAIAccountByQuota(ctx context.Context, account *Account)
if account == nil || !account.IsOpenAI() {
return false, openAIQuotaAutoPauseDecision{}
}
+ if codexQuotaOverdraftBypassesSchedulingThreshold(ctx, account) {
+ return false, openAIQuotaAutoPauseDecision{}
+ }
// Per-account explicit-disable flags must take precedence over the global default.
// Without these, leaving the account threshold blank means "use global default",
// so an admin has no way to exempt a single account from auto-pause once a global
@@ -1305,11 +1308,15 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex
func (s *OpenAIGatewayService) listSchedulableAccounts(ctx context.Context, groupID *int64, platform string) ([]Account, error) {
platform = normalizeOpenAICompatiblePlatform(platform)
+ if accounts, handled, err := s.listCodexQuotaOverdraftSchedulableAccounts(ctx, groupID, platform); handled {
+ return accounts, err
+ }
if s.schedulerSnapshot != nil {
accounts, _, err := s.schedulerSnapshot.ListSchedulableAccounts(ctx, groupID, platform, false)
if err != nil {
return accounts, err
}
+ accounts = normalizeCodexQuotaOverdraftAccountsForScheduling(ctx, accounts)
accounts = s.filterOpenAIAccountsBySchedulingThreshold(ctx, accounts)
if platform == PlatformGrok {
accounts = s.filterGrokFreeQuotaAccountsForOpenAI(ctx, accounts)
@@ -1328,6 +1335,7 @@ func (s *OpenAIGatewayService) listSchedulableAccounts(ctx context.Context, grou
if err != nil {
return nil, fmt.Errorf("query accounts failed: %w", err)
}
+ accounts = normalizeCodexQuotaOverdraftAccountsForScheduling(ctx, accounts)
accounts = s.filterOpenAIAccountsBySchedulingThreshold(ctx, accounts)
if platform == PlatformGrok {
accounts = s.filterGrokFreeQuotaAccountsForOpenAI(ctx, accounts)
@@ -1367,6 +1375,7 @@ func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccountBeforeProfit(
}
fresh = current
}
+ fresh = normalizeCodexQuotaOverdraftAccountForScheduling(ctx, fresh)
if !isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx, fresh, platform, requestedModel, requireCompact, requiredCapability) {
return nil
@@ -1436,6 +1445,7 @@ func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDBBeforeProfit(ct
if err != nil || latest == nil {
return nil
}
+ latest = normalizeCodexQuotaOverdraftAccountForScheduling(ctx, latest)
if !s.openAIAccountMatchesSchedulingGroup(latest, groupID) {
return nil
}
@@ -1477,6 +1487,7 @@ func (s *OpenAIGatewayService) getSchedulableAccount(ctx context.Context, accoun
if err != nil || account == nil {
return account, err
}
+ account = normalizeCodexQuotaOverdraftAccountForScheduling(ctx, account)
if s.isOpenAIAccountBlockedBySchedulingThreshold(ctx, account) {
return nil, nil
}
@@ -1517,6 +1528,9 @@ func (s *OpenAIGatewayService) isOpenAIAccountBlockedBySchedulingThreshold(ctx c
if s == nil || s.rateLimitService == nil || account == nil {
return false
}
+ if codexQuotaOverdraftBypassesSchedulingThreshold(ctx, account) {
+ return false
+ }
return s.rateLimitService.ApplyAccountSchedulingThreshold(ctx, account)
}
diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go
index a9200b10b744f7004144bfd69123f0d6dc2ab2d4..a134c76e1844a7fdcd26f2413463f02f3cf8c9c6 100644
--- a/backend/internal/service/openai_gateway_service.go
+++ b/backend/internal/service/openai_gateway_service.go
@@ -416,6 +416,7 @@ type OpenAIGatewayService struct {
concurrencyService *ConcurrencyService
billingService *BillingService
rateLimitService *RateLimitService
+ codexQuotaOverdraft *CodexQuotaOverdraftCoordinator
billingCacheService *BillingCacheService
userGroupRateResolver *userGroupRateResolver
httpUpstream HTTPUpstream
@@ -435,6 +436,7 @@ type OpenAIGatewayService struct {
openaiWSPoolOnce sync.Once
openaiWSStateStoreOnce sync.Once
openaiSchedulerOnce sync.Once
+ codexQuotaOverdraftOnce sync.Once
openaiProxyStreamCircuitOnce sync.Once
openaiWSPassthroughDialerOnce sync.Once
openaiModelTransientOnce sync.Once
@@ -498,6 +500,7 @@ func NewOpenAIGatewayService(
// 拿不到配置,故在此发布进程级开关快照。配置取反义,零值即「强制统一出口开启」。
if cfg != nil {
SetCodexIdentityEnforcementEnabled(!cfg.Gateway.DisableCodexIdentityEnforcement)
+ SetCodexQuotaOverdraftEnabled(cfg.Gateway.CodexQuotaOverdraftEnabled)
}
svc := &OpenAIGatewayService{
accountRepo: accountRepo,
diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go
index c55bcdbeac89b7f58f50daaa80c22964a27628d0..9dfc1724ca28e9c345b7187ab6a59d35f121603e 100644
--- a/backend/internal/service/openai_gateway_usage.go
+++ b/backend/internal/service/openai_gateway_usage.go
@@ -1028,15 +1028,7 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc
if len(updates) == 0 {
return
}
- if !s.getCodexSnapshotThrottle().Allow(accountID, now) {
- return
- }
-
- go func() {
- updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- _ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates)
- }()
+ s.processCodexQuotaOverdraftUsageSnapshot(ctx, accountID, now, updates)
}
func (s *OpenAIGatewayService) UpdateCodexUsageSnapshotFromHeaders(ctx context.Context, accountID int64, headers http.Header) {
diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go
index ce7e8f038666c6818a3af948e309a3bb0c84ee22..3c5c5f03a6d678f85b56372d106a8325ce0b743a 100644
--- a/backend/internal/service/openai_ws_forwarder_ingress.go
+++ b/backend/internal/service/openai_ws_forwarder_ingress.go
@@ -804,7 +804,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
}
turnStart := time.Now()
wroteDownstream := false
- if err := lease.WriteJSONWithContextTimeout(ctx, json.RawMessage(payload), s.openAIWSWriteTimeout()); err != nil {
+ outboundPayload := s.prepareCodexQuotaOverdraftBody(ctx, account, false, payload)
+ if err := lease.WriteJSONWithContextTimeout(ctx, json.RawMessage(outboundPayload), s.openAIWSWriteTimeout()); err != nil {
return nil, wrapOpenAIWSIngressTurnError(
"write_upstream",
fmt.Errorf("write upstream websocket request: %w", err),
diff --git a/backend/internal/service/openai_ws_forwarder_support.go b/backend/internal/service/openai_ws_forwarder_support.go
index 6c6d7c9cf3c2655f013ed58d1a2d0172c098fc98..d5a0ee34b0f9e50357a3503b78d16efc61d37832 100644
--- a/backend/internal/service/openai_ws_forwarder_support.go
+++ b/backend/internal/service/openai_ws_forwarder_support.go
@@ -524,6 +524,7 @@ func (s *OpenAIGatewayService) resolveAccountByPreviousResponseIDForCapability(
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
return 0, nil, "", nil
}
+ latest = normalizeCodexQuotaOverdraftAccountForScheduling(ctx, latest)
if shouldClearStickySession(latest, requestedModel) || !latest.IsOpenAI() || !latest.IsSchedulable() {
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
return 0, nil, "", nil
diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go
index e0dbd58bd4f504b32112d9fc5a0c269366330be6..5ea286779bcf83b75760275bcb1789c5cda367bd 100644
--- a/backend/internal/service/openai_ws_forwarder_v2.go
+++ b/backend/internal/service/openai_ws_forwarder_v2.go
@@ -324,6 +324,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
); err != nil {
return nil, err
}
+ payload = s.prepareCodexQuotaOverdraftPayload(ctx, account, payload)
if err := lease.WriteJSONWithContextTimeout(ctx, payload, s.openAIWSWriteTimeout()); err != nil {
lease.MarkBroken()
diff --git a/backend/internal/service/openai_ws_ratelimit_signal_test.go b/backend/internal/service/openai_ws_ratelimit_signal_test.go
index 9d1fdaf76515237eaf5e6b32a679601d8e43c5af..95a1648dbae6e6b1195183d7545ffe01d84368aa 100644
--- a/backend/internal/service/openai_ws_ratelimit_signal_test.go
+++ b/backend/internal/service/openai_ws_ratelimit_signal_test.go
@@ -486,6 +486,41 @@ func TestOpenAIGatewayService_UpdateCodexUsageSnapshot_ThrottlesExtraWrites(t *t
}
}
+func TestOpenAIGatewayService_UpdateCodexUsageSnapshot_PrearmBypassesThrottle(t *testing.T) {
+ repo := &openAICodexSnapshotAsyncRepo{
+ updateExtraCh: make(chan map[string]any, 2),
+ }
+ svc := &OpenAIGatewayService{
+ accountRepo: repo,
+ codexSnapshotThrottle: newAccountWriteThrottle(time.Hour),
+ }
+ belowPrearm := &OpenAICodexUsageSnapshot{
+ PrimaryUsedPercent: ptrFloat64WS(94),
+ PrimaryResetAfterSeconds: ptrIntWS(3600),
+ PrimaryWindowMinutes: ptrIntWS(10080),
+ SecondaryUsedPercent: ptrFloat64WS(22),
+ SecondaryResetAfterSeconds: ptrIntWS(1200),
+ SecondaryWindowMinutes: ptrIntWS(300),
+ }
+ atPrearm := *belowPrearm
+ atPrearm.PrimaryUsedPercent = ptrFloat64WS(95)
+
+ svc.updateCodexUsageSnapshot(context.Background(), 778, belowPrearm)
+ select {
+ case <-repo.updateExtraCh:
+ case <-time.After(2 * time.Second):
+ t.Fatal("等待首次 codex 快照落库超时")
+ }
+
+ svc.updateCodexUsageSnapshot(context.Background(), 778, &atPrearm)
+ select {
+ case updates := <-repo.updateExtraCh:
+ require.Equal(t, 95.0, updates["codex_7d_used_percent"])
+ case <-time.After(2 * time.Second):
+ t.Fatal("95% 预热快照不应被写入节流拦截")
+ }
+}
+
func ptrFloat64WS(v float64) *float64 { return &v }
func ptrIntWS(v int) *int { return &v }
diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go
index 775e26980ce0bc70e15f895f9af8be22ccd7aa7e..3bf082a92a8d0759815a52ca68f2ba95698a2238 100644
--- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go
+++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go
@@ -752,6 +752,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, blocked.Message, blocked)
}
firstClientMessage = updatedFirst
+ firstClientMessage = s.prepareCodexQuotaOverdraftBody(ctx, account, false, firstClientMessage)
// 在 policy filter 之后再提取 service_tier / reasoning_effort 用于
// usage 上报:filter
@@ -1034,6 +1035,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
// 覆盖(Store(nil)),因为 OpenAI 上游对该帧实际不传
// service_tier 时按 default 处理,billing 应如实反映。
if policyErr == nil && blocked == nil && isResponseCreate {
+ out = s.prepareCodexQuotaOverdraftBody(ctx, account, false, out)
usageMeta.updateFromResponseCreate(out, model, requestModelForThisFrame)
acceptedTurn = true
}
diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go
index ee5b09bbb4b18afcc9f423b74ebea2419a82392b..e51ce491a7ac4d1ebc7c10485519d4e81f30ed2a 100644
--- a/backend/internal/service/ratelimit_service.go
+++ b/backend/internal/service/ratelimit_service.go
@@ -149,6 +149,9 @@ func (s *RateLimitService) ApplyAccountSchedulingThreshold(ctx context.Context,
if !account.IsActive() || !account.Schedulable {
return false
}
+ if codexQuotaOverdraftBypassesSchedulingThreshold(ctx, account) {
+ return false
+ }
now := time.Now().UTC()
thresholds := s.settingService.GetAccountSchedulingThresholds(ctx)
@@ -176,7 +179,7 @@ func (s *RateLimitService) ApplyAccountSchedulingThreshold(ctx context.Context,
account.TempUnschedulableUntil = cloneTimePtr(decision.Until)
account.TempUnschedulableReason = reason
- s.notifyAccountSchedulingBlocked(account, *decision.Until, "account_scheduling_threshold")
+ s.notifyCodexQuotaOverdraftAwareSchedulingBlock(account, *decision.Until)
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, *decision.Until, reason); err != nil {
slog.Warn("account_scheduling_threshold_set_temp_unsched_failed",
diff --git a/backend/internal/service/update_service.go b/backend/internal/service/update_service.go
index 82df3bb024605360f36e75daa3891b1483995d27..659ee2c33189c22945278c5d85468cb6b72906ae 100644
--- a/backend/internal/service/update_service.go
+++ b/backend/internal/service/update_service.go
@@ -20,17 +20,21 @@ import (
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "golang.org/x/mod/semver"
)
var (
ErrNoUpdateAvailable = infraerrors.Conflict("ALREADY_UP_TO_DATE", "no update available; current version is latest")
ErrRollbackVersionNotAllowed = infraerrors.BadRequest("ROLLBACK_VERSION_NOT_ALLOWED", "version is not in the allowed rollback list")
+ ErrSourceBuildUpdateRequired = infraerrors.Conflict("SOURCE_BUILD_UPDATE_REQUIRED", "source builds must be updated with git pull and rebuilt")
)
const (
- updateCacheKey = "update_check_cache"
- updateCacheTTL = 1200 // 20 minutes
- githubRepo = "Wei-Shaw/sub2api"
+ updateCacheTTL = 1200 // 20 minutes
+ githubRepo = "DeanZFC/sub2api-overdraft"
+ githubSourceBranch = "codex-overdraft"
+ githubForkVersionFile = "FORK_VERSION"
+ githubSourceUpdateURL = "https://github.com/DeanZFC/sub2api-overdraft/commits/codex-overdraft"
// Security: allowed download domains for updates
allowedDownloadHost = "github.com"
@@ -55,6 +59,7 @@ type UpdateCache interface {
type GitHubReleaseClient interface {
FetchLatestRelease(ctx context.Context, repo string) (*GitHubRelease, error)
FetchRecentReleases(ctx context.Context, repo string, perPage int) ([]*GitHubRelease, error)
+ FetchRepositoryFile(ctx context.Context, repo, ref, filePath string) ([]byte, error)
DownloadFile(ctx context.Context, url, dest string, maxSize int64) error
FetchChecksumFile(ctx context.Context, url string) ([]byte, error)
}
@@ -138,8 +143,15 @@ func (s *UpdateService) CheckUpdate(ctx context.Context, force bool) (*UpdateInf
}
}
- // Fetch from GitHub
- info, err := s.fetchLatestRelease(ctx)
+ // Source builds track the Fork branch version file. Release builds track the
+ // Fork's GitHub Releases and remain eligible for binary replacement.
+ var info *UpdateInfo
+ var err error
+ if s.isSourceBuild() {
+ info, err = s.fetchLatestSourceVersion(ctx)
+ } else {
+ info, err = s.fetchLatestRelease(ctx)
+ }
if err != nil {
// Return cached on error
if cached, cacheErr := s.getFromCache(ctx); cacheErr == nil && cached != nil {
@@ -163,6 +175,9 @@ func (s *UpdateService) CheckUpdate(ctx context.Context, force bool) (*UpdateInf
// PerformUpdate downloads and applies the update
// Uses atomic file replacement pattern for safe in-place updates
func (s *UpdateService) PerformUpdate(ctx context.Context) error {
+ if s.isSourceBuild() {
+ return ErrSourceBuildUpdateRequired
+ }
info, err := s.CheckUpdate(ctx, true)
if err != nil {
return err
@@ -307,6 +322,9 @@ func (s *UpdateService) Rollback() error {
// strictly older than the current version (the current version itself is excluded),
// newest first. Draft and prerelease entries are skipped.
func (s *UpdateService) ListRollbackVersions(ctx context.Context) ([]RollbackVersion, error) {
+ if s.isSourceBuild() {
+ return []RollbackVersion{}, nil
+ }
releases, err := s.fetchRollbackCandidates(ctx)
if err != nil {
return nil, err
@@ -327,6 +345,9 @@ func (s *UpdateService) ListRollbackVersions(ctx context.Context) ([]RollbackVer
// The target must be one of the versions returned by ListRollbackVersions;
// anything else (including the current version) is rejected.
func (s *UpdateService) RollbackToVersion(ctx context.Context, version string) error {
+ if s.isSourceBuild() {
+ return ErrSourceBuildUpdateRequired
+ }
target := strings.TrimPrefix(strings.TrimSpace(version), "v")
if target == "" {
return ErrRollbackVersionNotAllowed
@@ -432,6 +453,33 @@ func (s *UpdateService) fetchLatestRelease(ctx context.Context) (*UpdateInfo, er
}, nil
}
+func (s *UpdateService) fetchLatestSourceVersion(ctx context.Context) (*UpdateInfo, error) {
+ raw, err := s.githubClient.FetchRepositoryFile(ctx, githubRepo, githubSourceBranch, githubForkVersionFile)
+ if err != nil {
+ return nil, err
+ }
+ latestVersion := strings.TrimSpace(string(raw))
+ if canonicalVersion(latestVersion) == "" {
+ return nil, fmt.Errorf("Fork version file contains invalid semantic version %q", latestVersion)
+ }
+
+ return &UpdateInfo{
+ CurrentVersion: s.currentVersion,
+ LatestVersion: latestVersion,
+ HasUpdate: compareVersions(s.currentVersion, latestVersion) < 0,
+ ReleaseInfo: &ReleaseInfo{
+ Name: "sub2api-overdraft " + latestVersion,
+ HTMLURL: githubSourceUpdateURL,
+ },
+ Cached: false,
+ BuildType: s.buildType,
+ }, nil
+}
+
+func (s *UpdateService) isSourceBuild() bool {
+ return strings.EqualFold(strings.TrimSpace(s.buildType), "source")
+}
+
func (s *UpdateService) downloadFile(ctx context.Context, downloadURL, dest string) error {
return s.githubClient.DownloadFile(ctx, downloadURL, dest, maxDownloadSize)
}
@@ -603,6 +651,8 @@ func (s *UpdateService) getFromCache(ctx context.Context) (*UpdateInfo, error) {
Latest string `json:"latest"`
ReleaseInfo *ReleaseInfo `json:"release_info"`
Timestamp int64 `json:"timestamp"`
+ Repository string `json:"repository"`
+ BuildType string `json:"build_type"`
}
if err := json.Unmarshal([]byte(data), &cached); err != nil {
return nil, err
@@ -611,6 +661,9 @@ func (s *UpdateService) getFromCache(ctx context.Context) (*UpdateInfo, error) {
if time.Now().Unix()-cached.Timestamp > updateCacheTTL {
return nil, fmt.Errorf("cache expired")
}
+ if cached.Repository != githubRepo || cached.BuildType != s.buildType {
+ return nil, fmt.Errorf("cache belongs to a different update source")
+ }
return &UpdateInfo{
CurrentVersion: s.currentVersion,
@@ -627,10 +680,14 @@ func (s *UpdateService) saveToCache(ctx context.Context, info *UpdateInfo) {
Latest string `json:"latest"`
ReleaseInfo *ReleaseInfo `json:"release_info"`
Timestamp int64 `json:"timestamp"`
+ Repository string `json:"repository"`
+ BuildType string `json:"build_type"`
}{
Latest: info.LatestVersion,
ReleaseInfo: info.ReleaseInfo,
Timestamp: time.Now().Unix(),
+ Repository: githubRepo,
+ BuildType: s.buildType,
}
data, _ := json.Marshal(cacheData)
@@ -639,6 +696,12 @@ func (s *UpdateService) saveToCache(ctx context.Context, info *UpdateInfo) {
// compareVersions compares two semantic versions
func compareVersions(current, latest string) int {
+ currentSemver := canonicalVersion(current)
+ latestSemver := canonicalVersion(latest)
+ if currentSemver != "" && latestSemver != "" {
+ return semver.Compare(currentSemver, latestSemver)
+ }
+
currentParts := parseVersion(current)
latestParts := parseVersion(latest)
@@ -653,6 +716,20 @@ func compareVersions(current, latest string) int {
return 0
}
+func canonicalVersion(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return ""
+ }
+ if !strings.HasPrefix(value, "v") {
+ value = "v" + value
+ }
+ if !semver.IsValid(value) {
+ return ""
+ }
+ return value
+}
+
func parseVersion(v string) [3]int {
v = strings.TrimPrefix(v, "v")
parts := strings.Split(v, ".")
diff --git a/backend/internal/service/update_service_test.go b/backend/internal/service/update_service_test.go
index 243e25d1e47d891434afabb274c709abbdaac935..99ef67944e07fe28af9ddc73161a616ba2d4e6d1 100644
--- a/backend/internal/service/update_service_test.go
+++ b/backend/internal/service/update_service_test.go
@@ -28,12 +28,20 @@ func (s *updateServiceCacheStub) SetUpdateInfo(_ context.Context, data string, _
}
type updateServiceGitHubClientStub struct {
- release *GitHubRelease
- recentReleases []*GitHubRelease
- recentErr error
+ release *GitHubRelease
+ recentReleases []*GitHubRelease
+ recentErr error
+ repositoryFile []byte
+ repositoryFileErr error
+ latestRepo string
+ fileRepo string
+ fileRef string
+ filePath string
+ fileCalls int
}
-func (s *updateServiceGitHubClientStub) FetchLatestRelease(context.Context, string) (*GitHubRelease, error) {
+func (s *updateServiceGitHubClientStub) FetchLatestRelease(_ context.Context, repo string) (*GitHubRelease, error) {
+ s.latestRepo = repo
return s.release, nil
}
@@ -41,6 +49,14 @@ func (s *updateServiceGitHubClientStub) FetchRecentReleases(context.Context, str
return s.recentReleases, s.recentErr
}
+func (s *updateServiceGitHubClientStub) FetchRepositoryFile(_ context.Context, repo, ref, filePath string) ([]byte, error) {
+ s.fileCalls++
+ s.fileRepo = repo
+ s.fileRef = ref
+ s.filePath = filePath
+ return s.repositoryFile, s.repositoryFileErr
+}
+
func (s *updateServiceGitHubClientStub) DownloadFile(context.Context, string, string, int64) error {
panic("DownloadFile should not be called when no update is available")
}
@@ -69,6 +85,72 @@ func TestUpdateServicePerformUpdateNoUpdateReturnsSentinel(t *testing.T) {
require.ErrorIs(t, err, ErrNoUpdateAvailable)
}
+func TestUpdateServiceSourceBuildTracksForkVersionFile(t *testing.T) {
+ client := &updateServiceGitHubClientStub{repositoryFile: []byte("0.1.176-overdraft.2\n")}
+ svc := NewUpdateService(
+ &updateServiceCacheStub{},
+ client,
+ "0.1.176-overdraft.1",
+ "source",
+ )
+
+ info, err := svc.CheckUpdate(context.Background(), true)
+
+ require.NoError(t, err)
+ require.Equal(t, "0.1.176-overdraft.1", info.CurrentVersion)
+ require.Equal(t, "0.1.176-overdraft.2", info.LatestVersion)
+ require.True(t, info.HasUpdate)
+ require.Equal(t, "source", info.BuildType)
+ require.Equal(t, githubSourceUpdateURL, info.ReleaseInfo.HTMLURL)
+ require.Equal(t, githubRepo, client.fileRepo)
+ require.Equal(t, githubSourceBranch, client.fileRef)
+ require.Equal(t, githubForkVersionFile, client.filePath)
+ require.Empty(t, client.latestRepo, "源码构建不能查询官方或 Fork 的二进制 Release")
+}
+
+func TestUpdateServiceSourceBuildIgnoresLegacyOfficialCache(t *testing.T) {
+ cache := &updateServiceCacheStub{data: `{"latest":"0.1.176","timestamp":4102444800}`}
+ client := &updateServiceGitHubClientStub{repositoryFile: []byte("0.1.176-overdraft.1\n")}
+ svc := NewUpdateService(cache, client, "0.1.176-overdraft.1", "source")
+
+ info, err := svc.CheckUpdate(context.Background(), false)
+
+ require.NoError(t, err)
+ require.False(t, info.HasUpdate)
+ require.Equal(t, "0.1.176-overdraft.1", info.LatestVersion)
+ require.Equal(t, 1, client.fileCalls)
+}
+
+func TestUpdateServiceSourceBuildRejectsBinaryUpdate(t *testing.T) {
+ svc := NewUpdateService(
+ &updateServiceCacheStub{},
+ &updateServiceGitHubClientStub{},
+ "0.1.176-overdraft.1",
+ "source",
+ )
+
+ err := svc.PerformUpdate(context.Background())
+
+ require.ErrorIs(t, err, ErrSourceBuildUpdateRequired)
+}
+
+func TestUpdateServiceReleaseBuildUsesForkRepository(t *testing.T) {
+ client := &updateServiceGitHubClientStub{release: &GitHubRelease{TagName: "v0.1.177-overdraft.1"}}
+ svc := NewUpdateService(&updateServiceCacheStub{}, client, "0.1.176-overdraft.1", "release")
+
+ info, err := svc.CheckUpdate(context.Background(), true)
+
+ require.NoError(t, err)
+ require.True(t, info.HasUpdate)
+ require.Equal(t, githubRepo, client.latestRepo)
+}
+
+func TestCompareVersionsSupportsForkPrereleaseRevisions(t *testing.T) {
+ require.Less(t, compareVersions("0.1.176-overdraft.1", "0.1.176-overdraft.2"), 0)
+ require.Zero(t, compareVersions("v0.1.176-overdraft.2", "0.1.176-overdraft.2"))
+ require.Greater(t, compareVersions("0.1.177-overdraft.1", "0.1.176-overdraft.9"), 0)
+}
+
func newRollbackTestService(current string, releases []*GitHubRelease) *UpdateService {
return NewUpdateService(
&updateServiceCacheStub{},
diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go
index 2e26c0035c22c55a4b89eb7bd39cc2b05ed2f7f4..007f3ba6ca89c6e01a86e8878bc2639d1c20e1bc 100644
--- a/backend/internal/service/wire.go
+++ b/backend/internal/service/wire.go
@@ -217,6 +217,7 @@ func ProvideAccountUsageService(
tlsFPProfileService,
)
service.agentIdentityWS = openAIGatewayService
+ service.SetCodexQuotaOverdraftCoordinator(openAIGatewayService.codexQuotaOverdraftCoordinator(tlsFPProfileService))
return service
}
@@ -244,6 +245,7 @@ func ProvideAccountTestService(
)
service.agentIdentityWS = openAIGatewayService
service.SetSettingService(settingService)
+ service.SetCodexQuotaOverdraftCoordinator(openAIGatewayService.codexQuotaOverdraftCoordinator(tlsFPProfileService))
return service
}
diff --git a/deploy/.env.example b/deploy/.env.example
index 6beca88308572880c2ffdad0ff051c571ac223ff..da4b71fc93cb85084e669ae94e65580bf37a76f3 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -280,6 +280,9 @@ RATE_LIMIT_OVERLOAD_COOLDOWN_MINUTES=10
#
# 默认:false
GATEWAY_FORCE_CODEX_CLI=false
+# sub2api-overdraft: Codex 5h/7d 额度达到 100% 后启用真实探测和透支调度。
+# 设为 false 可立即恢复上游 Sub2API 的调度和请求行为。
+GATEWAY_CODEX_QUOTA_OVERDRAFT_ENABLED=true
# OpenAI /responses/compact 上游模型(默认 gpt-5.4)。
# 当 compact 端点暂未支持更新模型时,可通过这里降级规避失败。
GATEWAY_OPENAI_COMPACT_MODEL=gpt-5.4
diff --git a/deploy/Dockerfile b/deploy/Dockerfile
index 325520f308fb279dfabe1e6826f5f4f3c93413c4..30c7a1a240f1c74f3f46a868b1bffe309a732e21 100644
--- a/deploy/Dockerfile
+++ b/deploy/Dockerfile
@@ -7,7 +7,7 @@
# =============================================================================
ARG NODE_IMAGE=node:24-alpine
-ARG GOLANG_IMAGE=golang:1.26.5-alpine
+ARG GOLANG_IMAGE=golang:1.26.6-alpine
ARG ALPINE_IMAGE=alpine:3.20
ARG GOPROXY=https://goproxy.cn,direct
ARG GOSUMDB=sum.golang.google.cn
diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml
index 095c550993527ea2b53871088f58b02e4082fc63..cd71e4a3d60fd8d9e5db3c8e8b96d28b49e6720c 100644
--- a/deploy/config.example.yaml
+++ b/deploy/config.example.yaml
@@ -4,7 +4,7 @@
# Copy this file to /etc/sub2api/config.yaml and modify as needed
# 复制此文件到 /etc/sub2api/config.yaml 并根据需要修改
#
-# Documentation / 文档: https://github.com/Wei-Shaw/sub2api
+# Documentation / 文档: https://github.com/DeanZFC/sub2api-overdraft
# =============================================================================
# Server Configuration
@@ -287,6 +287,11 @@ gateway:
# 默认 false:保持纯文本 Codex 请求不被改写;客户端显式提供 image_generation tool 时,
# 仍会在分组允许图片生成的情况下正常转发。
codex_image_generation_bridge_enabled: false
+ # Experimental: allow eligible OpenAI OAuth Codex accounts to keep serving
+ # after the reported 5h/7d quota reaches the scheduling threshold.
+ # 实验性 Codex 额度透支:为符合条件的 OAuth 请求注入空操作工具调用,并忽略
+ # 5h/7d 用量百分比预暂停。真实上游 429 仍会触发账号冷却。此部署示例默认开启。
+ codex_quota_overdraft_enabled: true
# Optional: template file used to build the final top-level Codex `instructions`.
# 可选:用于构建最终 Codex 顶层 `instructions` 的模板文件路径。
#
diff --git a/deploy/docker-compose.overdraft.yml b/deploy/docker-compose.overdraft.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0ba503eda086a93e2e2fe4e9c3c612644e2c38cf
--- /dev/null
+++ b/deploy/docker-compose.overdraft.yml
@@ -0,0 +1,18 @@
+# sub2api-overdraft source-build overlay.
+# Use together with docker-compose.local.yml from this directory:
+# docker compose -f docker-compose.local.yml -f docker-compose.overdraft.yml up -d --build
+
+services:
+ sub2api:
+ image: sub2api-overdraft:local
+ build:
+ context: ..
+ dockerfile: Dockerfile
+ args:
+ # Keep this empty so source builds always read the tracked FORK_VERSION.
+ # This avoids unrelated host BUILD_VERSION values overriding the Fork version.
+ VERSION: ""
+ COMMIT: ${BUILD_COMMIT:-local}
+ BUILD_TYPE: source
+ environment:
+ - GATEWAY_CODEX_QUOTA_OVERDRAFT_ENABLED=${GATEWAY_CODEX_QUOTA_OVERDRAFT_ENABLED:-true}
diff --git a/frontend/index.html b/frontend/index.html
index 245db62961e97784b3bc65a8c399fea279c9956a..e6b3b8061ccc4013571b3d01728dbb8296c5bf67 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>Sub2API - AI API Gateway</title>
+ <title>sub2api-overdraft - AI API Gateway</title>
</head>
<body>
<div id="app"></div>
diff --git a/frontend/src/components/account/AccountStatusIndicator.vue b/frontend/src/components/account/AccountStatusIndicator.vue
index 4130ecd8ec8b45ecf90fc307c80368f2105f8710..ee81ad9edd09678ae74193da6d30ec825ff580ab 100644
--- a/frontend/src/components/account/AccountStatusIndicator.vue
+++ b/frontend/src/components/account/AccountStatusIndicator.vue
@@ -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') {
diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue
index 485b4b8701020a68bb1b38606d68cd3991c87e0b..cb8e082baf3c5f6dc6aafce99aaf136d9c60173b 100644
--- a/frontend/src/components/account/AccountUsageCell.vue
+++ b/frontend/src/components/account/AccountUsageCell.vue
@@ -119,12 +119,17 @@
<!-- OpenAI OAuth accounts: single source from /usage API -->
<template v-else-if="account.platform === 'openai' && account.type === 'oauth'">
<div v-if="hasOpenAIUsageFallback" class="space-y-1">
+ <CodexOverdraftStatus :state="usageInfo?.codex_quota_overdraft" />
<UsageProgressBar
v-if="usageInfo?.five_hour"
label="5h"
:utilization="usageInfo.five_hour.utilization"
:resets-at="usageInfo.five_hour.resets_at"
:window-stats="usageInfo.five_hour.window_stats"
+ :overdraft-active="usageInfo.five_hour.overdraft_active"
+ :overdraft-stats="usageInfo.five_hour.overdraft_stats"
+ :overdraft-started-at="usageInfo.five_hour.overdraft_started_at"
+ :overdraft-recover-at="usageInfo.five_hour.overdraft_recover_at"
:show-now-when-idle="true"
color="indigo"
/>
@@ -134,6 +139,10 @@
:utilization="usageInfo.seven_day.utilization"
:resets-at="usageInfo.seven_day.resets_at"
:window-stats="usageInfo.seven_day.window_stats"
+ :overdraft-active="usageInfo.seven_day.overdraft_active"
+ :overdraft-stats="usageInfo.seven_day.overdraft_stats"
+ :overdraft-started-at="usageInfo.seven_day.overdraft_started_at"
+ :overdraft-recover-at="usageInfo.seven_day.overdraft_recover_at"
:show-now-when-idle="true"
color="emerald"
/>
@@ -622,6 +631,7 @@ import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
import { enqueueUsageRequest } from '@/utils/usageLoadQueue'
import { formatCompactNumber } from '@/utils/format'
import UsageProgressBar from './UsageProgressBar.vue'
+import CodexOverdraftStatus from './CodexOverdraftStatus.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue'
import OpenAIQuotaResetCell from './OpenAIQuotaResetCell.vue'
import GrokQuotaProbeCell from './GrokQuotaProbeCell.vue'
@@ -731,7 +741,7 @@ const geminiUsageAvailable = computed(() => {
const hasOpenAIUsageFallback = computed(() => {
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return false
- return !!usageInfo.value?.five_hour || !!usageInfo.value?.seven_day
+ return !!usageInfo.value?.five_hour || !!usageInfo.value?.seven_day || !!usageInfo.value?.codex_quota_overdraft
})
const openAIUsageRefreshKey = computed(() => buildOpenAIUsageRefreshKey(props.account))
diff --git a/frontend/src/components/account/CodexOverdraftStats.vue b/frontend/src/components/account/CodexOverdraftStats.vue
new file mode 100644
index 0000000000000000000000000000000000000000..db846462ebe4ad53b6685be89f5c5702cfc96931
--- /dev/null
+++ b/frontend/src/components/account/CodexOverdraftStats.vue
@@ -0,0 +1,50 @@
+<template>
+ <div
+ v-if="active"
+ class="mb-0.5 flex items-center gap-1.5 text-[9px] text-red-600 dark:text-red-400"
+ :title="title"
+ >
+ <span class="rounded bg-red-50 px-1.5 py-0.5 font-medium dark:bg-red-950/40">
+ {{ t('usage.overdraftActive') }}
+ </span>
+ <span v-if="stats" class="text-gray-500 dark:text-gray-400">
+ {{ requests }} req · {{ tokens }} · ${{ cost }}
+ </span>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { useI18n } from 'vue-i18n'
+import type { WindowStats } from '@/types'
+import { formatCompactNumber } from '@/utils/format'
+
+const props = defineProps<{
+ active?: boolean
+ stats?: WindowStats | null
+ startedAt?: string | null
+ recoverAt?: string | null
+}>()
+
+const { t } = useI18n()
+
+const requests = computed(() => {
+ if (!props.stats) return ''
+ return formatCompactNumber(props.stats.requests, { allowBillions: false })
+})
+
+const tokens = computed(() => {
+ if (!props.stats) return ''
+ return formatCompactNumber(props.stats.tokens)
+})
+
+const cost = computed(() => {
+ if (!props.stats) return '0.00'
+ return props.stats.cost.toFixed(2)
+})
+
+const title = computed(() => {
+ if (!props.recoverAt) return t('usage.overdraftActive')
+ return `${t('usage.overdraftActive')} · ${t('usage.overdraftRecoverAt')}: ${new Date(props.recoverAt).toLocaleString()}`
+})
+</script>
diff --git a/frontend/src/components/account/CodexOverdraftStatus.vue b/frontend/src/components/account/CodexOverdraftStatus.vue
new file mode 100644
index 0000000000000000000000000000000000000000..cab0a1df724f7de8b9e93dbf345ac7724b5e52a9
--- /dev/null
+++ b/frontend/src/components/account/CodexOverdraftStatus.vue
@@ -0,0 +1,88 @@
+<template>
+ <div
+ v-if="status"
+ class="flex items-center gap-1.5 text-[9px]"
+ :class="status.textClass"
+ :title="status.title"
+ >
+ <span class="rounded px-1.5 py-0.5 font-medium" :class="status.badgeClass">
+ {{ status.label }}
+ </span>
+ <span v-if="status.detail" class="text-gray-500 dark:text-gray-400">
+ {{ status.detail }}
+ </span>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { useI18n } from 'vue-i18n'
+import type { CodexQuotaOverdraftProbeState } from '@/types'
+
+const props = defineProps<{
+ state?: CodexQuotaOverdraftProbeState | null
+}>()
+
+const { t } = useI18n()
+
+const status = computed(() => {
+ const probe = props.state
+ if (!probe) return null
+
+ const attempts = Math.max(0, probe.attempts || 0)
+ const limit = Math.max(1, probe.limit || 1)
+ const windowLabel = probe.quota_window === 'multiple' ? '5h / 7d' : probe.quota_window
+ const testedAt = probe.tested_at ? new Date(probe.tested_at).toLocaleString() : ''
+ const recoverAt = probe.recover_at ? new Date(probe.recover_at).toLocaleString() : ''
+ 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}`)
+
+ switch (probe.status) {
+ case 'pending':
+ return {
+ label: t('usage.overdraftProbePending'),
+ detail: `${attempts}/${limit} · ${windowLabel}`,
+ title: titleParts.join(' · '),
+ textClass: 'text-amber-600 dark:text-amber-400',
+ badgeClass: 'bg-amber-50 dark:bg-amber-950/40'
+ }
+ case 'passed':
+ return {
+ label: t('usage.overdraftActive'),
+ detail: `${attempts}/${limit} · ${windowLabel}`,
+ title: titleParts.join(' · '),
+ textClass: 'text-red-600 dark:text-red-400',
+ badgeClass: 'bg-red-50 dark:bg-red-950/40'
+ }
+ case 'failed':
+ return {
+ label: t('usage.overdraftProbeFailed'),
+ detail: `${attempts}/${limit} · ${windowLabel}`,
+ title: titleParts.join(' · '),
+ textClass: 'text-red-600 dark:text-red-400',
+ badgeClass: 'bg-red-50 dark:bg-red-950/40'
+ }
+ case 'inconclusive':
+ return {
+ label: t('usage.overdraftProbeInconclusive'),
+ detail: `${attempts}/${limit} · ${windowLabel}`,
+ title: titleParts.join(' · '),
+ textClass: 'text-amber-600 dark:text-amber-400',
+ badgeClass: 'bg-amber-50 dark:bg-amber-950/40'
+ }
+ case 'recovered':
+ return {
+ label: t('usage.overdraftRecovered'),
+ detail: windowLabel,
+ title: titleParts.join(' · '),
+ textClass: 'text-emerald-600 dark:text-emerald-400',
+ badgeClass: 'bg-emerald-50 dark:bg-emerald-950/40'
+ }
+ default:
+ return null
+ }
+})
+</script>
diff --git a/frontend/src/components/account/UsageProgressBar.vue b/frontend/src/components/account/UsageProgressBar.vue
index 2f8b9a88a7ade8fb57871dacd5aa8d1f3e0490df..f2a22c8de8ba395d9c5edc1e81d69bca1dd6a45f 100644
--- a/frontend/src/components/account/UsageProgressBar.vue
+++ b/frontend/src/components/account/UsageProgressBar.vue
@@ -25,6 +25,13 @@
</div>
</div>
+ <CodexOverdraftStats
+ :active="overdraftActive"
+ :stats="overdraftStats"
+ :started-at="overdraftStartedAt"
+ :recover-at="overdraftRecoverAt"
+ />
+
<!-- Progress bar row -->
<div class="flex items-center gap-1">
<!-- Label badge (fixed width for alignment) -->
@@ -61,6 +68,7 @@ import { useIntervalFn } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import type { WindowStats } from '@/types'
import { formatCompactNumber } from '@/utils/format'
+import CodexOverdraftStats from './CodexOverdraftStats.vue'
const props = defineProps<{
label: string
@@ -70,6 +78,10 @@ const props = defineProps<{
windowStats?: WindowStats | null
showNowWhenIdle?: boolean
remainingCapacity?: boolean
+ overdraftActive?: boolean
+ overdraftStats?: WindowStats | null
+ overdraftStartedAt?: string | null
+ overdraftRecoverAt?: string | null
}>()
const { t } = useI18n()
diff --git a/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts b/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts
index e38f43aa6ab3e7c300422ecb4250baee0733c8cb..233ffb3d03c9ce4e020c4cf86237145a24e24c48 100644
--- a/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts
+++ b/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts
@@ -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: {
diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
index ff0e94d6647ec290dbb79f366228d75055254f8f..c3510ef75da50a05e686a11440d9a6b0a70ef6c1 100644
--- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
+++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
@@ -615,6 +615,99 @@ describe('AccountUsageCell', () => {
expect(wrapper.text()).toContain('7d|100|106540000')
})
+ it('OpenAI OAuth 会显示单次确认的限额状态', async () => {
+ getUsage.mockResolvedValue({
+ five_hour: {
+ utilization: 100,
+ resets_at: '2099-03-07T12:00:00Z',
+ remaining_seconds: 3600
+ },
+ seven_day: null,
+ codex_quota_overdraft: {
+ status: 'failed',
+ quota_window: '5h',
+ cycle_key: '5h:4076577600',
+ attempts: 1,
+ limit: 1,
+ model: 'gpt-5.5',
+ reason_code: 'quota_limited',
+ started_at: '2099-03-07T10:00:00Z',
+ tested_at: '2099-03-07T10:01:00Z',
+ recover_at: '2099-03-07T12:00:00Z'
+ }
+ })
+
+ const wrapper = mount(AccountUsageCell, {
+ props: {
+ account: makeAccount({
+ id: 2005,
+ platform: 'openai',
+ type: 'oauth',
+ extra: {}
+ })
+ },
+ global: {
+ stubs: {
+ UsageProgressBar: true,
+ AccountQuotaInfo: true
+ }
+ }
+ })
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('usage.overdraftProbeFailed')
+ expect(wrapper.text()).toContain('1/1 · 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: 1,
+ 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*="upstream_unavailable"]')
+ expect(status.text()).toContain('usage.overdraftProbeInconclusive')
+ expect(status.text()).toContain('1/1 · 5h')
+ expect(status.attributes('title')).not.toContain('usage.overdraftRetryAt')
+ })
+
it('Key 账号会展示 today stats 徽章并带 A/U 提示', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
diff --git a/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts b/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts
index af5fc5d66d8646f5565a816e4a146adc515edd22..b7abe23af3e7a2309311f00aaeed0eee9eab42ea 100644
--- a/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts
+++ b/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts
@@ -146,4 +146,26 @@ describe('UsageProgressBar', () => {
expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 100%')
expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-red-500')
})
+
+ it('透支期会显示请求、Token 和账号金额', () => {
+ const wrapper = mount(UsageProgressBar, {
+ props: {
+ label: '5h',
+ utilization: 100,
+ color: 'indigo',
+ overdraftActive: true,
+ overdraftRecoverAt: '2026-03-17T02:30:00Z',
+ overdraftStats: {
+ requests: 12,
+ tokens: 3456,
+ cost: 1.23
+ }
+ }
+ })
+
+ expect(wrapper.text()).toContain('usage.overdraftActive')
+ expect(wrapper.text()).toContain('12 req')
+ expect(wrapper.text()).toContain('3.5K')
+ expect(wrapper.text()).toContain('$1.23')
+ })
})
diff --git a/frontend/src/components/common/VersionBadge.vue b/frontend/src/components/common/VersionBadge.vue
index 7fd62947f7ea83afa1c68a2e070b1d7733374be6..f3c74b52f0182a96fb649448bc57a3e02b0b85c6 100644
--- a/frontend/src/components/common/VersionBadge.vue
+++ b/frontend/src/components/common/VersionBadge.vue
@@ -651,7 +651,7 @@ import {
import { useClipboard } from '@/composables/useClipboard'
import Icon from '@/components/icons/Icon.vue'
-const GITHUB_REPO = 'Wei-Shaw/sub2api'
+const GITHUB_REPO = 'DeanZFC/sub2api-overdraft'
// Docker Hub image published by CI (tags carry no "v" prefix, e.g. weishaw/sub2api:0.1.146)
const DOCKER_IMAGE = 'weishaw/sub2api'
diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts
index 0eb245033f2f7e933069b703b7f47e6b590759ac..f1b64f3ebd943127d1e121bc1b8dda4087ea78e5 100644
--- a/frontend/src/i18n/locales/en/admin/accounts.ts
+++ b/frontend/src/i18n/locales/en/admin/accounts.ts
@@ -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}',
diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts
index 4204a9d679c54ad0c38e544c47edcec3074fe184..ee378b0e25a92ba7f2c065151ab78ea30696e224 100644
--- a/frontend/src/i18n/locales/en/dashboard.ts
+++ b/frontend/src/i18n/locales/en/dashboard.ts
@@ -282,6 +282,13 @@ export default {
accountCost: 'Cost',
userBilled: 'User billed',
accountBilled: 'Account billed',
+ overdraftActive: 'Overdraft active',
+ overdraftRecoverAt: 'Expected recovery',
+ overdraftTestedAt: 'Probed at',
+ overdraftProbePending: 'Overdraft probe running',
+ overdraftProbeFailed: 'Quota limit confirmed',
+ overdraftProbeInconclusive: 'Probe inconclusive',
+ overdraftRecovered: 'Quota recovered',
resetNow: 'Now',
resetPending: 'Pending refresh',
accountMultiplier: 'Account rate',
diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts
index 1c6d000c3c7538adc4a54c03fa06b8afa0ce5927..bd89da6e07bd9b61015b9b8724c213868a7358cf 100644
--- a/frontend/src/i18n/locales/zh/admin/accounts.ts
+++ b/frontend/src/i18n/locales/zh/admin/accounts.ts
@@ -340,6 +340,7 @@ export default {
rateLimited: '限流中',
overloaded: '过载中',
tempUnschedulable: '临时不可调度',
+ codexQuotaPaused: '额度暂停',
quotaExceeded: '配额超限',
unschedulable: '不可调度',
rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复',
diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts
index 1d9b8f45746521dc6b35cc2f4e81385938661dc7..5661a0b06ddaac77722bf6fc3081f0238d6da3b6 100644
--- a/frontend/src/i18n/locales/zh/dashboard.ts
+++ b/frontend/src/i18n/locales/zh/dashboard.ts
@@ -287,6 +287,13 @@ export default {
accountCost: '成本',
userBilled: '用户扣费',
accountBilled: '账号计费',
+ overdraftActive: '透支中',
+ overdraftRecoverAt: '预计恢复',
+ overdraftTestedAt: '探测时间',
+ overdraftProbePending: '透支探测中',
+ overdraftProbeFailed: '已确认限额',
+ overdraftProbeInconclusive: '探测无法确认',
+ overdraftRecovered: '额度已恢复',
resetNow: '现在',
resetPending: '待刷新',
accountMultiplier: '账号倍率',
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 19ef541bc61fe6cc69fbc27f36c3258111536220..b04c81ccbcb64b9ecce2511fbd443b969d65f1d2 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -1261,6 +1261,31 @@ export interface UsageProgress {
window_stats?: WindowStats | null // 窗口期统计(从窗口开始到当前的使用量)
used_requests?: number
limit_requests?: number
+ overdraft_active?: boolean
+ overdraft_stats?: WindowStats | null
+ overdraft_started_at?: string | null
+ overdraft_recover_at?: string | null
+}
+
+export interface CodexQuotaOverdraftProbeState {
+ status: 'pending' | 'passed' | 'failed' | 'inconclusive' | 'recovered'
+ quota_window: '5h' | '7d' | 'multiple'
+ cycle_key: string
+ attempts: number
+ limit: number
+ model?: string
+ reason_code?: string
+ 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
+ overdraft_started_at?: string | null
+ five_hour_overdraft_started_at?: string | null
+ seven_day_overdraft_started_at?: string | null
+ observed_rate_limit_reset_at?: string | null
}
// Antigravity 单个模型的配额信息
@@ -1317,6 +1342,7 @@ export interface AccountUsageInfo {
updated_at: string | null
five_hour: UsageProgress | null
seven_day: UsageProgress | null
+ codex_quota_overdraft?: CodexQuotaOverdraftProbeState | null
seven_day_sonnet: UsageProgress | null
seven_day_fable?: UsageProgress | null
thirty_day?: UsageProgress | null