diff --git a/patches/README_CN.md b/patches/README_CN.md deleted file mode 100644 index 65da4ad53..000000000 --- a/patches/README_CN.md +++ /dev/null @@ -1,63 +0,0 @@ -# sub2api-overdraft Patch 说明 - -## Patch 信息 - -- 文件:`sub2api-overdraft-v0.1.176-fbfdcef81.patch` -- 上游项目: -- Sub2API 基础版本:`0.1.176` -- Git 描述:`v0.1.176-5-gfbfdcef81` -- 基础提交:`fbfdcef8184ae4b2e224d5cfc47cf1d0e3742710` -- 目标提交:`cdf8c1d6597cd5763660268e377f6717b0e84737` -- SHA-256:`60cb661e06406aa323344ccd2fe27fc31024e998148010e3ab987c5f0266bc79` -- 文件大小:158,794 字节 - -该 Patch 合并了以下两个提交: - -```text -e91ba0a8dca3efb65d55e7f4b31597fbe432f658 feat: add Codex quota overdraft support -cdf8c1d6597cd5763660268e377f6717b0e84737 docs: publish sub2api-overdraft fork -``` - -内容包括 Codex 5h / 7d 额度透支后端、前端显示、测试、配置、源码构建 Compose 文件、品牌和公开部署文档,共修改 41 个文件。 - -## 在精确基线上应用 - -先把 Patch 下载到仓库外的临时位置: - -```bash -curl -L \ - https://raw.githubusercontent.com/DeanZFC/sub2api-overdraft/codex-overdraft/patches/sub2api-overdraft-v0.1.176-fbfdcef81.patch \ - -o /tmp/sub2api-overdraft.patch -``` - -在 Sub2API 源码目录执行: - -```bash -git switch -c codex-overdraft-patched \ - fbfdcef8184ae4b2e224d5cfc47cf1d0e3742710 -git apply --check /tmp/sub2api-overdraft.patch -git apply --3way /tmp/sub2api-overdraft.patch -``` - -`git apply --check` 没有输出且退出码为 `0`,表示可以干净应用。 - -## 应用到较新的官方版本 - -Patch 的精确基线是 `fbfdcef81`。应用到后续 Sub2API 版本时,先创建独立分支,再使用三方合并模式: - -```bash -git switch -c codex-overdraft-reapply -git apply --3way /tmp/sub2api-overdraft.patch -``` - -如果上游修改了相同文件,Git 可能报告冲突。这时应按照根目录的 `CODEX_QUOTA_OVERDRAFT_CUSTOMIZATION.md` 逐项处理,并重新生成 Wire、运行后端和前端测试。不要对有运行数据或未提交修改的服务器目录直接强制应用。 - -## 完整性验证 - -本 Patch 已在临时工作树中从基础提交正向应用,并通过反向检查。应用后的 Git tree 为: - -```text -a555c6ef94e7e5a151a051c02eb600ee6f26e31a -``` - -它与目标提交 `cdf8c1d6597cd5763660268e377f6717b0e84737` 的 Git tree 完全一致。 diff --git a/patches/sub2api-overdraft-v0.1.176-fbfdcef81.patch b/patches/sub2api-overdraft-v0.1.176-fbfdcef81.patch deleted file mode 100644 index 5bbf5fa1c..000000000 --- a/patches/sub2api-overdraft-v0.1.176-fbfdcef81.patch +++ /dev/null @@ -1,3735 +0,0 @@ -# sub2api-overdraft combined patch -# -# Upstream project: https://github.com/Wei-Shaw/sub2api -# Base version: 0.1.176 (git describe: v0.1.176-5-gfbfdcef81) -# Base commit: fbfdcef8184ae4b2e224d5cfc47cf1d0e3742710 -# Target commit: cdf8c1d6597cd5763660268e377f6717b0e84737 -# Included commits: -# e91ba0a8dca3efb65d55e7f4b31597fbe432f658 feat: add Codex quota overdraft support -# cdf8c1d6597cd5763660268e377f6717b0e84737 docs: publish sub2api-overdraft fork -# -# Apply from the exact base or a compatible Sub2API tree: -# git apply --check sub2api-overdraft-v0.1.176-fbfdcef81.patch -# git apply --3way sub2api-overdraft-v0.1.176-fbfdcef81.patch -# -# Newer upstream versions may require conflict resolution. Run the backend and -# frontend verification commands documented in CODEX_QUOTA_OVERDRAFT_CUSTOMIZATION.md -# after applying the patch. - -diff --git a/CODEX_OVERDRAFT_DEPLOYMENT_CN.md b/CODEX_OVERDRAFT_DEPLOYMENT_CN.md -new file mode 100644 -index 0000000000000000000000000000000000000000..1417c3e166c24a8113b3e7dfcfa225c6a763b88e ---- /dev/null -+++ b/CODEX_OVERDRAFT_DEPLOYMENT_CN.md -@@ -0,0 +1,511 @@ -+# 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 的透支功能。 -+ -+## 功能范围 -+ -+开启 `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 次真实探测。 -+- 分别维护 5h、7d 透支周期,并在管理页面显示状态、请求数、Token、账号金额和预计恢复时间。 -+- 将探测状态保存在现有 `accounts.extra` JSONB 字段中,不新增数据库表,不需要手动执行数据库迁移(migration)。 -+ -+以下端点不启用透支:`/responses/compact`、图片生成、Embedding、Count Tokens 和 Live。 -+ -+## 前置条件 -+ -+- 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,才会启动探测。额度未耗尽时没有相关日志是正常现象。 -+ -+```bash -+docker logs --since 30m sub2api 2>&1 | \ -+ grep -E 'codex_quota_overdraft_(probe|state|pause)' -+``` -+ -+关键日志: -+ -+| 日志 | 含义 | -+| --- | --- | -+| `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`,页面可能不显示最新结果 | -+ -+真正“透支成功”的最直接证据是: -+ -+```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 -+ -+运行数据均位于被 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`,只需替换第一条路径。升级完成后重新执行“判断功能是否生效”中的镜像、环境变量和日志检查。 -+ -+## 合并 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%,没有探测日志属于正常情况。使用管理页面的主动刷新获取最新额度后,再发送一次符合范围的文本请求。 -+ -+### 页面仍显示“限流中” -+ -+本功能只绕过 5h/7d 额度阈值产生的预暂停。以下情况仍会正常限流或暂停:账号禁用、401/403、5 次探测均确认额度耗尽、代理/网络错误、模型级冷却、并发限制或其他上游 429。先按 `account_id` 检查应用日志和数据库中的 `reason_code`。 -+ -+### `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..5a2efaef7e5c2377377ca523a2f3bf5409c6a188 ---- /dev/null -+++ b/CODEX_QUOTA_OVERDRAFT_CUSTOMIZATION.md -@@ -0,0 +1,146 @@ -+# sub2api-overdraft 5h / 7d 额度透支定制记录 -+ -+本文件是维护者实现与升级记录。面向部署者的完整安装、验证、升级和故障排查说明见 -+[CODEX_OVERDRAFT_DEPLOYMENT_CN.md](CODEX_OVERDRAFT_DEPLOYMENT_CN.md)。 -+ -+## 基本信息 -+ -+- 实施基线:`fbfdcef81` -+- 参考实现: -+- 功能开关:`gateway.codex_quota_overdraft_enabled` -+- 代码默认值:关闭;`deploy/config.example.yaml` 部署示例默认开启 -+ -+## 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 首轮和后续轮 -+ -+每次最终发往上游前,如果 `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/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|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 -+``` -+ -+数据库状态可这样检查: -+ -+```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..9f50fe85a1ca942a488715afef4628cde5795a1a 100644 ---- a/Dockerfile -+++ b/Dockerfile -@@ -108,9 +108,10 @@ FROM ${POSTGRES_IMAGE} AS pg-client - FROM ${ALPINE_IMAGE} - - # Labels --LABEL maintainer="Wei-Shaw " --LABEL description="Sub2API - AI API Gateway Platform" --LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api" -+LABEL maintainer="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/README.md b/README.md -index f4ad2fa6b028bdc70ac0b502104a345dd8d940c1..4e0f4be6eea519b4c5c64928924776ebc4721b08 100644 ---- a/README.md -+++ b/README.md -@@ -1,8 +1,8 @@ -
- --Sub2API Logo -+sub2api-overdraft Logo - --# 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/) - --Wei-Shaw%2Fsub2api | Trendshift -- --**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) - -
- -+> [!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..ad52e4472d587e76e906d3419ce9be0d8f3e7bda 100644 ---- a/README_CN.md -+++ b/README_CN.md -@@ -1,8 +1,8 @@ -
- --Sub2API Logo -+sub2api-overdraft Logo - --# 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,73 @@ - [![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/) - --Wei-Shaw%2Fsub2api | Trendshift -- --**AI API 网关平台 - 订阅配额分发管理** -+**带 Codex 5h / 7d 额度透支探测、统计与恢复的 AI API 网关** - - [English](README.md) | 中文 | [日本語](README_JA.md) - -
- -+> [!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 的调度和请求行为。 -+ -+## 快速部署 -+ -+完整步骤、现有服务器迁移、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` 已通过环境变量默认开启该功能。 -+ -+## 如何确认透支成功 -+ -+账号额度达到 100% 后检查日志: -+ -+```bash -+docker logs --since 30m sub2api 2>&1 | \ -+ grep -E 'codex_quota_overdraft_(probe|state|pause)' -+``` -+ -+出现 `codex_quota_overdraft_probe_passed`,页面显示“透支中”,并且后续真实业务请求成功,即可确认透支功能完整生效。额度未达到 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 部署指南为准。 - - ## ⚠️ 重要提醒 - -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 @@ -
- --Sub2API Logo -+sub2api-overdraft Logo - --# 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/) - --Wei-Shaw%2Fsub2api | Trendshift -- --**サブスクリプションクォータ配分のための AI API ゲートウェイプラットフォーム** -+**Codex 5h / 7d クォータのオーバードラフト検証・集計・回復機能を追加した AI API ゲートウェイ** - - [English](README.md) | [中文](README_CN.md) | 日本語 - -
- -+> [!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..83b97e68f139e590d6bfa7e04007bd6eb56cb9fd 100644 ---- a/backend/cmd/server/wire_gen.go -+++ b/backend/cmd/server/wire_gen.go -@@ -199,7 +199,8 @@ 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) -+ 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) - 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) -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/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..cde930e0abe678514e44e8db90f5f8659d8256b8 ---- /dev/null -+++ b/backend/internal/service/openai_codex_quota_overdraft_probe.go -@@ -0,0 +1,942 @@ -+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 -+ if state != nil && sameCodexQuotaOverdraftTime(account.RateLimitResetAt, state.ObservedRateLimit) { -+ _ = c.accountRepo.ClearRateLimit(ctx, accountID) -+ clearedSchedulingState = true -+ } -+ 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 sameCodexQuotaOverdraftTime(left, right *time.Time) bool { -+ if left == nil || right == nil { -+ return false -+ } -+ delta := left.Sub(*right) -+ if delta < 0 { -+ delta = -delta -+ } -+ return delta <= time.Second -+} -+ -+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..fa1d0835491b19ede65faf0a0af38d9b17fecba1 ---- /dev/null -+++ b/backend/internal/service/openai_codex_quota_overdraft_probe_test.go -@@ -0,0 +1,319 @@ -+//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 -+} -+ -+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 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/wire.go b/backend/internal/service/wire.go -index 2e26c0035c22c55a4b89eb7bd39cc2b05ed2f7f4..2b58b8797e0ce2ab361497e155ead00d954c4628 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, -@@ -799,6 +827,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..3d81a0d37476034f0cc4649107dcfbf7a27c31b9 ---- /dev/null -+++ b/deploy/docker-compose.overdraft.yml -@@ -0,0 +1,15 @@ -+# 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:-codex-overdraft} -+ COMMIT: ${BUILD_COMMIT:-local} -+ 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 @@ - - - -- Sub2API - AI API Gateway -+ sub2api-overdraft - AI API Gateway - - -
-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 @@ - -