Files
sub2api-overdraft/patches/sub2api-overdraft-v0.1.176-fbfdcef81.patch

4657 lines
196 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..d3b682534b997a725da80fa4738056020669c465
--- /dev/null
+++ b/CODEX_OVERDRAFT_DEPLOYMENT_CN.md
@@ -0,0 +1,526 @@
+# sub2api-overdraft 部署与运维指南
+
+本文档面向希望部署本项目、验证 Codex 5h / 7d 额度透支状态,以及后续安全升级的使用者。
+
+本项目是 [Wei-Shaw/sub2api](https://github.com/Wei-Shaw/sub2api) 的非官方衍生版本,公开项目名为 **sub2api-overdraft**。内部二进制名、Docker 服务名、数据目录和 Go module 仍保留 `sub2api`,这是为了兼容原项目并降低同步上游更新时的冲突。
+
+## 重要说明
+
+- 本功能仅表示:当 Codex 5h 或 7d 页面额度达到 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。
+- 在适用请求的最后一条用户消息后注入一对无操作工具调用,用于保持与参考实现一致的请求形态。
+- 当 5h 或 7d 额度首次达到 100%,或收到带明确额度证据的 429 时,最多执行 5 次真实探测。
+- 管理页面的 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 额度达到 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_probe_passed` | 至少一次真实探测成功,透支成立,账号继续参与调度 |
+| `codex_quota_overdraft_probe_failed` | 5 次都明确返回额度限制,账号暂停至额度恢复时间 |
+| `codex_quota_overdraft_probe_inconclusive` | 网络、超时、5xx 等导致无法确认,约 1 分钟后可重试 |
+| `codex_quota_overdraft_probe_claim_failed` | 无法在 PostgreSQL 中原子领取探测任务,需要排查数据库或版本 |
+| `codex_quota_overdraft_state_persist_failed` | 状态无法写入 `accounts.extra`,页面可能不显示最新结果 |
+| `codex_quota_overdraft_stale_rate_limit_cleared` | 探测已证明账号可用,并清除了并发 429 遗留的账号级限流状态 |
+
+真正“透支成功”的最直接证据是:
+
+```text
+codex_quota_overdraft_probe_passed
+```
+
+其中会包含 `account_id`、`attempts`、`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` | 透支探测中 | 最多 5 次真实探测正在执行 |
+| `passed` | 透支中 | 上游仍可调用,账号继续调度并开始统计透支用量 |
+| `failed` | 已确认限额 | 5 次均明确受额度限制,暂停到恢复时间 |
+| `inconclusive` | 探测无法确认 | 暂时无法判断,查看 `reason_code` 和 `retry_at` |
+| `recovered` | 额度已恢复 | 原额度周期结束,相关暂停已清理 |
+
+页面显示 `透支中`,数据库为 `passed`,并且后续业务请求返回成功,三者同时满足即可确认功能完整生效。
+
+## 探测逻辑
+
+每个额度周期最多探测 5 次,每次最长 20 秒。模型按以下顺序循环:
+
+```text
+首选模型 -> gpt-5.5 -> gpt-5.4-mini -> 首选模型 -> gpt-5.5
+```
+
+- 任意一次返回有效 `response.completed` 或 `response.output_item.done`,立即记为 `passed`。
+- 只有 5 次都明确返回 `quota_limited`,才记为 `failed`。
+- 网络错误、超时、5xx、普通瞬时 429、无效响应等记为 `inconclusive`,不会因此误停账号。
+- 401/403 继续由原有认证失效逻辑处理。
+- 账号禁用、过载、代理故障、模型冷却等非额度限制不会被本功能绕过。
+
+多实例部署通过 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.176-overdraft.1`,更新方式应提示源码构建使用 `git pull`。也可以直接检查容器内二进制版本:
+
+```bash
+docker exec sub2api /app/sub2api -version
+```
+
+维护者发布新的源码版本时必须递增 `FORK_VERSION`,例如从 `0.1.176-overdraft.1` 改为 `0.1.176-overdraft.2`。同步到新的上游 Sub2API 版本时,使用 `0.1.177-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、5 次探测均确认额度耗尽、代理/网络错误、模型级冷却、并发限制或没有明确额度证据的其他上游 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..ae1cd6c60ba753c6e950c64d86391029e89f7fc8
--- /dev/null
+++ b/CODEX_QUOTA_OVERDRAFT_CUSTOMIZATION.md
@@ -0,0 +1,165 @@
+# sub2api-overdraft 5h / 7d 额度透支定制记录
+
+本文件是维护者实现与升级记录。面向部署者的完整安装、验证、升级和故障排查说明见
+[CODEX_OVERDRAFT_DEPLOYMENT_CN.md](CODEX_OVERDRAFT_DEPLOYMENT_CN.md)。
+
+## 基本信息
+
+- 实施基线:`fbfdcef81`
+- 参考实现:<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.176-overdraft.2`,同步上游版本后使用 `0.1.177-overdraft.1`。
+
+## 后台测试连接与残留限流修复
+
+管理页面的 OpenAI OAuth 常规文本“测试账号连接”使用与真实业务请求相同的透支请求形态。测试成功并取得 5h/7d 快照后会触发额度观察;测试返回带明确额度证据的 429 时交给透支协调器执行最多 5 次真实探测,不再直接写入普通账号级限流。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 常规文本“测试账号连接”
+
+每次最终发往上游前,如果 `input` 最后一项是用户消息,则追加一对与参考项目相同的无操作 `custom_tool_call` 和 `custom_tool_call_output`。请求体超过 32 MiB、JSON 无效、形状不匹配或已经注入时保持原样。
+
+5h 或 7d 额度首次达到 100%,或者收到包含明确额度证据的真实 429 时,按参考项目执行复核:
+
+1. 同一额度周期只启动一个探测计划,状态持久化在 `accounts.extra.codex_quota_overdraft_probe`,多实例使用数据库原子 claim 去重。
+2. 最多执行 5 次真实 Responses 请求,每次独立 20 秒超时。
+3. 模型按“首选模型、`gpt-5.5`、`gpt-5.4-mini`”循环。例如首选为 `gpt-5.4` 时,顺序是 `gpt-5.4`、`gpt-5.5`、`gpt-5.4-mini`、`gpt-5.4`、`gpt-5.5`。
+4. 任意一次返回有效的 `response.completed` 或 `response.output_item.done` 即判定 `passed`,账号继续调度并开始透支统计。
+5. 只有 5 次均返回明确 `quota_limited` 才判定 `failed`,账号暂停到对应额度恢复时间;5h 和 7d 同时耗尽时取最晚恢复时间。
+6. 网络错误、超时、5xx、普通瞬时 429、无效响应、认证依赖不可用均判定 `inconclusive`,不会误停账号,一分钟后允许重试。
+7. 401/403 仍交给原有认证失效策略处理;账号禁用、过载、代理/传输故障、模型级冷却及其他临时不可调度原因不被绕过。
+8. 额度恢复后状态改为 `recovered`,清理本功能或额度阈值产生的暂停;5h 与 7d 分别维护透支起点,不会因另一窗口后来耗尽而重置已有统计。
+
+状态值包括 `pending`、`passed`、`failed`、`inconclusive`、`recovered`。账号用量页面显示探测状态、尝试次数、额度周期、透支期成功请求数、Token、账号金额及预计恢复时间。
+
+不需要新增数据库表或执行数据库结构迁移(schema migration);周期状态保存在现有 `accounts.extra` JSONB 字段,透支统计读取现有 `usage_logs`。
+
+`/responses/compact`、生图请求、Embedding、Count Tokens、Live 等端点不启用额度透支。
+
+## 修改文件
+
+后端配置、路由和调度:
+
+- `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_schedulable_projection_test.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_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`
+- `backend/cmd/server/wire_gen.go`
+
+前端、配置示例和测试:
+
+- `frontend/src/types/index.ts`
+- `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_stale_rate_limit_cleared
+```
+
+数据库状态可这样检查:
+
+```sql
+SELECT id, name, extra->'codex_quota_overdraft_probe'
+FROM accounts
+WHERE platform = 'openai' AND type = 'oauth';
+```
diff --git a/Dockerfile b/Dockerfile
index b2dfd7034f769abd7e8640090282e6b05a2af4a1..657985091514edeb8358800d4ac49ce7299edd38 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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..7770a2f7520859e155c2a8049db506d7a54063e6
--- /dev/null
+++ b/FORK_VERSION
@@ -0,0 +1 @@
+0.1.176-overdraft.1
diff --git a/README.md b/README.md
index f4ad2fa6b028bdc70ac0b502104a345dd8d940c1..4e0f4be6eea519b4c5c64928924776ebc4721b08 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
<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/)
[![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/)
@@ -10,14 +10,43 @@
[![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
+
+- Runs up to five real probes after the reported Codex 5h or 7d quota reaches 100%.
+- Keeps an account schedulable after a successful probe and tracks overdraft requests, tokens, cost, and recovery for both windows independently.
+- Exposes `pending`, `passed`, `failed`, `inconclusive`, and `recovered` states in the admin UI and PostgreSQL.
+- Uses an atomic PostgreSQL claim for multi-instance deployments and requires no schema migration.
+- 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:
diff --git a/README_CN.md b/README_CN.md
index 2d2a57a9ff3a363618001dda3ffbfae84bcd0f41..780544e69d8c4bcf48f7ab8c1e1679c99f39c376 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -1,8 +1,8 @@
<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/)
[![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/)
@@ -10,14 +10,76 @@
[![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 额度达到 100% 后,最多执行 5 次真实请求探测,判断账号是否仍可继续调用。
+- 探测成功后继续参与账号调度,并分别统计 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_probe_passed`,页面显示“透支中”,并且后续真实业务请求成功,即可确认透支功能完整生效。OpenAI OAuth 常规文本“测试账号连接”也会使用透支请求形态并接入同一探测状态机;API Key、Shadow、图片和 Compact 测试除外。额度未达到 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 部署指南为准。
## ⚠️ 重要提醒
@@ -273,12 +335,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..03c1c30a544c193fd1ffc05a4297344905c2a011 100644
--- a/README_JA.md
+++ b/README_JA.md
@@ -1,8 +1,8 @@
<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/)
[![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/)
@@ -10,14 +10,38 @@
[![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 に保存します。
+- 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 を支援または承認していることを意味しません。
+
## ⚠️ 重要なお知らせ
本プロジェクトをご利用になる前に、以下の内容を必ずよくお読みください:
diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go
index ab9f4530b8663c00be9f04cf9c46c3822d6f9fb6..5f2d628569e4ebfdf682f810c9022af8131b7c8f 100644
--- a/backend/cmd/server/wire_gen.go
+++ b/backend/cmd/server/wire_gen.go
@@ -199,8 +199,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository, settingService)
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService)
usageCache := service.NewUsageCache()
- accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService)
- accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService)
+ codexQuotaOverdraftCoordinator := service.ProvideCodexQuotaOverdraftCoordinator(accountRepository, httpUpstream, openAITokenProvider, tlsFingerprintProfileService, configConfig, tempUnschedCache, openAIGatewayService, rateLimitService)
+ accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService, codexQuotaOverdraftCoordinator)
+ accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, codexQuotaOverdraftCoordinator)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index 0ed2b172139e0c765d26701bcafc7e677e729e8f..81b257b8ac547e6d8ec82fb1df07f38182853345 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"`
@@ -2272,6 +2275,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..17b47905886e1f845337b653ad73db03b23a00dd 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 {
diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go
index 26b5dd03c54ec4dadaf7e4f6a41745c3eeabb8f8..b71abfe10b037475442f1d86e58b139738cb1668 100644
--- a/backend/internal/handler/openai_gateway_handler.go
+++ b/backend/internal/handler/openai_gateway_handler.go
@@ -443,7 +443,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 !requireCompact && !imageIntent {
+ requestCtx = service.WithCodexQuotaOverdraftScheduling(requestCtx)
+ }
+ pricingCtx, pricingAt := h.gatewayService.WithOpenAIRequestPricingContext(requestCtx, apiKey.GroupID)
c.Request = c.Request.WithContext(pricingCtx)
for {
@@ -1016,7 +1020,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 {
@@ -1735,6 +1740,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 兜底。
diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go
index 010442eb6bcf3b80d4e7d65c27d8b9dd966259d4..65a592fcbfcaa1327a2fcce759550cb74c62d64d 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)),
@@ -2592,6 +2593,49 @@ func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates m
return nil
}
+// ClaimCodexQuotaOverdraftProbe atomically reserves one quota cycle. This
+// prevents duplicate five-request probe plans across multiple 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
+ OR (
+ extra #>> '{codex_quota_overdraft_probe,status}' = 'inconclusive'
+ AND COALESCE(NULLIF(extra #>> '{codex_quota_overdraft_probe,retry_at}', '')::timestamptz, '1970-01-01'::timestamptz) <= NOW()
+ )
+ OR (
+ extra #>> '{codex_quota_overdraft_probe,status}' = 'pending'
+ AND COALESCE(NULLIF(extra #>> '{codex_quota_overdraft_probe,started_at}', '')::timestamptz, '1970-01-01'::timestamptz) <= NOW() - INTERVAL '2 minutes'
+ )
+ )
+ `, 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
+}
+
// UpdateUpstreamBillingProbeSnapshot stores a probe result only while the
// network identity used by that probe is still current.
func (r *accountRepository) UpdateUpstreamBillingProbeSnapshot(
@@ -3029,7 +3073,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 +3178,23 @@ 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()")),
- ))
+ }
+ if service.CodexQuotaOverdraftSchedulingEnabled(ctx) {
+ reasonCol := s.C("temp_unschedulable_reason")
+ predicates = 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+`"`),
+ ))
+ }
+ s.Where(entsql.Or(predicates...))
})
}
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_service.go b/backend/internal/service/account_test_service.go
index 9e1d29c2f945b42a9ce5d1e5d7990ef72d7d7a95..450af91c8d7d3608c68087da7369c692f3d40436 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
@@ -153,12 +154,23 @@ type AccountTestService struct {
grokWSDialer openAIWSClientDialer
}
+type codexQuotaOverdraftAccountTestCoordinator interface {
+ ObserveAccount(account *Account, preferredModel string)
+ HandleQuota429(ctx context.Context, account *Account, headers http.Header, body []byte, preferredModel string) bool
+}
+
func (s *AccountTestService) SetSettingService(settingService *SettingService) {
if s != nil {
s.settingService = settingService
}
}
+func (s *AccountTestService) SetCodexQuotaOverdraftCoordinator(coordinator *CodexQuotaOverdraftCoordinator) {
+ if s != nil {
+ s.codexQuotaOverdraft = coordinator
+ }
+}
+
// NewAccountTestService creates a new AccountTestService
func NewAccountTestService(
accountRepo AccountRepository,
@@ -693,6 +705,11 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
}
payload := createOpenAITestPayload(upstreamTestModelID, isOAuth)
payloadBytes, _ := json.Marshal(payload)
+ if s.codexQuotaOverdraftTestEnabled(account) {
+ if updated, changed, err := injectCodexQuotaOverdraft(payloadBytes); err == nil && changed {
+ payloadBytes = updated
+ }
+ }
// Send test_start event once. A task-invalid Agent Identity response may
// restart this probe after registering a replacement task.
@@ -776,7 +793,11 @@ 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)
+ handled := s.codexQuotaOverdraft != nil && s.codexQuotaOverdraftTestEnabled(account) &&
+ s.codexQuotaOverdraft.HandleQuota429(ctx, account, resp.Header, body, upstreamTestModelID)
+ if !handled {
+ s.reconcileOpenAI429State(ctx, account, resp.Header, body)
+ }
}
// 401 Unauthorized: 标记账号为永久错误
if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil {
@@ -785,9 +806,19 @@ 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
+ }
+ if s.codexQuotaOverdraft != nil && s.codexQuotaOverdraftTestEnabled(account) {
+ s.codexQuotaOverdraft.ObserveAccount(account, upstreamTestModelID)
+ }
+ return nil
+}
+
+func (s *AccountTestService) codexQuotaOverdraftTestEnabled(account *Account) bool {
+ return s != nil && s.cfg != nil && s.cfg.Gateway.CodexQuotaOverdraftEnabled &&
+ isCodexQuotaOverdraftAccount(account)
}
// testGrokAccountConnection routes Grok admin connectivity tests by explicit mode first,
@@ -2548,6 +2579,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 5b973285a8bbc05a6dc3a42c87eb4ae9fc836210..c5cb7b9f89708ab63d0133a505623a0afae5d1f4 100644
--- a/backend/internal/service/account_test_service_openai_compact_test.go
+++ b/backend/internal/service/account_test_service_openai_compact_test.go
@@ -44,6 +44,7 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactOAuthSuccessPersi
svc := &AccountTestService{
accountRepo: repo,
httpUpstream: upstream,
+ cfg: &config.Config{Gateway: config.GatewayConfig{CodexQuotaOverdraftEnabled: true}},
}
rec := httptest.NewRecorder()
@@ -63,6 +64,7 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactOAuthSuccessPersi
require.Equal(t, "chatgpt-acc", upstream.lastReq.Header.Get("chatgpt-account-id"))
require.Equal(t, "true", upstream.lastReq.Header.Get("x-openai-fedramp"))
require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String())
+ require.False(t, gjson.GetBytes(upstream.lastBody, "input.1").Exists())
updates := <-updateCalls
require.Equal(t, true, updates["openai_compact_supported"])
diff --git a/backend/internal/service/account_test_service_openai_test.go b/backend/internal/service/account_test_service_openai_test.go
index 2cd63ebe3fa98ac99643ce3aa54965eff4d7c008..00813e04e9fe9f99f2580e57ef0d0231c266884c 100644
--- a/backend/internal/service/account_test_service_openai_test.go
+++ b/backend/internal/service/account_test_service_openai_test.go
@@ -73,6 +73,35 @@ type openAIAccountTestRepo struct {
setErrorMsg string
}
+type accountTestOverdraftCoordinatorStub struct {
+ observeCalls 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) 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 +166,47 @@ 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"},
+ }
+
+ 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.Equal(t, 1, coordinator.observeCalls)
+ 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 +233,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 +281,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 +298,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 +365,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 +579,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 +601,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..e9a3e78fac1734c5681da902687e985b6da99f0f 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,10 +307,17 @@ type AccountUsageService struct {
cache *UsageCache
identityCache IdentityCache
tlsFPProfileService *TLSFingerprintProfileService
+ codexQuotaOverdraft *CodexQuotaOverdraftCoordinator
agentIdentityTaskMu sync.Mutex
agentIdentityWS agentIdentityWSConnectionInvalidator
}
+func (s *AccountUsageService) SetCodexQuotaOverdraftCoordinator(coordinator *CodexQuotaOverdraftCoordinator) {
+ if s != nil {
+ s.codexQuotaOverdraft = coordinator
+ }
+}
+
// NewAccountUsageService 创建AccountUsageService实例
func NewAccountUsageService(
accountRepo AccountRepository,
@@ -711,6 +723,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 +754,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 +776,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..076004067c8d2aa027c62bf00e9df2a08dfd2ab9 100644
--- a/backend/internal/service/openai_account_runtime_block_fastpath.go
+++ b/backend/internal/service/openai_account_runtime_block_fastpath.go
@@ -73,6 +73,14 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont
return false
}
stateCtx = withTempUnschedulableModel(stateCtx, canonicalModel)
+ preferredModel := ""
+ if len(canonicalModel) > 0 {
+ preferredModel = canonicalModel[0]
+ }
+ if statusCode == http.StatusTooManyRequests && s.codexQuotaOverdraft != nil &&
+ s.codexQuotaOverdraft.HandleQuota429(stateCtx, account, headers, responseBody, preferredModel) {
+ 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 6457ef8fc28c21cd3a1517391ca9c269d2fbd45b..154dd1dc6c805c14c50da61d812bf0709ec16ba9 100644
--- a/backend/internal/service/openai_account_scheduler.go
+++ b/backend/internal/service/openai_account_scheduler.go
@@ -1393,7 +1393,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")
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..2aba1fd711b4d538fa4230a73ca07e388d80766f
--- /dev/null
+++ b/backend/internal/service/openai_codex_quota_overdraft.go
@@ -0,0 +1,210 @@
+package service
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "strings"
+ "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
+)
+
+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{}
+
+// 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()
+ }
+ return context.WithValue(ctx, codexQuotaOverdraftSchedulingCtxKey{}, true)
+}
+
+// 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
+ }
+ enabled, _ := ctx.Value(codexQuotaOverdraftSchedulingCtxKey{}).(bool)
+ return enabled
+}
+
+func codexQuotaOverdraftSchedulingEnabled(ctx context.Context) bool {
+ return CodexQuotaOverdraftSchedulingEnabled(ctx)
+}
+
+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 &&
+ isCodexQuotaOverdraftAccount(account)
+}
+
+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 {
+ return body
+ }
+ return updated
+}
+
+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 {
+ 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"`
+}
+
+// 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
+ }
+
+ for _, raw := range document.Input {
+ var item codexQuotaOverdraftInputItem
+ if err := json.Unmarshal(raw, &item); err == nil &&
+ item.Type == "custom_tool_call" &&
+ strings.HasPrefix(item.CallID, codexQuotaOverdraftCallIDPrefix) {
+ 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_probe.go b/backend/internal/service/openai_codex_quota_overdraft_probe.go
new file mode 100644
index 0000000000000000000000000000000000000000..8ae318ca1a4fc8f34d18dfe46c6eb8020d185beb
--- /dev/null
+++ b/backend/internal/service/openai_codex_quota_overdraft_probe.go
@@ -0,0 +1,938 @@
+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 = 5
+ codexQuotaOverdraftProbeAttemptTimeout = 20 * time.Second
+ codexQuotaOverdraftProbePlanTimeout = codexQuotaOverdraftProbeAttemptLimit * codexQuotaOverdraftProbeAttemptTimeout
+ codexQuotaOverdraftProbeRetryDelay = time.Minute
+ codexQuotaOverdraftProbeBodyLimit = 256 << 10
+ codexQuotaOverdraftPauseSource = "codex_quota_overdraft"
+
+ 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"`
+ 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)
+}
+
+// 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)
+}
+
+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
+ }
+ c.startProbe(accountCopy, signal, preferredModel)
+ 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:
+ if c.currentTime().Sub(current.StartedAt) < 2*time.Minute {
+ return
+ }
+ case codexQuotaOverdraftProbeInconclusive:
+ if current.RetryAt != nil && c.currentTime().Before(*current.RetryAt) {
+ 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++
+ }
+ c.persistState(accountID, state)
+
+ if result.Status == "available" {
+ state.Status = codexQuotaOverdraftProbePassed
+ state.ReasonCode = "model_response_ok"
+ state.RetryAt = nil
+ startCodexQuotaOverdraftWindows(state, signal, testedAt)
+ c.persistState(accountID, state)
+ 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
+ c.persistState(accountID, state)
+ c.ensureFailedPause(account, state)
+ 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()
+ retryAt := now.Add(codexQuotaOverdraftProbeRetryDelay)
+ state.Status = codexQuotaOverdraftProbeInconclusive
+ state.ReasonCode = reason
+ state.TestedAt = &now
+ state.RetryAt = &retryAt
+ c.persistState(accountID, state)
+ 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"`)) {
+ 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 statusCode >= http.StatusInternalServerError {
+ return "inconclusive", "upstream_unavailable"
+ }
+ if statusCode == http.StatusBadRequest || statusCode == http.StatusNotFound {
+ return "inconclusive", "model_not_found"
+ }
+ return "inconclusive", "invalid_response"
+ }
+}
+
+func (c *CodexQuotaOverdraftCoordinator) persistState(accountID int64, state *CodexQuotaOverdraftProbeState) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := c.accountRepo.UpdateExtra(ctx, accountID, map[string]any{CodexQuotaOverdraftProbeExtraKey: state}); err != nil {
+ slog.Warn("codex_quota_overdraft_state_persist_failed", "account_id", accountID, "error", err)
+ }
+}
+
+func (c *CodexQuotaOverdraftCoordinator) ensureFailedPause(account *Account, state *CodexQuotaOverdraftProbeState) {
+ if account == nil || state == nil || state.RecoverAt == nil || !state.RecoverAt.After(c.currentTime()) {
+ return
+ }
+ reason := BuildTempUnschedReasonPayload(codexQuotaOverdraftPauseSource, "five real overdraft probes confirmed quota exhaustion")
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := c.accountRepo.SetTempUnschedulable(ctx, account.ID, *state.RecoverAt, reason); err != nil {
+ slog.Warn("codex_quota_overdraft_pause_failed", "account_id", account.ID, "error", err)
+ return
+ }
+ if c.tempUnschedCache != nil {
+ _ = c.tempUnschedCache.SetTempUnsched(ctx, account.ID, &TempUnschedState{
+ UntilUnix: state.RecoverAt.Unix(),
+ TriggeredAtUnix: c.currentTime().Unix(),
+ StatusCode: http.StatusTooManyRequests,
+ ErrorMessage: "five real overdraft probes confirmed quota exhaustion",
+ })
+ }
+ if c.runtimeBlocker != nil {
+ c.runtimeBlocker.BlockAccountScheduling(account, *state.RecoverAt, codexQuotaOverdraftPauseSource)
+ }
+}
+
+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
+ }
+ clearedSchedulingState := false
+ probeProvedAvailable := state != nil &&
+ (state.Status == codexQuotaOverdraftProbePassed || state.Status == codexQuotaOverdraftProbeRecovered)
+ if probeProvedAvailable && 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 IsAccountSchedulingThresholdReason(account.TempUnschedulableReason) || codexQuotaOverdraftPauseReason(account.TempUnschedulableReason) {
+ _ = 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 {
+ 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
+ }
+ }
+ text := strings.ToLower(strings.Join(strings.Fields(string(body)), " "))
+ for _, marker := range []string{"usage_limit_reached", "usage limit has been reached", "quota exhausted", "weekly limit reached"} {
+ if strings.Contains(text, marker) {
+ 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 !isCodexQuotaOverdraftAccount(account) {
+ 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 codexQuotaOverdraftSnapshotExhausted(updates map[string]any) bool {
+ if len(updates) == 0 {
+ return false
+ }
+ return parseExtraFloat64(updates["codex_5h_used_percent"]) >= 100 ||
+ parseExtraFloat64(updates["codex_7d_used_percent"]) >= 100
+}
+
+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..e1f2f68fcf5234317742977f27a649635bd36611
--- /dev/null
+++ b/backend/internal/service/openai_codex_quota_overdraft_probe_test.go
@@ -0,0 +1,372 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+type codexOverdraftProbeRepoStub struct {
+ AccountRepository
+ account *Account
+ states []*CodexQuotaOverdraftProbeState
+ tempPauseCalls int
+ clearTempCalls int
+ clearLimitCalls int
+}
+
+type codexOverdraftRuntimeBlockerStub struct {
+ clearCalls int
+}
+
+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 TestCodexQuotaOverdraftProbeModelsMatchPluginRotation(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", "gpt-5.5", "gpt-5.4-mini", "gpt-5.4", "gpt-5.5"}, 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 TestCodexQuotaOverdraftProbePassesOnAnySuccessfulAttempt(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, 3)
+ coordinator.probeAttemptForTest = func(_ context.Context, _ *Account, model string) codexQuotaOverdraftProbeResult {
+ models = append(models, model)
+ if len(models) == 3 {
+ return codexQuotaOverdraftProbeResult{Status: "available", ReasonCode: "model_response_ok", StatusCode: http.StatusOK, Model: model}
+ }
+ return codexQuotaOverdraftProbeResult{Status: "retry", ReasonCode: "quota_limited", StatusCode: http.StatusTooManyRequests, 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, 3, state.Attempts)
+ require.Equal(t, []string{"gpt-5.4", "gpt-5.5", "gpt-5.4-mini"}, models)
+ require.NotNil(t, state.FiveHourStartedAt)
+ require.Zero(t, repo.tempPauseCalls)
+}
+
+func TestCodexQuotaOverdraftProbeRequiresFiveExplicitQuotaFailures(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 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.NotNil(t, state.RetryAt)
+ 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}
+ state := &CodexQuotaOverdraftProbeState{
+ Status: status,
+ ObservedRateLimit: codexQuotaOverdraftTimePtr(now.Add(time.Hour)),
+ }
+
+ 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.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.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..4d20e2bc45754018fce259452ed10ba45d60ff24
--- /dev/null
+++ b/backend/internal/service/openai_codex_quota_overdraft_test.go
@@ -0,0 +1,142 @@
+//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{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
+ 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))
+
+ 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{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
+ 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)
+ 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}}
+ 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 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
+}
diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go
index f7eb7593460691769bf0b1405150d8eb5c9bb821..dfb29d1aa31ff7a7d8757e4f75fa652b0a595b0d 100644
--- a/backend/internal/service/openai_gateway_forward.go
+++ b/backend/internal/service/openai_gateway_forward.go
@@ -1029,6 +1029,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
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 8c029f0aa8633a645f020b470a104d7ffbeec4b6..d712ba25a1b19789f44c92a5c62976df1708b37d 100644
--- a/backend/internal/service/openai_gateway_passthrough.go
+++ b/backend/internal/service/openai_gateway_passthrough.go
@@ -340,6 +340,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 be25b72b01f9f97daa788efb4b9ed021b5c2d7d0..301ba997dd7862d3aef8f1d84eb34c5a7369d878 100644
--- a/backend/internal/service/openai_gateway_scheduling.go
+++ b/backend/internal/service/openai_gateway_scheduling.go
@@ -445,6 +445,10 @@ func shouldAutoPauseOpenAIAccountByQuota(ctx context.Context, account *Account)
if account == nil || !account.IsOpenAI() {
return false, openAIQuotaAutoPauseDecision{}
}
+ if codexQuotaOverdraftSchedulingEnabled(ctx) && isCodexQuotaOverdraftAccount(account) &&
+ codexQuotaOverdraftSchedulingAllowed(account, time.Now().UTC()) {
+ 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
@@ -1277,11 +1281,28 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex
func (s *OpenAIGatewayService) listSchedulableAccounts(ctx context.Context, groupID *int64, platform string) ([]Account, error) {
platform = normalizeOpenAICompatiblePlatform(platform)
+ if CodexQuotaOverdraftSchedulingEnabled(ctx) && platform == PlatformOpenAI && s.accountRepo != 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, fmt.Errorf("query overdraft accounts failed: %w", err)
+ }
+ accounts = normalizeCodexQuotaOverdraftAccountsForScheduling(ctx, accounts)
+ return s.filterOpenAIAccountsBySchedulingThreshold(ctx, accounts), nil
+ }
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)
@@ -1300,6 +1321,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)
@@ -1339,6 +1361,7 @@ func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccountBeforeProfit(
}
fresh = current
}
+ fresh = normalizeCodexQuotaOverdraftAccountForScheduling(ctx, fresh)
if !isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx, fresh, platform, requestedModel, requireCompact, requiredCapability) {
return nil
@@ -1408,6 +1431,7 @@ func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDBBeforeProfit(ct
if err != nil || latest == nil {
return nil
}
+ latest = normalizeCodexQuotaOverdraftAccountForScheduling(ctx, latest)
if !s.openAIAccountMatchesSchedulingGroup(latest, groupID) {
return nil
}
@@ -1449,6 +1473,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
}
@@ -1489,6 +1514,10 @@ func (s *OpenAIGatewayService) isOpenAIAccountBlockedBySchedulingThreshold(ctx c
if s == nil || s.rateLimitService == nil || account == nil {
return false
}
+ if codexQuotaOverdraftSchedulingEnabled(ctx) && isCodexQuotaOverdraftAccount(account) &&
+ codexQuotaOverdraftSchedulingAllowed(account, time.Now().UTC()) {
+ 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 55353e1d0a728261c4a419d0dca65a12619ce9ca..1f0893afa7d6d47f2b4291920b064f8ecb7d689a 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
@@ -464,6 +465,12 @@ type OpenAIGatewayService struct {
openaiCompatAnthropicDigestSessions sync.Map
}
+func (s *OpenAIGatewayService) SetCodexQuotaOverdraftCoordinator(coordinator *CodexQuotaOverdraftCoordinator) {
+ if s != nil {
+ s.codexQuotaOverdraft = coordinator
+ }
+}
+
// NewOpenAIGatewayService creates a new OpenAIGatewayService
func NewOpenAIGatewayService(
accountRepo AccountRepository,
@@ -493,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..baa644ff525cdcf6e438eed335ac1c158784cadb 100644
--- a/backend/internal/service/openai_gateway_usage.go
+++ b/backend/internal/service/openai_gateway_usage.go
@@ -1028,14 +1028,38 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc
if len(updates) == 0 {
return
}
- if !s.getCodexSnapshotThrottle().Allow(accountID, now) {
- return
- }
+ persistSnapshot := codexQuotaOverdraftSnapshotExhausted(updates) || s.getCodexSnapshotThrottle().Allow(accountID, now)
go func() {
updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
- _ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates)
+ 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)
+ s.codexQuotaOverdraft.ObserveAccount(account, "")
}()
}
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_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..b56b2b015273d6e463d3e50f8714c6e009aa8d73 100644
--- a/backend/internal/service/ratelimit_service.go
+++ b/backend/internal/service/ratelimit_service.go
@@ -149,6 +149,10 @@ func (s *RateLimitService) ApplyAccountSchedulingThreshold(ctx context.Context,
if !account.IsActive() || !account.Schedulable {
return false
}
+ if codexQuotaOverdraftSchedulingEnabled(ctx) && isCodexQuotaOverdraftAccount(account) &&
+ codexQuotaOverdraftSchedulingAllowed(account, time.Now().UTC()) {
+ return false
+ }
now := time.Now().UTC()
thresholds := s.settingService.GetAccountSchedulingThresholds(ctx)
@@ -176,7 +180,12 @@ func (s *RateLimitService) ApplyAccountSchedulingThreshold(ctx context.Context,
account.TempUnschedulableUntil = cloneTimePtr(decision.Until)
account.TempUnschedulableReason = reason
- s.notifyAccountSchedulingBlocked(account, *decision.Until, "account_scheduling_threshold")
+ // When overdraft is globally enabled, the persisted threshold pause remains
+ // visible to ordinary endpoints, but it must not create an untyped runtime
+ // blocker that would also hide the account from an overdraft-marked request.
+ if !CodexQuotaOverdraftEnabled() || !isCodexQuotaOverdraftAccount(account) {
+ s.notifyAccountSchedulingBlocked(account, *decision.Until, "account_scheduling_threshold")
+ }
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..1e853b64c4d4133aa2ddf940c63c07f94f8fcd7f 100644
--- a/backend/internal/service/wire.go
+++ b/backend/internal/service/wire.go
@@ -202,6 +202,7 @@ func ProvideAccountUsageService(
identityCache IdentityCache,
tlsFPProfileService *TLSFingerprintProfileService,
openAIGatewayService *OpenAIGatewayService,
+ codexQuotaOverdraft *CodexQuotaOverdraftCoordinator,
) *AccountUsageService {
service := NewAccountUsageService(
accountRepo,
@@ -217,9 +218,36 @@ func ProvideAccountUsageService(
tlsFPProfileService,
)
service.agentIdentityWS = openAIGatewayService
+ service.SetCodexQuotaOverdraftCoordinator(codexQuotaOverdraft)
return service
}
+func ProvideCodexQuotaOverdraftCoordinator(
+ accountRepo AccountRepository,
+ httpUpstream HTTPUpstream,
+ openAITokenProvider *OpenAITokenProvider,
+ tlsFPProfileService *TLSFingerprintProfileService,
+ cfg *config.Config,
+ tempUnschedCache TempUnschedCache,
+ runtimeBlocker AccountRuntimeBlocker,
+ rateLimitService *RateLimitService,
+) *CodexQuotaOverdraftCoordinator {
+ coordinator := NewCodexQuotaOverdraftCoordinator(
+ accountRepo,
+ httpUpstream,
+ openAITokenProvider,
+ tlsFPProfileService,
+ cfg,
+ tempUnschedCache,
+ runtimeBlocker,
+ rateLimitService,
+ )
+ if gateway, ok := runtimeBlocker.(*OpenAIGatewayService); ok {
+ gateway.SetCodexQuotaOverdraftCoordinator(coordinator)
+ }
+ return coordinator
+}
+
func ProvideAccountTestService(
accountRepo AccountRepository,
geminiTokenProvider *GeminiTokenProvider,
@@ -231,6 +259,7 @@ func ProvideAccountTestService(
tlsFPProfileService *TLSFingerprintProfileService,
openAIGatewayService *OpenAIGatewayService,
settingService *SettingService,
+ codexQuotaOverdraft *CodexQuotaOverdraftCoordinator,
) *AccountTestService {
service := NewAccountTestService(
accountRepo,
@@ -244,6 +273,7 @@ func ProvideAccountTestService(
)
service.agentIdentityWS = openAIGatewayService
service.SetSettingService(settingService)
+ service.SetCodexQuotaOverdraftCoordinator(codexQuotaOverdraft)
return service
}
@@ -799,6 +829,7 @@ var ProviderSet = wire.NewSet(
ProvideClaudeTokenProvider,
NewAntigravityGatewayService,
ProvideRateLimitService,
+ ProvideCodexQuotaOverdraftCoordinator,
ProvideAccountUsageService,
ProvideAccountTestService,
ProvideUpstreamBillingProbeService,
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/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..eeb8525db74c458ecc7a213f0732d871799b5300
--- /dev/null
+++ b/deploy/docker-compose.overdraft.yml
@@ -0,0 +1,16 @@
+# 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:
+ VERSION: ${BUILD_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/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue
index 485b4b8701020a68bb1b38606d68cd3991c87e0b..4e7aefb5137245ddfa42a3e24ac495cd4ce8b365 100644
--- a/frontend/src/components/account/AccountUsageCell.vue
+++ b/frontend/src/components/account/AccountUsageCell.vue
@@ -119,12 +119,32 @@
<!-- 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">
+ <div
+ v-if="codexOverdraftStatus"
+ class="flex items-center gap-1.5 text-[9px]"
+ :class="codexOverdraftStatus.textClass"
+ :title="codexOverdraftStatus.title"
+ >
+ <span
+ class="rounded px-1.5 py-0.5 font-medium"
+ :class="codexOverdraftStatus.badgeClass"
+ >
+ {{ codexOverdraftStatus.label }}
+ </span>
+ <span v-if="codexOverdraftStatus.detail" class="text-gray-500 dark:text-gray-400">
+ {{ codexOverdraftStatus.detail }}
+ </span>
+ </div>
<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 +154,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"
/>
@@ -731,7 +755,70 @@ 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 codexOverdraftStatus = computed(() => {
+ const probe = usageInfo.value?.codex_quota_overdraft
+ if (!probe) return null
+
+ const attempts = Math.max(0, probe.attempts || 0)
+ const limit = Math.max(1, probe.limit || 5)
+ 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 retryAt = probe.retry_at ? new Date(probe.retry_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}`)
+ if (retryAt) titleParts.push(`${t('usage.overdraftRetryAt')}: ${retryAt}`)
+
+ 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
+ }
})
const openAIUsageRefreshKey = computed(() => buildOpenAIUsageRefreshKey(props.account))
diff --git a/frontend/src/components/account/UsageProgressBar.vue b/frontend/src/components/account/UsageProgressBar.vue
index 2f8b9a88a7ade8fb57871dacd5aa8d1f3e0490df..722ff5bac06f015f68e0fe10fbf03775522deff5 100644
--- a/frontend/src/components/account/UsageProgressBar.vue
+++ b/frontend/src/components/account/UsageProgressBar.vue
@@ -25,6 +25,19 @@
</div>
</div>
+ <div
+ v-if="overdraftActive"
+ class="mb-0.5 flex items-center gap-1.5 text-[9px] text-red-600 dark:text-red-400"
+ :title="overdraftTitle"
+ >
+ <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="overdraftStats" class="text-gray-500 dark:text-gray-400">
+ {{ formatOverdraftRequests }} req · {{ formatOverdraftTokens }} · ${{ formatOverdraftCost }}
+ </span>
+ </div>
+
<!-- Progress bar row -->
<div class="flex items-center gap-1">
<!-- Label badge (fixed width for alignment) -->
@@ -70,6 +83,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()
@@ -218,4 +235,24 @@ const formatUserCost = computed(() => {
return props.windowStats.user_cost.toFixed(2)
})
+const formatOverdraftRequests = computed(() => {
+ if (!props.overdraftStats) return ''
+ return formatCompactNumber(props.overdraftStats.requests, { allowBillions: false })
+})
+
+const formatOverdraftTokens = computed(() => {
+ if (!props.overdraftStats) return ''
+ return formatCompactNumber(props.overdraftStats.tokens)
+})
+
+const formatOverdraftCost = computed(() => {
+ if (!props.overdraftStats) return '0.00'
+ return props.overdraftStats.cost.toFixed(2)
+})
+
+const overdraftTitle = computed(() => {
+ if (!props.overdraftRecoverAt) return t('usage.overdraftActive')
+ return `${t('usage.overdraftActive')} · ${t('usage.overdraftRecoverAt')}: ${new Date(props.overdraftRecoverAt).toLocaleString()}`
+})
+
</script>
diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
index ff0e94d6647ec290dbb79f366228d75055254f8f..8c12d75698e05736fc3afce48712beb6f3b6f607 100644
--- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
+++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
@@ -615,6 +615,51 @@ 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: 5,
+ limit: 5,
+ 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('5/5 · 5h')
+ })
+
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/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts
index 4204a9d679c54ad0c38e544c47edcec3074fe184..d51f339950e39d14e2fda2df4fbdd6e43abe56c8 100644
--- a/frontend/src/i18n/locales/en/dashboard.ts
+++ b/frontend/src/i18n/locales/en/dashboard.ts
@@ -282,6 +282,14 @@ export default {
accountCost: 'Cost',
userBilled: 'User billed',
accountBilled: 'Account billed',
+ overdraftActive: 'Overdraft active',
+ overdraftRecoverAt: 'Expected recovery',
+ overdraftRetryAt: 'Next retry',
+ 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/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts
index 1d9b8f45746521dc6b35cc2f4e81385938661dc7..dd53bba760a8ea2fc8fcedb4a3396cb9eb4e2617 100644
--- a/frontend/src/i18n/locales/zh/dashboard.ts
+++ b/frontend/src/i18n/locales/zh/dashboard.ts
@@ -287,6 +287,14 @@ export default {
accountCost: '成本',
userBilled: '用户扣费',
accountBilled: '账号计费',
+ overdraftActive: '透支中',
+ overdraftRecoverAt: '预计恢复',
+ overdraftRetryAt: '下次重试',
+ overdraftTestedAt: '探测时间',
+ overdraftProbePending: '透支探测中',
+ overdraftProbeFailed: '已确认限额',
+ overdraftProbeInconclusive: '探测无法确认',
+ overdraftRecovered: '额度已恢复',
resetNow: '现在',
resetPending: '待刷新',
accountMultiplier: '账号倍率',
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 19ef541bc61fe6cc69fbc27f36c3258111536220..bde71c44b2d61d7264a4ba7ee83275d63d0c48f2 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -1261,6 +1261,30 @@ 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
+ 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 +1341,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