diff --git a/README.md b/README.md index 4cbd50f621..5284d7a7eb 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,8 @@ next Codex session. opencodex keeps these behaviors: quota window across 5h, weekly, and 30d usage, then picks a lower-usage eligible account for new sessions once the active account crosses the threshold. - **Quota lookup is built in.** The dashboard can refresh all account quotas in one click, and the - request log labels pool traffic with non-PII account ordinals. + request log labels pool traffic with non-PII account ordinals. **Pause exhausted** refreshes the + pool and pauses only accounts whose relevant quota window is freshly confirmed at 100%. - **Failures fail closed.** Token failures mark reauthentication instead of falling back to another credential silently; 429 quota responses put the account in cooldown and can fail over future work to another eligible pool account. diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index 2cf4d114a6..7b4fc3c55d 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -51,6 +51,7 @@ namespaced selected id を bare id に変えます。 | `codexShimAutoRestore?` | `boolean` | `true` | 完了した外部 Codex 更新で以前にインストールした shim が置換された場合に復元します。無効にするには `false`、またはプロセスで `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定します。 | | `syncResumeHistory?` | `boolean` | `true` | 戻せる Codex App 履歴互換モード。opencodex は元の Codex thread metadata をバックアップし、旧 OpenAI interactive row を `opencodex` に再マッピングし、opencodex が作成した `exec` row を App に見えるソースとして一時的に昇格します。`ocx stop` / `ocx restore` はバックアップした OpenAI row を復元し、残った opencodex user thread を OpenAI に戻し、ネイティブ Codex が `config.toml` からプロキシを削除した後でも開き続けられるようにします。オフにするには `false` に設定します。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth ダッシュボードが管理する ChatGPT/Codex pool アカウント metadata。secret は `codex-accounts.json` に別途置きます。 | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Codex Auth で再開するまで、今後のすべての Pool 選択から除外するアカウント ID。メインを一時停止した場合は `__main__` も含みます。 | | `activeCodexAccountId?` | `string` | — | 手動選択した pool アカウント。既存 thread affinity を消去して次のリクエストから適用し、処理中のリクエストは現在のアカウントを維持します。 | | `autoSwitchThreshold?` | `number` | `80` | 新しいセッション自動切替用の使用量百分率 threshold。既知の 5 時間、週次、30 日 quota window のうち最も高いスコアを使います。`0` なら quota 自動切替をオフにします。`quota` 戦略と `fill-first` の drain threshold にも使います。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool の新しいセッション rotation 戦略。**新しいセッションのみ**に適用され、既存 thread id は affinity を維持します。`quota`(既定)— アクティブアカウントが `autoSwitchThreshold` を超えたら既知 usage 最小を選択。`round-robin` — 適格アカウント間を smooth weighted で均等分散。`fill-first` — cooldown、使用不可、または(設定時)`autoSwitchThreshold` までアクティブアカウントを使い切り(未知 usage は強制切替しない)、安定ソート順で次へ。 | @@ -75,6 +76,9 @@ pool アカウントの追加と quota 更新はダッシュボードの **Codex ないアカウント metadata だけを保存し、access/refresh token は強化された Codex アカウント credential store に別途 保管します。既存 thread id はアカウント affinity を維持し、新しいセッションは `accountPoolStrategy`、quota、cooldown、health に 応じて自動ルーティングされます。 +一時停止したアカウントと quota metadata は表示されたままですが、自動切り替え、再試行/failover 選択、cooldown 復旧プローブ、手動有効化の対象外です。 +状態は再起動後も保持され、すべてのアカウントが一時停止中なら Pool ルーティングは別のアカウントを暗黙に選ばず失敗します。 +**上限到達を一括停止** は全アカウントを先に更新し、関連する quota window が今回 100% と確認できたアカウントだけを停止します。quota が不明、または更新に失敗したアカウントは変更しません。 **rotation 戦略**(新しいセッションのみ;bound thread は不変):`quota`(既定)— `autoSwitchThreshold` 超過時に最小 usage を選択;`round-robin` — 均等分散、`accountPoolStickyLimit`(既定 `1`、1–100)で 1 選択あたりの成功 bind 数;`fill-first` — アクティブアカウントを cooldown、再認証、または threshold まで使い切り(未知 usage は強制切替しない)後、安定ソート順で次へ。rotation は provider enforcement を回避しません — 複数アカウント利用は ToS 違反の可能性があります。 ::: diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index f91e3d116d..9be3bc26c2 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -52,6 +52,7 @@ namespaced selected id를 bare id로 바꿉니다. | `codexShimAutoRestore?` | `boolean` | `true` | 완료된 외부 Codex 업데이트가 이전에 설치한 shim을 교체하면 자동으로 복구합니다. 끄려면 `false`로 설정하거나 프로세스에 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`을 설정합니다. | | `syncResumeHistory?` | `boolean` | `true` | 되돌릴 수 있는 Codex App 기록 호환 모드. opencodex가 원래 Codex thread metadata를 백업하고, 예전 OpenAI interactive row를 `opencodex`로 재매핑하며, opencodex가 만든 `exec` row를 App에 보이는 source로 잠시 승격합니다. `ocx stop` / `ocx restore`는 백업한 OpenAI row를 복원하고 남은 opencodex user thread를 OpenAI로 돌려 네이티브 Codex가 `config.toml`에서 프록시를 제거한 뒤에도 이어서 열 수 있게 합니다. 끄려면 `false`로 설정합니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 대시보드에서 관리하는 ChatGPT/Codex pool 계정 metadata. secret은 `codex-accounts.json`에 따로 둡니다. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Codex Auth에서 재개할 때까지 이후의 모든 Pool 선택에서 제외할 계정 ID. 메인 계정을 일시 중지하면 `__main__`도 포함됩니다. | | `activeCodexAccountId?` | `string` | — | 수동으로 선택한 pool 계정. 선택 시 기존 thread affinity를 지우고 다음 요청부터 적용하며, 진행 중인 요청은 기존 계정을 유지합니다. | | `autoSwitchThreshold?` | `number` | `80` | 새 세션 자동 전환용 사용량 백분율 threshold. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`이면 quota 자동 전환을 끕니다. `quota` 전략과 `fill-first` drain threshold에도 사용됩니다. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool의 새 세션 rotation 전략. **새 세션에만** 적용되며 기존 thread id는 affinity를 유지합니다. `quota`(기본) — 활성 계정이 `autoSwitchThreshold`를 넘으면 알려진 usage가 가장 낮은 계정 선택. `round-robin` — 적격 계정 간 smooth weighted 균등 분배. `fill-first` — cooldown, 사용 불가 또는(설정 시) `autoSwitchThreshold`까지 활성 계정을 소진(알 수 없는 usage는 강제 전환하지 않음)한 뒤 안정 정렬 순으로 다음 계정. | @@ -77,6 +78,9 @@ pool 계정 추가와 quota 갱신은 대시보드의 **Codex Auth** 페이지 아닌 계정 metadata만 저장하고, access/refresh token은 강화된 Codex 계정 credential store에 따로 보관합니다. 기존 thread id는 계정 affinity를 유지하며, 새 세션은 `accountPoolStrategy`, quota, cooldown, health에 따라 자동 라우팅됩니다. +일시 중지된 계정과 quota metadata는 계속 표시되지만 자동 전환, 재시도/failover 선택, cooldown 복구 probe, 수동 활성화에서는 제외됩니다. +상태는 재시작 후에도 유지되며, 모든 계정이 일시 중지되면 Pool 라우팅은 계정을 몰래 선택하지 않고 실패합니다. +**한도 도달 계정 일시 중지**는 먼저 모든 계정을 새로고친 뒤 관련 quota window가 이번 응답에서 100%로 확인된 계정만 일시 중지합니다. quota가 없거나 새로고침에 실패한 계정은 변경하지 않습니다. **rotation 전략**(새 세션만; bound thread는 변경 없음): `quota`(기본) — `autoSwitchThreshold` 초과 시 최저 usage 선택; `round-robin` — 균등 분배, `accountPoolStickyLimit`(기본 `1`, 1–100)로 한 선택당 diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 01e2aa6f36..1dd7311c31 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -57,6 +57,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `codexShimAutoRestore?` | `boolean` | `true` | Restore a previously installed Codex shim when a completed external Codex update replaces it. Set `false`, or set `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` for a process-level opt-out. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility mode. opencodex backs up original Codex thread metadata, remaps old OpenAI interactive rows to `opencodex`, and temporarily promotes opencodex-created `exec` rows to an app-visible source. `ocx stop` / `ocx restore` restore backed-up OpenAI rows and eject remaining opencodex user threads to OpenAI so native Codex can resume them after the proxy is removed from `config.toml`. Set `false` to opt out. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by the Codex Auth dashboard. Secrets live separately in `codex-accounts.json`. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from every future Pool selection until resumed in Codex Auth. Includes the main `__main__` account when paused. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account. Selection clears existing thread affinity and applies to the next request; in-flight requests keep their captured account. | | `autoSwitchThreshold?` | `number` | `80` | Usage percent threshold for new-session auto-switching. The score uses the hottest known 5h, weekly, or 30d quota window. Set `0` to disable quota auto-switching. Used by the `quota` strategy and as the drain threshold for `fill-first`. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session rotation strategy for the Codex pool. Applies to **new sessions only**; existing thread ids keep affinity. `quota` — today's default: pick the lowest known usage when the active account crosses `autoSwitchThreshold`. `round-robin` — even spread across eligible accounts via smooth weighted selection. `fill-first` — keep the active account until it cools down, becomes unusable, or crosses `autoSwitchThreshold` when set (unknown usage does not force a switch), then advance to the next eligible account in stable sorted order. | @@ -105,6 +106,11 @@ credential store. Existing thread ids keep account affinity, while new sessions `accountPoolStrategy`, quota, cooldown, and health. A pre-stream upstream **429**/**402** on one pool account is retried once on an eligible alternate account in the same request (so Codex CLI does not stall on a depleted primary while another account still has quota). +Pause keeps an account and its quota metadata visible, but excludes it from automatic switching, +retry/failover selection, cooldown recovery probes, and manual activation. The exclusion survives +restarts; if every account is paused, Pool routing fails instead of silently selecting one. +**Pause exhausted** first refreshes every account and pauses only those whose relevant quota window +is freshly confirmed at 100%; unknown quota and failed refreshes are left unchanged. ::: **Rotation strategies** (new sessions only; bound threads are unchanged): diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index c6b674e49a..1272488859 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -56,6 +56,7 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `codexShimAutoRestore?` | `boolean` | `true` | Восстанавливает ранее установленный shim после того, как завершённое внешнее обновление Codex заменило его. Для отключения задайте `false` или установите процессу `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | | `syncResumeHistory?` | `boolean` | `true` | Обратимый режим совместимости истории Codex App. opencodex резервирует исходные метаданные потоков Codex, переназначает старые интерактивные строки OpenAI на `opencodex` и временно повышает созданные opencodex строки `exec` до видимого в приложении источника. `ocx stop` / `ocx restore` восстанавливают зарезервированные строки OpenAI и возвращают оставшиеся пользовательские потоки opencodex обратно к OpenAI, чтобы нативный Codex мог возобновлять их после удаления прокси из `config.toml`. Установите `false`, чтобы отказаться. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, управляемые дашбордом Codex Auth. Секреты хранятся отдельно в `codex-accounts.json`. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | ID аккаунтов, исключённых из всех будущих выборов Pool до возобновления в Codex Auth. При паузе основного аккаунта включает `__main__`. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт пула. Выбор очищает существующие привязки потоков и действует со следующего запроса; выполняющиеся запросы сохраняют захваченный аккаунт. | | `autoSwitchThreshold?` | `number` | `80` | Порог процента использования для автопереключения новых сессий. Оценка использует самое «горячее» из известных окон квоты — 5-часовое, недельное или 30-дневное. Установите `0`, чтобы отключить автопереключение по квоте. Используется стратегией `quota` и как порог исчерпания для `fill-first`. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия ротации новых сессий для пула Codex. Применяется **только к новым сессиям**; существующие id потоков сохраняют affinity. `quota` (по умолчанию) — выбор наименьшего известного usage, когда активный аккаунт превышает `autoSwitchThreshold`. `round-robin` — равномерное распределение между подходящими аккаунтами через smooth weighted selection. `fill-first` — использовать активный аккаунт до cooldown, недоступности или (если задано) `autoSwitchThreshold` (неизвестный usage не принуждает к переключению), затем переход к следующему подходящему аккаунту в стабильном отсортированном порядке. | @@ -83,6 +84,10 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е защищённом хранилище учётных данных аккаунтов Codex. Существующие id потоков сохраняют привязку к аккаунту; новые сессии маршрутизируются по `accountPoolStrategy`, квоте, cooldown и работоспособности. +Приостановленный аккаунт и его метаданные квоты остаются видимыми, но исключаются из автоматического переключения, +повторов/failover, проб восстановления cooldown и ручной активации. Состояние сохраняется после перезапуска; +если приостановлены все аккаунты, маршрутизация Pool завершается ошибкой, а не выбирает аккаунт скрытно. +**Приостановить исчерпанные** сначала обновляет все аккаунты и приостанавливает только те, для которых актуальное окно квоты в этом ответе подтверждено на уровне 100%. Аккаунты с неизвестной квотой или неудачным обновлением не меняются. **Стратегии ротации** (только новые сессии; привязанные потоки не меняются): `quota` (по умолчанию) — выбор наименьшего usage при превышении `autoSwitchThreshold`; `round-robin` — равномерное diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index da12eab6de..46a01b1832 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -50,6 +50,7 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `codexShimAutoRestore?` | `boolean` | `true` | 已完成的外部 Codex 更新替换此前安装的 shim 时自动恢复。若要关闭,请设为 `false`,或为进程设置 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容模式。opencodex 会备份原始 Codex thread metadata,把旧 OpenAI interactive row 重映射到 `opencodex`,并暂时把 opencodex 创建的 `exec` row 提升成 App 可见 source。`ocx stop` / `ocx restore` 会恢复已备份的 OpenAI row,并把剩余 opencodex user thread 转回 OpenAI,使原生 Codex 在从 `config.toml` 移除代理后仍能继续这些 thread。设为 `false` 可退出该模式。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 仪表盘管理的 ChatGPT/Codex pool account metadata。secret 单独存放在 `codex-accounts.json`。 | +| `pausedCodexAccountIds?` | `string[]` | `[]` | 在 Codex Auth 中恢复前,不参与任何后续 Pool 选择的账号 ID。暂停主账号时也包含 `__main__`。 | | `activeCodexAccountId?` | `string` | — | 手动选择的 pool account。选择时清除已有 thread affinity,并从下一次请求开始生效;进行中的请求保留原账号。 | | `autoSwitchThreshold?` | `number` | `80` | 新 session 自动切换的 usage 百分比 threshold。分数取已知 5 小时、周或 30 天 quota window 中最高的一项。设为 `0` 可禁用 quota 自动切换。`quota` 策略和 `fill-first` 的耗尽 threshold 都会用到。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool 的新 session 轮换策略。仅适用于**新 session**;已有 thread id 保留 affinity。`quota`(默认):活跃账号超过 `autoSwitchThreshold` 时选已知 usage 最低者。`round-robin`:在合格账号间平滑加权均分。`fill-first`:持续使用活跃账号,直到 cooldown、不可用或(如已设置)超过 `autoSwitchThreshold`(未知 usage 不会强制切换),再按稳定排序进入下一个合格账号。 | @@ -73,6 +74,9 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 请在仪表盘 **Codex Auth** 页面添加 pool account 并刷新 quota。配置只保存非 secret account metadata;access/refresh token 存放在加固的 Codex account credential store 中。已有 thread id 会 保留 account affinity;新 session 按 `accountPoolStrategy`、quota、cooldown 和 health 自动路由。 +暂停后仍会显示账号及其 quota metadata,但不会参与自动切换、重试/failover 选择、cooldown 恢复探测或手动激活。 +暂停状态会跨重启保留;如果所有账号均已暂停,Pool 路由会明确失败,而不会暗中选择某个账号。 +**暂停已达上限账号** 会先刷新全部账号,只暂停相关 quota window 本次明确返回 100% 的账号;未知额度或刷新失败的账号保持不变。 **轮换策略**(仅新 session;已绑定 thread 不变):`quota`(默认)— 活跃账号 usage 超过 `autoSwitchThreshold` 时选最低者;`round-robin` — 均分,`accountPoolStickyLimit`(默认 `1`, diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 2a52c310d3..21aa730265 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -29,7 +29,7 @@ const DOCTOR_CMD = "ocx doctor"; * Auth page (WP060). `accountModeState` arrives as a prop (the parent owns the * /api/config fetch); `banner` is an optional slot rendered above the main card * (the Codex Auth page passes its mode banner); `embedded` (WP090) omits page - * chrome — currently a no-op stub reserved for the Providers workspace. + * title chrome while retaining the shared account actions in the Providers workspace. */ export default function CodexAccountPool({ apiBase, accountModeState = null, banner = null, embedded = false, onActiveNeedsReauthChange, controller: injectedController }: { apiBase: string; @@ -55,7 +55,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban // but stays inert (no load, no polling) whenever a shared controller was injected. const ownController = useCodexAccountPool(apiBase, !injectedController); const controller = injectedController ?? ownController; - const { accounts, activeId, loadState, switchingId, load } = controller; + const { accounts, activeId, loadState, switchingId, pauseUpdatingId, pausingExhausted, load } = controller; const [confirm, setConfirm] = useState(null); const [showAdd, setShowAdd] = useState(false); const [reauthId, setReauthId] = useState(null); @@ -156,6 +156,20 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban setToast(t(result.ok ? "prov.aliasSaved" : "prov.aliasSaveFailed")); }; + const togglePaused = async (account: CodexAccountEntry) => { + const paused = !account.paused; + const result = await controller.setAccountPaused(account.id, paused); + if (!result.ok && result.reason === "busy") return; + setConfirm(current => current?.id === account.id ? null : current); + setToastError(!result.ok); + setToast(t(result.ok + ? paused ? "codexAuth.pauseSucceeded" : "codexAuth.resumeSucceeded" + : paused ? "codexAuth.pauseFailed" : "codexAuth.resumeFailed", { + email: account.alias ?? account.email, + })); + setTimeout(() => setToast(""), 5000); + }; + const remove = async (id: string) => { const label = accounts.find(account => account.id === id)?.email ?? t("pws.accountOrdinal", { count: "1" }); if (!window.confirm(t("codexAuth.removeConfirm", { id: label }))) return; @@ -178,6 +192,18 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban } }; + const pauseExhausted = async () => { + const result = await controller.pauseExhaustedAccounts(); + if (!result.ok && result.reason === "busy") return; + setToastError(!result.ok); + setToast(result.ok + ? result.pausedCount > 0 + ? t("codexAuth.pauseExhaustedSucceeded", { count: String(result.pausedCount) }) + : t("codexAuth.pauseExhaustedNone") + : t("codexAuth.pauseExhaustedFailed")); + setTimeout(() => setToast(""), 5000); + }; + const openResetPopup = async (account: CodexAccountEntry) => { setResetPopup(account); setResetConfirm(false); @@ -216,7 +242,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const main = accounts.find(a => a.isMain); const pool = accounts.filter(a => !a.isMain); - const isMainActive = !activeId || activeId === "__main__"; + const isMainActive = !main?.paused && (!activeId || activeId === "__main__"); const switchActionLabel = t(accountModeState === "direct" ? "codexAuth.prepareForPool" : "codexAuth.setAsNext"); return ( @@ -225,7 +251,9 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban t={t} embedded={embedded} refreshingQuota={refreshingQuota} + pausingExhausted={pausingExhausted} onRefresh={() => { void refreshQuotas(); }} + onPauseExhausted={() => { void pauseExhausted(); }} /> {toast && {toast}} @@ -247,6 +275,9 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban threshold={autoSwitch.threshold ?? 0} switchActionLabel={switchActionLabel} onSwitch={setConfirm} + onTogglePause={togglePaused} + pauseUpdatingId={pauseUpdatingId} + pauseBusy={pauseUpdatingId !== null || pausingExhausted} onOpenReset={openResetPopup} onCopyDoctor={copyDoctor} doctorCopyOutcomeFor={doctorCopy.outcomeFor} @@ -274,6 +305,9 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban threshold={autoSwitch.threshold ?? 0} onOpenReset={openResetPopup} onSwitch={setConfirm} + onTogglePause={togglePaused} + pauseUpdatingId={pauseUpdatingId} + pauseBusy={pauseUpdatingId !== null || pausingExhausted} onReauth={openReauth} onEditAlias={editAlias} onRemove={remove} diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index 478b0e8a21..605f3a4585 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -1,5 +1,5 @@ import { useT } from "../i18n/shared"; -import { IconAlert, IconX } from "../icons"; +import { IconAlert, IconPause, IconPlay, IconX } from "../icons"; import { displayAccountId } from "../lib/privacy"; import type { CodexAccountEntry } from "./codex-account-pool-types"; import type { CodexAccountModeState } from "../codex-multi-state"; @@ -23,6 +23,9 @@ export function CodexAccountPoolCards({ threshold, onOpenReset, onSwitch, + onTogglePause, + pauseUpdatingId, + pauseBusy, onReauth, onEditAlias, onRemove, @@ -36,6 +39,9 @@ export function CodexAccountPoolCards({ threshold: number; onOpenReset: (account: CodexAccountEntry) => void; onSwitch: (account: CodexAccountEntry) => void; + onTogglePause: (account: CodexAccountEntry) => void; + pauseUpdatingId: string | null; + pauseBusy: boolean; onReauth: (id: string) => void; onEditAlias: (account: CodexAccountEntry) => void; onRemove: (id: string) => void; @@ -43,7 +49,7 @@ export function CodexAccountPoolCards({ doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null; }) { const t = useT(); - const isNext = (id: string) => activeId === id; + const isNext = (account: CodexAccountEntry) => !account.paused && activeId === account.id; return ( <> @@ -54,24 +60,25 @@ export function CodexAccountPoolCards({ const healthLabel = formatOAuthHealthLabel(t, a.health); const healthSummary = formatOAuthHealthSummary(t, "codex", a.id, a.health); return ( -
+
- + {a.alias ?? a.email} {a.plan && {a.plan}} + {a.paused && {t("codexAuth.paused")}} onOpenReset(a)} /> {healthLabel && ( {healthLabel} )} {showReauth && !healthLabel && {t("codexAuth.needsReauth")}} - {isNext(a.id) && !showReauth && !inCooldown && ( + {isNext(a) && !showReauth && !inCooldown && ( {t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession")} )} - {!isNext(a.id) && !showReauth && !inCooldown && ( + {!a.paused && !isNext(a) && !showReauth && !inCooldown && ( @@ -86,6 +93,15 @@ export function CodexAccountPoolCards({ {doctorCopyButtonLabel(t, doctorCopyOutcomeFor?.(a.id))} )} + @@ -103,6 +119,7 @@ export function CodexAccountPoolCards({ {healthSummary && (
{healthSummary}
)} + {a.paused &&
{t("codexAuth.pausedHint")}
} {inCooldown && (
{t("pws.healthCooldownHint")}
)} diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index 7f205a4da2..0e03e40f57 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { IconLock, IconRefresh } from "../icons"; +import { IconLock, IconPause, IconPlay, IconRefresh } from "../icons"; import QuotaBars from "./QuotaBars"; import { CodexTicketBadge } from "./codex-account-pool-helpers"; import type { CodexAccountEntry } from "./codex-account-pool-types"; @@ -23,6 +23,9 @@ export function CodexAccountPoolMainCard({ threshold, switchActionLabel, onSwitch, + onTogglePause, + pauseUpdatingId, + pauseBusy, onOpenReset, onCopyDoctor, doctorCopyOutcomeFor, @@ -34,6 +37,9 @@ export function CodexAccountPoolMainCard({ threshold: number; switchActionLabel: string; onSwitch: (entry: CodexAccountEntry) => void; + onTogglePause: (entry: CodexAccountEntry) => void; + pauseUpdatingId: string | null; + pauseBusy: boolean; onOpenReset: (account: CodexAccountEntry) => void; onCopyDoctor?: (accountId: string) => void; doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null; @@ -45,6 +51,7 @@ export function CodexAccountPoolMainCard({ email: main?.email || mainFallbackLabel, plan: main?.plan, isMain: true, + paused: main?.paused ?? false, hasCredential: true, quota: main?.quota ?? null, }; @@ -62,17 +69,20 @@ export function CodexAccountPoolMainCard({ {t("codexAuth.mainAccount")} {main && onOpenReset({ ...main, id: "__main__" } as CodexAccountEntry)} />} + {main?.paused && {t("codexAuth.paused")}} {healthLabel && ( {healthLabel} )} {showReauth && !healthLabel && {t("codexAuth.needsReauth")}} - - {isMainActive - ? t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession") - : t("codexAuth.current")} - + {!main?.paused && ( + + {isMainActive + ? t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession") + : t("codexAuth.current")} + + )} - {!isMainActive && !showReauth && !inCooldown && ( + {!main?.paused && !isMainActive && !showReauth && !inCooldown && ( @@ -82,12 +92,24 @@ export function CodexAccountPoolMainCard({ {doctorCopyButtonLabel(t, doctorCopyOutcomeFor?.(mainId))} )} + {main && ( + + )} {t("codexAuth.appLogin")}
{main?.email || t("codexAuth.appLogin")}{main?.plan ? ` · ${main.plan}` : ""}
{healthSummary && (
{healthSummary}
)} + {main?.paused &&
{t("codexAuth.pausedHint")}
} {inCooldown && (
{t("pws.healthCooldownHint")}
)} @@ -102,20 +124,41 @@ export function CodexAccountPoolPageHead({ t, embedded, refreshingQuota, + pausingExhausted, onRefresh, + onPauseExhausted, }: { t: TFn; embedded: boolean; refreshingQuota: boolean; + pausingExhausted: boolean; onRefresh: () => void; + onPauseExhausted: () => void; }) { - if (embedded) return null; return ( -
-

{t("nav.codexAuth")}

- +
+ {!embedded &&

{t("nav.codexAuth")}

} +
+ + +
); } diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index f23b7f73c8..dd5485e3c3 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -21,6 +21,8 @@ export interface CodexAccountEntry { plan?: string; /** Required, not optional: the API always distinguishes the app-login row. */ isMain: boolean; + /** Persisted routing exclusion. Paused accounts remain visible but cannot be selected. */ + paused: boolean; hasCredential: boolean; quota: AccountQuota | null; needsReauth?: boolean; @@ -56,10 +58,14 @@ export interface CodexAccountPoolController { activeId: string | null; loadState: CodexAccountLoadState; switchingId: string | null; + pauseUpdatingId: string | null; + pausingExhausted: boolean; activeNeedsReauth: boolean; load(refreshQuota?: boolean): Promise; switchAccount(id: string | null): Promise>; + setAccountPaused(id: string, paused: boolean): Promise; + pauseExhaustedAccounts(): Promise>; saveAlias(id: string, alias: string): Promise; removeAccount(id: string): Promise; syncAfterAccountAdded(): Promise; @@ -78,6 +84,8 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou const [activeId, setActiveId] = useState(null); const [loadState, setLoadState] = useState("loading"); const [switchingId, setSwitchingId] = useState(null); + const [pauseUpdatingId, setPauseUpdatingId] = useState(null); + const [pausingExhausted, setPausingExhausted] = useState(false); // Pause leases live in a ref: pausing must not re-render, and the effect below reads // the live set rather than a captured snapshot. const [pauseCount, setPauseCount] = useState(0); @@ -91,6 +99,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou // load already finished read it to seed their UI instead of waiting a poll interval. const lastThresholdRef = useRef<{ value: unknown } | null>(null); const switchingRef = useRef(null); + const pauseMutationRef = useRef<"bulk" | { accountId: string } | null>(null); const subscribeLoadObserver = useCallback((observer: CodexAccountLoadObserver) => { observersRef.current.add(observer); @@ -230,6 +239,67 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou } }, [apiBase, load]); + const setAccountPaused = useCallback(async (id: string, paused: boolean) => { + if (pauseMutationRef.current) return { ok: false, reason: "busy" } as const; + pauseMutationRef.current = { accountId: id }; + setPauseUpdatingId(id); + try { + const response = await fetch(`${apiBase}/api/codex-auth/accounts/pause`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, paused }), + }); + if (!response.ok) return { ok: false, reason: "request" } as const; + const result = await response.json() as { activeCodexAccountId?: string | null }; + setAccounts(current => current.map(account => ( + account.id === id || (id === "__main__" && account.isMain) + ? { ...account, paused } + : account + ))); + const nextActiveId = result.activeCodexAccountId ?? null; + pendingActiveIdRef.current = { id: nextActiveId }; + setActiveId(nextActiveId); + void load(); + return { ok: true } as const; + } catch { + return { ok: false, reason: "request" } as const; + } finally { + pauseMutationRef.current = null; + setPauseUpdatingId(null); + } + }, [apiBase, load]); + + const pauseExhaustedAccounts = useCallback(async () => { + if (pauseMutationRef.current) return { ok: false, reason: "busy" } as const; + pauseMutationRef.current = "bulk"; + setPausingExhausted(true); + try { + const response = await fetch(`${apiBase}/api/codex-auth/accounts/pause-exhausted`, { method: "PUT" }); + if (!response.ok) return { ok: false, reason: "request" } as const; + const result = await response.json() as { + pausedAccountIds?: string[]; + pausedCount?: number; + activeCodexAccountId?: string | null; + }; + const pausedIds = new Set(result.pausedAccountIds ?? []); + setAccounts(current => current.map(account => ( + pausedIds.has(account.id) || (pausedIds.has("__main__") && account.isMain) + ? { ...account, paused: true } + : account + ))); + const nextActiveId = result.activeCodexAccountId ?? null; + pendingActiveIdRef.current = { id: nextActiveId }; + setActiveId(nextActiveId); + void load(); + return { ok: true, pausedCount: result.pausedCount ?? pausedIds.size } as const; + } catch { + return { ok: false, reason: "request" } as const; + } finally { + pauseMutationRef.current = null; + setPausingExhausted(false); + } + }, [apiBase, load]); + const removeAccount = useCallback(async (id: string) => { try { const response = await fetch( @@ -254,16 +324,21 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou : null; const mainAccount = accounts.find(a => a.isMain); // Include health-only reauth so Providers overview attention matches row CTAs. - const activeNeedsReauth = accountNeedsReauth(activePoolAccount ?? mainAccount); + const activeAccount = activePoolAccount ?? mainAccount; + const activeNeedsReauth = !activeAccount?.paused && accountNeedsReauth(activeAccount); return { accounts, activeId, loadState, switchingId, + pauseUpdatingId, + pausingExhausted, activeNeedsReauth, load, switchAccount, + setAccountPaused, + pauseExhaustedAccounts, saveAlias, removeAccount, syncAfterAccountAdded, diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 11167fc3bf..5e85333edd 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -646,7 +646,20 @@ export const de: Record = { "codexAuth.refreshingQuota": "Aktualisiere…", "codexAuth.quotaRefreshed": "Kontingente aktualisiert", "codexAuth.quotaRefreshFailed": "Kontingente konnten nicht aktualisiert werden", + "codexAuth.pauseExhausted": "Ausgeschöpfte pausieren", + "codexAuth.pausingExhausted": "Kontingente werden geprüft…", + "codexAuth.pauseExhaustedSucceeded": "Konten am Limit pausiert: {count}", + "codexAuth.pauseExhaustedNone": "Keine Konten mit bestätigter 100-%-Nutzung.", + "codexAuth.pauseExhaustedFailed": "Ausgeschöpfte Konten konnten nicht geprüft und pausiert werden.", "codexAuth.noPool": "Noch keine Pool-Konten hinzugefügt.", + "codexAuth.pause": "Pausieren", + "codexAuth.resume": "Fortsetzen", + "codexAuth.paused": "PAUSIERT", + "codexAuth.pauseSucceeded": "{email} ist pausiert", + "codexAuth.resumeSucceeded": "{email} ist wieder im Pool verfügbar", + "codexAuth.pauseFailed": "{email} konnte nicht pausiert werden. Es wurde nichts geändert.", + "codexAuth.resumeFailed": "{email} konnte nicht fortgesetzt werden. Es wurde nichts geändert.", + "codexAuth.pausedHint": "Bis zur Fortsetzung von automatischem Wechsel, Wiederholungen, Cooldown-Wiederherstellung und manueller Auswahl ausgeschlossen.", "codexAuth.fiveHour": "5 Std.", "codexAuth.weekly": "Woche", "codexAuth.monthly": "30d", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index edb244f485..26aa5f6534 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1060,7 +1060,20 @@ export const en = { "codexAuth.refreshingQuota": "Refreshing...", "codexAuth.quotaRefreshed": "Quotas refreshed", "codexAuth.quotaRefreshFailed": "Failed to refresh quotas", + "codexAuth.pauseExhausted": "Pause exhausted", + "codexAuth.pausingExhausted": "Checking quotas...", + "codexAuth.pauseExhaustedSucceeded": "Accounts at the limit paused: {count}", + "codexAuth.pauseExhaustedNone": "No accounts have confirmed 100% usage.", + "codexAuth.pauseExhaustedFailed": "Failed to check and pause exhausted accounts.", "codexAuth.noPool": "No pool accounts added yet.", + "codexAuth.pause": "Pause", + "codexAuth.resume": "Resume", + "codexAuth.paused": "PAUSED", + "codexAuth.pauseSucceeded": "{email} is paused", + "codexAuth.resumeSucceeded": "{email} is available to the pool again", + "codexAuth.pauseFailed": "Could not pause {email}. Nothing was changed.", + "codexAuth.resumeFailed": "Could not resume {email}. Nothing was changed.", + "codexAuth.pausedHint": "Excluded from automatic switching, retries, cooldown recovery, and manual selection until resumed.", "codexAuth.fiveHour": "5h", "codexAuth.weekly": "Week", "codexAuth.monthly": "30d", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3a6981701f..47b1de3f02 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1017,7 +1017,20 @@ export const ja: Record = { "codexAuth.refreshingQuota": "更新中...", "codexAuth.quotaRefreshed": "クォータを更新しました", "codexAuth.quotaRefreshFailed": "クォータの更新に失敗しました", + "codexAuth.pauseExhausted": "上限到達を一括停止", + "codexAuth.pausingExhausted": "クォータを確認中...", + "codexAuth.pauseExhaustedSucceeded": "上限に達したアカウントを停止しました: {count}", + "codexAuth.pauseExhaustedNone": "使用率 100% が確認されたアカウントはありません。", + "codexAuth.pauseExhaustedFailed": "上限到達アカウントの確認と停止に失敗しました。", "codexAuth.noPool": "まだプールアカウントは追加されていません。", + "codexAuth.pause": "一時停止", + "codexAuth.resume": "再開", + "codexAuth.paused": "一時停止中", + "codexAuth.pauseSucceeded": "{email} を一時停止しました", + "codexAuth.resumeSucceeded": "{email} をアカウントプールに戻しました", + "codexAuth.pauseFailed": "{email} を一時停止できませんでした。変更はありません。", + "codexAuth.resumeFailed": "{email} を再開できませんでした。変更はありません。", + "codexAuth.pausedHint": "再開するまで、自動切り替え、再試行、クールダウン復旧、手動選択の対象外です。", "codexAuth.fiveHour": "5時間", "codexAuth.weekly": "週", "codexAuth.monthly": "30日", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 5e206ab317..2372bf5001 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -663,7 +663,20 @@ export const ko: Record = { "codexAuth.refreshingQuota": "새로고침 중...", "codexAuth.quotaRefreshed": "할당량을 다시 조회했습니다", "codexAuth.quotaRefreshFailed": "할당량 재조회에 실패했습니다", + "codexAuth.pauseExhausted": "한도 도달 계정 일시 중지", + "codexAuth.pausingExhausted": "할당량 확인 중...", + "codexAuth.pauseExhaustedSucceeded": "한도에 도달해 일시 중지된 계정: {count}", + "codexAuth.pauseExhaustedNone": "사용량 100%가 확인된 계정이 없습니다.", + "codexAuth.pauseExhaustedFailed": "한도 도달 계정을 확인하고 일시 중지하지 못했습니다.", "codexAuth.noPool": "풀 계정이 아직 없습니다.", + "codexAuth.pause": "일시 중지", + "codexAuth.resume": "재개", + "codexAuth.paused": "일시 중지됨", + "codexAuth.pauseSucceeded": "{email} 계정을 일시 중지했습니다", + "codexAuth.resumeSucceeded": "{email} 계정을 풀에서 다시 사용할 수 있습니다", + "codexAuth.pauseFailed": "{email} 계정을 일시 중지하지 못했습니다. 변경 사항이 없습니다.", + "codexAuth.resumeFailed": "{email} 계정을 재개하지 못했습니다. 변경 사항이 없습니다.", + "codexAuth.pausedHint": "재개할 때까지 자동 전환, 재시도, 쿨다운 복구 및 수동 선택에서 제외됩니다.", "codexAuth.fiveHour": "5시간", "codexAuth.weekly": "주간", "codexAuth.monthly": "30일", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 97e1d68dac..e0ae4bdf4f 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1059,7 +1059,20 @@ export const ru: Record = { "codexAuth.refreshingQuota": "Обновление...", "codexAuth.quotaRefreshed": "Квоты обновлены", "codexAuth.quotaRefreshFailed": "Не удалось обновить квоты", + "codexAuth.pauseExhausted": "Приостановить исчерпанные", + "codexAuth.pausingExhausted": "Проверка квот...", + "codexAuth.pauseExhaustedSucceeded": "Приостановлено аккаунтов на лимите: {count}", + "codexAuth.pauseExhaustedNone": "Нет аккаунтов с подтверждённым использованием 100%.", + "codexAuth.pauseExhaustedFailed": "Не удалось проверить и приостановить исчерпанные аккаунты.", "codexAuth.noPool": "В пул ещё не добавлено ни одного аккаунта.", + "codexAuth.pause": "Приостановить", + "codexAuth.resume": "Возобновить", + "codexAuth.paused": "ПРИОСТАНОВЛЕН", + "codexAuth.pauseSucceeded": "Аккаунт {email} приостановлен", + "codexAuth.resumeSucceeded": "Аккаунт {email} снова доступен в пуле", + "codexAuth.pauseFailed": "Не удалось приостановить {email}. Изменений нет.", + "codexAuth.resumeFailed": "Не удалось возобновить {email}. Изменений нет.", + "codexAuth.pausedHint": "До возобновления исключён из автоматического переключения, повторов, восстановления после задержки и ручного выбора.", "codexAuth.fiveHour": "5 ч", "codexAuth.weekly": "Неделя", "codexAuth.monthly": "30 дн.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ee16b561cd..3a50a76589 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -663,7 +663,20 @@ export const zh: Record = { "codexAuth.refreshingQuota": "刷新中...", "codexAuth.quotaRefreshed": "额度已刷新", "codexAuth.quotaRefreshFailed": "额度刷新失败", + "codexAuth.pauseExhausted": "暂停已达上限账号", + "codexAuth.pausingExhausted": "正在检查额度...", + "codexAuth.pauseExhaustedSucceeded": "已暂停 {count} 个达到上限的账号", + "codexAuth.pauseExhaustedNone": "没有确认达到 100% 用量的账号。", + "codexAuth.pauseExhaustedFailed": "无法检查并暂停已达上限账号。", "codexAuth.noPool": "尚未添加池账号。", + "codexAuth.pause": "暂停", + "codexAuth.resume": "恢复", + "codexAuth.paused": "已暂停", + "codexAuth.pauseSucceeded": "已暂停 {email}", + "codexAuth.resumeSucceeded": "{email} 已重新加入账号池", + "codexAuth.pauseFailed": "无法暂停 {email},未做任何更改。", + "codexAuth.resumeFailed": "无法恢复 {email},未做任何更改。", + "codexAuth.pausedHint": "恢复前不会参与自动切换、重试、冷却恢复或手动选择。", "codexAuth.fiveHour": "5 小时", "codexAuth.weekly": "每周", "codexAuth.monthly": "30天", diff --git a/gui/src/icons.tsx b/gui/src/icons.tsx index 563dbc6a98..0e807d197b 100644 --- a/gui/src/icons.tsx +++ b/gui/src/icons.tsx @@ -21,6 +21,8 @@ export const IconCheck = (p: P) => ( (); export const IconPlus = (p: P) => (); export const IconRefresh = (p: P) => (); +export const IconPause = (p: P) => (); +export const IconPlay = (p: P) => (); export const IconTrash = (p: P) => (); export const IconAlert = (p: P) => (); export const IconInfo = (p: P) => (); diff --git a/gui/tests/active-account-reauth-aggregate.test.ts b/gui/tests/active-account-reauth-aggregate.test.ts index e606c2347c..fee1ba6f02 100644 --- a/gui/tests/active-account-reauth-aggregate.test.ts +++ b/gui/tests/active-account-reauth-aggregate.test.ts @@ -21,7 +21,8 @@ describe("Codex callback aggregate path", () => { test("shared Codex controller derives activeNeedsReauth from health as well", async () => { const hook = await Bun.file(new URL("../src/hooks/useCodexAccountPool.ts", import.meta.url)).text(); - expect(hook).toContain("accountNeedsReauth(activePoolAccount ?? mainAccount)"); + expect(hook).toContain("const activeAccount = activePoolAccount ?? mainAccount"); + expect(hook).toContain("!activeAccount?.paused && accountNeedsReauth(activeAccount)"); expect(hook).not.toMatch(/activePoolAccount\s*\?\s*Boolean\(activePoolAccount\.needsReauth\)/); }); diff --git a/gui/tests/codex-account-pool-behaviour.test.tsx b/gui/tests/codex-account-pool-behaviour.test.tsx index 84a58bb0c9..2b02a4516f 100644 --- a/gui/tests/codex-account-pool-behaviour.test.tsx +++ b/gui/tests/codex-account-pool-behaviour.test.tsx @@ -22,6 +22,10 @@ let calls: string[] = []; let originalFetch: typeof globalThis.fetch; let accounts: unknown[] = []; let threshold = 80; +let nextAccountsResponseGate: Promise | null = null; +let pauseResponseActiveId: string | null = null; +let bulkPausedAccountIds: string[] = ["a2"]; +let bulkResponseActiveId: string | null = null; beforeEach(() => { previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; @@ -36,13 +40,47 @@ beforeEach(() => { originalFetch = globalThis.fetch; calls = []; - accounts = [{ id: "a1", email: "account-one", isMain: true, hasCredential: true, quota: null }]; + nextAccountsResponseGate = null; + pauseResponseActiveId = null; + bulkPausedAccountIds = ["a2"]; + bulkResponseActiveId = null; + accounts = [{ id: "a1", email: "account-one", isMain: true, paused: false, hasCredential: true, quota: null }]; Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (url: string, init?: RequestInit) => { const path = String(url).split("/api/")[1] ?? String(url); calls.push(`${init?.method ?? "GET"} ${path}`); + if (path === "codex-auth/accounts/pause") { + const body = JSON.parse(String(init?.body)) as { id: string; paused: boolean }; + accounts = accounts.map(account => ( + typeof account === "object" && account !== null && "id" in account + && (account.id === body.id || (body.id === "__main__" && "isMain" in account && account.isMain === true)) + ? { ...account, paused: body.paused } + : account + )); + return { ok: true, json: async () => ({ activeCodexAccountId: pauseResponseActiveId }) } as unknown as Response; + } + if (path === "codex-auth/accounts/pause-exhausted") { + const pausedIds = new Set(bulkPausedAccountIds); + accounts = accounts.map(account => ( + typeof account === "object" && account !== null && "id" in account + && (pausedIds.has(String(account.id)) || (pausedIds.has("__main__") && "isMain" in account && account.isMain === true)) + ? { ...account, paused: true } + : account + )); + return { + ok: true, + json: async () => ({ + pausedAccountIds: bulkPausedAccountIds, + pausedCount: bulkPausedAccountIds.length, + activeCodexAccountId: bulkResponseActiveId, + }), + } as unknown as Response; + } if (path.startsWith("codex-auth/accounts")) { + const gate = nextAccountsResponseGate; + nextAccountsResponseGate = null; + if (gate) await gate; return { ok: true, json: async () => ({ accounts }) } as unknown as Response; } if (path.startsWith("codex-auth/active")) { @@ -105,6 +143,109 @@ test("an inert controller issues no requests at all", async () => { expect(calls.length).toBe(0); }); +test("pausing an account writes the persisted endpoint and updates shared state", async () => { + const seen = await mountController(); + + await act(async () => { + expect(await seen.current!.setAccountPaused("a1", true)).toEqual({ ok: true }); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + + expect(calls).toContain("PUT codex-auth/accounts/pause"); + expect(seen.current!.accounts[0]?.paused).toBe(true); + expect(seen.current!.activeId).toBeNull(); +}); + +test("pausing the main sentinel updates its distinct account row before reload", async () => { + const seen = await mountController(); + let releaseReload!: () => void; + nextAccountsResponseGate = new Promise(resolve => { releaseReload = resolve; }); + + await act(async () => { + expect(await seen.current!.setAccountPaused("__main__", true)).toEqual({ ok: true }); + }); + + expect(calls).toContain("PUT codex-auth/accounts/pause"); + expect(seen.current!.accounts.find(account => account.isMain)?.id).toBe("a1"); + expect(seen.current!.accounts.find(account => account.isMain)?.paused).toBe(true); + + await act(async () => { + releaseReload(); + await new Promise((resolve) => setTimeout(resolve, 30)); + }); +}); + +test("pausing stores the actual fallback account returned by the API", async () => { + accounts = [ + { id: "a1", email: "main", isMain: true, paused: false, hasCredential: true, quota: null }, + { id: "a2", email: "next", isMain: false, paused: false, hasCredential: true, quota: null }, + ]; + pauseResponseActiveId = "a2"; + const seen = await mountController(); + + await act(async () => { + expect(await seen.current!.setAccountPaused("__main__", true)).toEqual({ ok: true }); + }); + + expect(seen.current!.activeId).toBe("a2"); +}); + +test("a paused main account does not contribute active reauth state", async () => { + accounts = [ + { id: "a1", email: "main", isMain: true, paused: true, hasCredential: true, needsReauth: true, quota: null }, + { id: "a2", email: "next", isMain: false, paused: false, hasCredential: true, quota: null }, + ]; + const seen = await mountController(); + + expect(seen.current!.activeId).toBeNull(); + expect(seen.current!.activeNeedsReauth).toBe(false); +}); + +test("bulk pausing writes one endpoint and updates every returned account", async () => { + accounts = [ + { id: "a1", email: "account-one", isMain: true, paused: false, hasCredential: true, quota: null }, + { id: "a2", email: "account-two", isMain: false, paused: false, hasCredential: true, quota: null }, + ]; + const seen = await mountController(); + + await act(async () => { + expect(await seen.current!.pauseExhaustedAccounts()).toEqual({ ok: true, pausedCount: 1 }); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + + expect(calls).toContain("PUT codex-auth/accounts/pause-exhausted"); + expect(seen.current!.accounts.find(account => account.id === "a2")?.paused).toBe(true); + expect(seen.current!.pausingExhausted).toBe(false); +}); + +test("bulk pausing translates the main sentinel to its distinct account row", async () => { + accounts = [ + { id: "a1", email: "main", isMain: true, paused: false, hasCredential: true, quota: null }, + { id: "a2", email: "pool", isMain: false, paused: false, hasCredential: true, quota: null }, + ]; + bulkPausedAccountIds = ["__main__"]; + bulkResponseActiveId = "a2"; + const seen = await mountController(); + let releaseReload!: () => void; + nextAccountsResponseGate = new Promise(resolve => { releaseReload = resolve; }); + + await act(async () => { + expect(await seen.current!.pauseExhaustedAccounts()).toEqual({ ok: true, pausedCount: 1 }); + }); + + expect(seen.current!.accounts.find(account => account.isMain)?.id).toBe("a1"); + expect(seen.current!.accounts.find(account => account.isMain)?.paused).toBe(true); + expect(seen.current!.activeId).toBe("a2"); + + await act(async () => { + releaseReload(); + await new Promise((resolve) => setTimeout(resolve, 30)); + }); + + expect(seen.current!.accounts.find(account => account.isMain)?.paused).toBe(true); + expect(seen.current!.activeId).toBe("a2"); +}); + test("two pause holders both have to release before polling resumes", async () => { const seen = await mountController(); const controller = seen.current!; diff --git a/gui/tests/codex-account-pool-controller.test.ts b/gui/tests/codex-account-pool-controller.test.ts index c27637aaba..ae2f65722b 100644 --- a/gui/tests/codex-account-pool-controller.test.ts +++ b/gui/tests/codex-account-pool-controller.test.ts @@ -14,8 +14,8 @@ test("the controller is the single data owner and exposes the agreed contract", // Data layer (Q6): list / active / loading / switching plus the mutating actions. for (const member of [ - "accounts", "activeId", "loadState", "switchingId", "activeNeedsReauth", - "load", "switchAccount", "saveAlias", "removeAccount", "syncAfterAccountAdded", + "accounts", "activeId", "loadState", "switchingId", "pauseUpdatingId", "pausingExhausted", "activeNeedsReauth", + "load", "switchAccount", "setAccountPaused", "pauseExhaustedAccounts", "saveAlias", "removeAccount", "syncAfterAccountAdded", ]) { expect(hook).toContain(member); } @@ -31,6 +31,28 @@ test("the controller is the single data owner and exposes the agreed contract", expect(hook).not.toContain("load(refreshQuota?: boolean, observer"); }); +test("main and added account cards expose the same persisted pause control", async () => { + const pool = await read("../src/components/CodexAccountPool.tsx"); + const mainCard = await read("../src/components/codex-account-pool-main-card.tsx"); + const addedCards = await read("../src/components/codex-account-pool-cards.tsx"); + + expect(pool).toContain("controller.setAccountPaused(account.id, paused)"); + expect(mainCard).toContain("onTogglePause(mainSwitchEntry)"); + expect(addedCards).toContain("onTogglePause(a)"); + expect(mainCard).toContain('t(main.paused ? "codexAuth.resume" : "codexAuth.pause")'); + expect(addedCards).toContain('t(a.paused ? "codexAuth.resume" : "codexAuth.pause")'); +}); + +test("the pool header exposes one bulk action backed by the atomic endpoint", async () => { + const pool = await read("../src/components/CodexAccountPool.tsx"); + const mainCard = await read("../src/components/codex-account-pool-main-card.tsx"); + const hook = await read("../src/hooks/useCodexAccountPool.ts"); + + expect(pool).toContain("controller.pauseExhaustedAccounts()"); + expect(mainCard).toContain('t("codexAuth.pauseExhausted")'); + expect(hook).toContain("/api/codex-auth/accounts/pause-exhausted"); +}); + test("pause is a token lease, so two holders cannot cancel each other", async () => { const hook = await read("../src/hooks/useCodexAccountPool.ts"); diff --git a/gui/tests/codex-account-pool-toast-tone.test.tsx b/gui/tests/codex-account-pool-toast-tone.test.tsx index af2db2f8fb..4953d4c74e 100644 --- a/gui/tests/codex-account-pool-toast-tone.test.tsx +++ b/gui/tests/codex-account-pool-toast-tone.test.tsx @@ -35,9 +35,13 @@ function makeController(overrides: Partial = {}): Co activeId: null, loadState: "ready", switchingId: null, + pauseUpdatingId: null, + pausingExhausted: false, activeNeedsReauth: false, load: async () => true, switchAccount: async () => ({ ok: true, activeId: null }), + setAccountPaused: async () => ({ ok: true }), + pauseExhaustedAccounts: async () => ({ ok: true, pausedCount: 0 }), saveAlias: async () => ({ ok: true }), removeAccount: async () => ({ ok: false, reason: "request" }), syncAfterAccountAdded: async () => ({ ok: true }), diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index 9186ea85b2..ad658b81a8 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -6,6 +6,7 @@ import { clearAccountQuota } from "./quota"; import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing"; import { invalidateCodexWebSocketsForAccount } from "./websocket-registry"; import { clearMainAccountInfoCache } from "./main-account-cache"; +import { forgetCodexAccountPause } from "./account-pause"; import type { OcxConfig } from "../types"; let observedMainChatgptAccountId: string | undefined; @@ -46,6 +47,7 @@ export function resetMainCodexAccountIdentityTrackingForTests(): void { export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): void { removeCodexAccountCredential(accountId); runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []).filter(account => account.id !== accountId); + forgetCodexAccountPause(runtimeConfig, accountId); if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined; purgeCodexAccountRuntimeState(accountId); invalidateCodexWebSocketsForAccount(accountId); diff --git a/src/codex/account-pause.ts b/src/codex/account-pause.ts new file mode 100644 index 0000000000..8e73a1bcfd --- /dev/null +++ b/src/codex/account-pause.ts @@ -0,0 +1,20 @@ +import type { OcxConfig } from "../types"; + +/** Whether an account is administratively excluded from future pool selection. */ +export function isCodexAccountPaused(config: OcxConfig, accountId: string): boolean { + return config.pausedCodexAccountIds?.includes(accountId) ?? false; +} + +/** Persist the account's pool eligibility without changing credentials or runtime health. */ +export function setCodexAccountPaused(config: OcxConfig, accountId: string, paused: boolean): void { + const pausedIds = new Set(config.pausedCodexAccountIds ?? []); + if (paused) pausedIds.add(accountId); + else pausedIds.delete(accountId); + + if (pausedIds.size > 0) config.pausedCodexAccountIds = [...pausedIds]; + else delete config.pausedCodexAccountIds; +} + +export function forgetCodexAccountPause(config: OcxConfig, accountId: string): void { + setCodexAccountPaused(config, accountId, false); +} diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 73da7ec1f4..a042f08318 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -3,6 +3,7 @@ import { withCodexAccountLogLabel } from "./account-label"; import { getCodexAccountCredential, getValidCodexToken, + isCodexAccountGenerationLive, markCodexAccountValidated, saveCodexAccountCredential, CodexCredentialGenerationConflictError, @@ -10,13 +11,20 @@ import { TokenRefreshError, } from "./account-store"; import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; +import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause"; +import { + clearCodexAccountCooldown, + clearThreadAccountMapForAccount, + getEffectiveActiveCodexAccountId, + reconcileCodexActiveAfterExclusion, + resetCodexRoutingForManualSelection, +} from "./routing"; import { normalizeAccountPoolStickyLimit, normalizeAccountPoolStrategy, parseAccountPoolStickyLimit, parseAccountPoolStrategy, } from "./pool-rotation"; -import { clearCodexAccountCooldown, getEffectiveActiveCodexAccountId, resetCodexRoutingForManualSelection } from "./routing"; import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision"; export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision"; export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; @@ -24,6 +32,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } import { clearAccountQuota, getAccountQuota, + isCodexQuotaExhausted, listAccountQuotas, parseUsageQuota, setAccountQuotaFromParsed, @@ -40,7 +49,7 @@ export { updateAccountQuota, } from "./quota"; import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt"; -import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; +import { getMainAccountPlan, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; import { clearMainAccountInfoCache, getMainAccountInfoCache, @@ -101,6 +110,7 @@ function poolAccountDto( account: CodexAccount, quotaResult: PoolQuotaResult, hasCredential: boolean, + paused: boolean, ): CodexAuthAccountDto { const quota = quotaForPlan(quotaResult.quota, account.plan); const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id); @@ -112,6 +122,7 @@ function poolAccountDto( ...(account.plan !== undefined ? { plan: account.plan } : {}), ...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}), isMain: false, + paused, quota: quota ? { ...quota } : null, needsReauth, hasCredential, @@ -214,6 +225,10 @@ const MAIN_CACHE_TTL = 5 * 60_000; const POOL_CACHE_TTL = 5 * 60_000; const POOL_QUOTA_REFRESH_CONCURRENCY = 4; +function nonEmptyPlan(value: unknown): string | null { + return typeof value === "string" && value.trim() !== "" ? value : null; +} + function isRuntimeConfig(config: OcxConfig): boolean { return !!config && typeof config === "object" && !!config.providers; } @@ -277,6 +292,8 @@ async function isTerminalMainAuthResponse(resp: Response): Promise { interface MainAccountInfoFetchResult { info: MainAccountInfo; + /** Present only when this call freshly parsed a WHAM usage response. */ + freshQuota?: Omit; /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */ freshResetCredits?: number; } @@ -337,11 +354,12 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini const data = (await resp.json()) as WhamUsageResponse; const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining); if (retried) return retried; - const quota = parseUsageQuota(data); + const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan()); + const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) }); const freshResetCredits = quota?.resetCredits; const result = { email: data.email ?? null, - plan: data.plan_type ?? null, + plan, quota, ts: Date.now(), }; @@ -355,6 +373,7 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini } return { info: result, + ...(quota ? { freshQuota: quota } : {}), ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), }; } catch { @@ -366,6 +385,12 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini interface PoolQuotaResult { quota: StoredAccountQuota | null; needsReauth: boolean; + /** Present only when this call freshly parsed a WHAM usage response. */ + freshQuota?: Omit; + /** Present only when this call's WHAM response included a non-empty `plan_type`. */ + freshPlan?: string; + /** Credential generation used by this fresh quota request. */ + freshCredentialGeneration?: number; /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */ freshResetCredits?: number; } @@ -377,6 +402,7 @@ export interface CodexAuthAccountDto { plan?: string | null; logLabel?: string; isMain: boolean; + paused: boolean; quota: (StoredAccountQuota | (Omit & { updatedAt: number })) | null; needsReauth?: boolean; hasCredential: boolean; @@ -392,20 +418,27 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co return { quota: existing, needsReauth: false }; } try { - const { accessToken, chatgptAccountId } = await getValidCodexToken(accountId); + const { accessToken, chatgptAccountId, generation } = await getValidCodexToken(accountId); const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId }, signal: AbortSignal.timeout(8000), }); if (!resp.ok) return { quota: existing ?? null, needsReauth: resp.status === 401 }; const data = (await resp.json()) as WhamUsageResponse; - const quota = parseUsageQuota({ ...data, plan_type: data.plan_type ?? configuredPlan }); + const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined; + const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan }); const freshResetCredits = quota?.resetCredits; if (!quota) return { quota: existing ?? null, needsReauth: false }; + if (!isCodexAccountGenerationLive(accountId, generation)) { + return { quota: getAccountQuota(accountId), needsReauth: false }; + } setAccountQuotaFromParsed(accountId, quota); return { quota: getAccountQuota(accountId), needsReauth: false, + freshQuota: quota, + freshCredentialGeneration: generation, + ...(freshPlan !== undefined ? { freshPlan } : {}), ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), }; } catch (e) { @@ -486,7 +519,7 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa const quotaResult = cred ? await fetchPoolAccountQuota(a.id, forceRefresh, a.plan) : { quota: null, needsReauth: true }; - return poolAccountDto(a, quotaResult, !!cred); + return poolAccountDto(a, quotaResult, !!cred, isCodexAccountPaused(runtimeConfig, a.id)); }); const hasMainCredential = readCodexTokens() !== null; const mainNeedsReauth = !hasMainCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); @@ -499,6 +532,7 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa email: maskEmail(mainInfo.email) ?? "Codex App login", plan: mainInfo.plan, isMain: true, + paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), hasCredential: hasMainCredential, needsReauth: mainNeedsReauth, quota: mainInfo.quota ? { ...quotaForPlan({ ...mainInfo.quota, updatedAt: Date.now() }, mainInfo.plan) } : null, @@ -507,6 +541,69 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa return [main, ...withQuota]; } +interface PauseExhaustedResult { + pausedAccountIds: string[]; + checkedAccountCount: number; + failedAccountCount: number; +} + +function selectFallbackAfterPause(config: OcxConfig, pausedActiveId: string): void { + reconcileCodexActiveAfterExclusion(config, pausedActiveId); +} + +async function pauseExhaustedCodexAccounts(config: OcxConfig): Promise { + const poolAccounts = (config.codexAccounts ?? []).filter(account => !account.isMain); + const mainAttempted = readCodexTokens() !== null; + const [mainResult, poolResults] = await Promise.all([ + fetchMainAccountInfoAttempt(true, 1), + mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => { + if (!getCodexAccountCredential(account.id)) return { account, quotaResult: null }; + return { + account, + quotaResult: await fetchPoolAccountQuota(account.id, true, account.plan), + }; + }), + ]); + + let checkedAccountCount = 0; + let failedAccountCount = 0; + const exhaustedIds: string[] = []; + if (mainAttempted) { + if (mainResult.freshQuota && mainResult.info.plan) { + checkedAccountCount += 1; + if ( + !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) + && isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan) + ) { + exhaustedIds.push(MAIN_CODEX_ACCOUNT_ID); + } + } else { + failedAccountCount += 1; + } + } + for (const { account, quotaResult } of poolResults) { + const currentAccount = (config.codexAccounts ?? []).find(candidate => candidate.id === account.id && !candidate.isMain); + if (!currentAccount) continue; + const generation = quotaResult?.freshCredentialGeneration; + const plan = quotaResult?.freshPlan ?? currentAccount.plan; + if (!quotaResult?.freshQuota || generation === undefined || !isCodexAccountGenerationLive(account.id, generation) || !plan) { + failedAccountCount += 1; + continue; + } + checkedAccountCount += 1; + if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) { + exhaustedIds.push(account.id); + } + } + + for (const id of exhaustedIds) { + setCodexAccountPaused(config, id, true); + clearThreadAccountMapForAccount(id); + } + for (const id of exhaustedIds) selectFallbackAfterPause(config, id); + return { pausedAccountIds: exhaustedIds, checkedAccountCount, failedAccountCount }; +} + export async function handleCodexAuthAPI( req: Request, url: URL, @@ -589,6 +686,57 @@ export async function handleCodexAuthAPI( return jsonResponse({ ok: true, id, alias: alias || null }); } + if (url.pathname === "/api/codex-auth/accounts/pause" && req.method === "PUT") { + const body = await req.json().catch(() => ({})) as { id?: unknown; paused?: unknown }; + const id = typeof body.id === "string" ? body.id.trim() : ""; + if (!id || !ACCOUNT_ID_RE.test(id)) return jsonResponse({ error: "Invalid account id format" }, 400); + if (typeof body.paused !== "boolean") return jsonResponse({ error: "paused must be a boolean" }, 400); + + const runtimeConfig = getRuntimeConfig(config); + const exists = id === MAIN_CODEX_ACCOUNT_ID + || (runtimeConfig.codexAccounts ?? []).some(account => account.id === id && !account.isMain); + if (!exists) return jsonResponse({ error: "Account not found" }, 404); + + setCodexAccountPaused(runtimeConfig, id, body.paused); + if (body.paused) { + clearThreadAccountMapForAccount(id); + selectFallbackAfterPause(runtimeConfig, id); + } + saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ + ok: true, + id, + paused: body.paused, + activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, + appliesImmediately: true, + }); + } + + if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") { + const runtimeConfig = getRuntimeConfig(config); + const result = await pauseExhaustedCodexAccounts(runtimeConfig); + const { pausedAccountIds, checkedAccountCount, failedAccountCount } = result; + if (checkedAccountCount === 0 && failedAccountCount > 0) { + return jsonResponse({ + ok: false, + error: "Failed to refresh any Codex account quota", + checkedAccountCount, + failedAccountCount, + }, 502); + } + if (pausedAccountIds.length > 0) saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ + ok: true, + pausedAccountIds, + pausedCount: pausedAccountIds.length, + checkedAccountCount, + failedAccountCount, + complete: failedAccountCount === 0, + activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, + appliesImmediately: true, + }); + } + // Manual escape from a quota cooldown. Injected Codex routing makes this proxy the only // model path for Codex Desktop, so a cooldown that outlives the real upstream limit // otherwise leaves editing config.toml as the user's only recovery. @@ -608,12 +756,16 @@ export async function handleCodexAuthAPI( let body: { accountId: string | null }; try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } const runtimeConfig = getRuntimeConfig(config); - if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) { - const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === body.accountId); + const targetAccountId = body.accountId ?? MAIN_CODEX_ACCOUNT_ID; + if (isCodexAccountPaused(runtimeConfig, targetAccountId)) { + return jsonResponse({ error: "Account is paused" }, 409); + } + if (targetAccountId !== MAIN_CODEX_ACCOUNT_ID) { + const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === targetAccountId); if (!exists) return jsonResponse({ error: "Account not found" }, 400); } runtimeConfig.activeCodexAccountId = body.accountId ?? undefined; - resetCodexRoutingForManualSelection(body.accountId ?? MAIN_CODEX_ACCOUNT_ID); + resetCodexRoutingForManualSelection(targetAccountId); saveRuntimeConfig(config, runtimeConfig); return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true }); } diff --git a/src/codex/quota.ts b/src/codex/quota.ts index f500b3641d..5fc147116c 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -33,6 +33,21 @@ const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60; const accountQuota = new Map(); export const CODEX_UNKNOWN_USAGE_SCORE = 100; +export const CODEX_EXHAUSTED_USAGE_PERCENT = 100; + +export function isCodexQuotaExhausted( + quota: Pick | null, + plan?: string | null, +): boolean { + if (!quota) return false; + const normalizedPlan = plan?.trim().toLowerCase(); + const values = normalizedPlan === "go" || normalizedPlan === "free" + ? [quota.monthlyPercent] + : [quota.weeklyPercent, quota.monthlyPercent]; + return values.some(value => typeof value === "number" + && Number.isFinite(value) + && value >= CODEX_EXHAUSTED_USAGE_PERCENT); +} export function normalizeUsagePercent(value: unknown): number | undefined { const numeric = typeof value === "number" diff --git a/src/codex/routing.ts b/src/codex/routing.ts index f127b12def..46fa6dec23 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { saveConfigPreservingClaudeCode } from "../config"; import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store"; import { codexAccountLogLabel } from "./account-label"; +import { isCodexAccountPaused } from "./account-pause"; import { isCodexAccountUsable } from "./account-usability"; import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; import { @@ -422,7 +423,8 @@ export function isCodexAccountSoftAvoided(accountId: string, now = Date.now()): } function isCodexAccountSelectable(config: OcxConfig, accountId: string, now: number): boolean { - return !isCodexAccountInCooldown(accountId, now) + return !isCodexAccountPaused(config, accountId) + && !isCodexAccountInCooldown(accountId, now) && !isCodexAccountSoftAvoided(accountId, now) && isCodexAccountUsable(config, accountId); } @@ -474,7 +476,7 @@ function bindThreadAffinity(threadId: string, accountId: string, now: number): v function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Date.now()): string[] { const ids = (config.codexAccounts ?? []) - .filter(account => !account.isMain && account.id !== excludeId && !isAccountNeedsReauth(account.id)) + .filter(account => !account.isMain && account.id !== excludeId && !isCodexAccountPaused(config, account.id) && !isAccountNeedsReauth(account.id)) .filter(account => !isCodexAccountInCooldown(account.id, now)) .filter(account => !isCodexAccountSoftAvoided(account.id, now)) .filter(account => isCodexAccountUsable(config, account.id)) @@ -483,6 +485,7 @@ function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Da // first-class rotation candidate when its read-only token is usable (Option A). if ( excludeId !== MAIN_CODEX_ACCOUNT_ID + && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) && !isCodexAccountInCooldown(MAIN_CODEX_ACCOUNT_ID, now) && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now) @@ -704,6 +707,29 @@ function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void { rememberActiveCodexAccount(config, accountId); } +/** + * Reconcile the effective active account after an administrative exclusion such as pause. + * The operator's persisted selection is cleared when it names the excluded account; quota + * keeps its historical persisted promotion, while rotating strategies retain the replacement + * only in the process-local cursor. + */ +export function reconcileCodexActiveAfterExclusion( + config: OcxConfig, + excludedAccountId: string, + now = Date.now(), +): string | null { + const wasEffective = (getEffectiveActiveCodexAccountId(config) ?? MAIN_CODEX_ACCOUNT_ID) === excludedAccountId; + if (config.activeCodexAccountId === excludedAccountId) { + config.activeCodexAccountId = undefined; + } + if (!wasEffective) return getEffectiveActiveCodexAccountId(config) ?? null; + + runtimeActiveCodexAccountId = undefined; + const fallback = pickAlternateCodexAccount(config, excludedAccountId, now); + if (fallback) promoteActiveCodexAccount(config, fallback); + return fallback; +} + function isUnknownUsage(usage: number): boolean { return usage >= CODEX_UNKNOWN_USAGE_SCORE; } @@ -805,7 +831,7 @@ export function previewCodexAccountForRequest( if (!isCodexAccountSelectable(config, active, now)) { const fallback = pickLowestUsageCodexAccount(config, active, now); if (fallback) active = fallback; - else if (hasConfiguredPoolAccount(config, active)) return active; + else if (hasConfiguredPoolAccount(config, active) && !isCodexAccountPaused(config, active)) return active; else return null; } @@ -823,6 +849,7 @@ export function previewCodexAccountForRequest( if (!isCodexAccountUsable(config, active)) { return hasConfiguredPoolAccount(config, active) ? active : null; } + if (isCodexAccountPaused(config, active)) return null; if (isCodexAccountInCooldown(active, now)) { return hasConfiguredPoolAccount(config, active) ? active : null; } @@ -898,7 +925,7 @@ export function resolveCodexAccountForThreadDetailed( if (fallback) { setActiveCodexAccount(config, fallback); active = fallback; - } else if (hasConfiguredPoolAccount(config, active)) { + } else if (hasConfiguredPoolAccount(config, active) && !isCodexAccountPaused(config, active)) { return { status: "selected", accountId: active }; } else { return { status: "none" }; @@ -909,6 +936,7 @@ export function resolveCodexAccountForThreadDetailed( if (!isCodexAccountUsable(config, active)) { return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" }; } + if (isCodexAccountPaused(config, active)) return { status: "none" }; if (isCodexAccountInCooldown(active, now)) { return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" }; } diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index ec07d51db3..6e936ec222 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -20,6 +20,7 @@ import { isCodexAccountInCooldown, } from "./routing"; import { isCodexAccountUsable } from "./account-usability"; +import { isCodexAccountPaused } from "./account-pause"; import { slugEquals } from "../providers/slug-codec"; import { isThreadSpawnRequest } from "../server/effort-policy"; import { PROVIDER_REGISTRY } from "../providers/registry"; @@ -187,6 +188,7 @@ export function isSubagentModelUnavailable( // route (canonical openai defaults to pool even when codexAccountMode is omitted). const resolvedAccountId = resolvePoolFallbackAccountId(config, accountId); if (!resolvedAccountId) return true; + if (isCodexAccountPaused(config, resolvedAccountId)) return true; if (!isCodexAccountUsable(config, resolvedAccountId)) return true; if ( isCodexAccountInCooldown(resolvedAccountId, now) diff --git a/src/config.ts b/src/config.ts index 1168e0e4e4..bd6841db2e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -541,6 +541,7 @@ const configSchema = z.object({ injectionEffort: z.string().optional().catch(undefined), syncCodexSubagentDefaults: z.boolean().optional().catch(undefined), codexShimAutoRestore: z.boolean().optional(), + pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), // Invalid values degrade to undefined ("auto") instead of failing the whole diff --git a/src/types.ts b/src/types.ts index 78d2e706f9..1d8aa2dfb1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -678,6 +678,8 @@ export interface OcxConfig { search?: OcxSearchConfig; /** Codex multi-account pool. */ codexAccounts?: CodexAccount[]; + /** Account ids administratively excluded from future pool selection until resumed. */ + pausedCodexAccountIds?: string[]; /** Active pool account id for next session. undefined = main (passthrough as-is). */ activeCodexAccountId?: string; /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index a0da134877..1a3ce47641 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -18,6 +18,13 @@ engine. Direct short-circuits that engine before pool state is read or mutated a current caller/main-login bearer. Neither mode may fall through to `openai-apikey`, and the API provider may not fall through to Codex-login credentials. +`pausedCodexAccountIds` is a persisted Pool eligibility boundary. A paused added account or the +stable `__main__` alias remains visible for maintenance and quota reads, but is excluded from new +affinity, quota rotation, cooldown probes, transient failover, and manual activation. In-flight +requests keep their captured credential. An all-paused pool fails closed. +The dashboard's bulk pause action refreshes all account quotas and mutates only accounts whose +plan-relevant window is freshly confirmed at exactly 100%; unknown and failed refreshes are skipped. + ```text gpt-5.6-sol # openai; Pool or Direct follows the provider option openai-apikey/gpt-5.6-sol # OpenAI API key diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 585d6eb6cc..30eb2f317d 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -7,14 +7,18 @@ import { handleCodexAuthAPI, updateAccountQuota, getAccountQuota, checkAccountIdCollision, getMainChatgptAccountId, markAccountNeedsReauth, isAccountNeedsReauth, clearAccountNeedsReauth, clearAccountQuota, - maskEmail, + clearMainAccountInfoCache, maskEmail, } from "../src/codex/auth-api"; import { getCodexAccountCredential, readCodexAccountRecord, saveCodexAccountCredential } from "../src/codex/account-store"; import { + clearCodexUpstreamHealth, + clearThreadAccountMap, getCodexUpstreamHealth, recordCodexUpstreamOutcome, + resetCodexRoutingForManualSelection, resolveCodexAccountForThread, } from "../src/codex/routing"; +import { clearPoolRotationState } from "../src/codex/pool-rotation"; import { clearCodexWebSocketRegistry, getTrackedCodexWebSocketCountForAccount, @@ -22,7 +26,7 @@ import { } from "../src/codex/websocket-registry"; import type { OcxConfig } from "../src/types"; import type { WsData } from "../src/server/ws-bridge"; -import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; +import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../src/codex/main-account"; import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests, @@ -117,6 +121,11 @@ beforeEach(() => { clearAccountNeedsReauth("__main__"); clearAccountQuota(); clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearMainAccountInfoCache(); + setMainAccountPlan(null); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); clearCodexWebSocketRegistry(); resetMainCodexAccountIdentityTrackingForTests(); }); @@ -125,6 +134,11 @@ afterEach(() => { clearAccountNeedsReauth("__main__"); clearAccountQuota(); clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearMainAccountInfoCache(); + setMainAccountPlan(null); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); clearCodexWebSocketRegistry(); globalThis.fetch = previousFetch; if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; @@ -1109,6 +1123,367 @@ describe("codex-auth API", () => { expect(config.activeCodexAccountId).toBe("pool-next"); }); + test("PUT /api/codex-auth/accounts/pause persists exclusion and applies to the next request", async () => { + const config = makeConfig({ + codexAccounts: [ + { id: "pool-active", email: "active@example.test", isMain: false }, + { id: "pool-next", email: "next@example.test", isMain: false }, + ], + activeCodexAccountId: "pool-active", + }); + seedPoolAccount(config, { id: "pool-extra", email: "extra@example.test" }); + saveCodexAccountCredential("pool-active", { + accessToken: "access-active", + refreshToken: "refresh-active", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-active", + }); + saveCodexAccountCredential("pool-next", { + accessToken: "access-next", + refreshToken: "refresh-next", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-next", + }); + updateAccountQuota("pool-active", 5); + updateAccountQuota("pool-next", 10); + updateAccountQuota("pool-extra", 20); + expect(resolveCodexAccountForThread("pause-thread", config)).toBe("pool-active"); + + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pool-active", paused: true }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ id: "pool-active", paused: true, activeCodexAccountId: "pool-next" }); + expect(config.pausedCodexAccountIds).toEqual(["pool-active"]); + expect(config.activeCodexAccountId).toBe("pool-next"); + expect(resolveCodexAccountForThread("pause-thread", config)).toBe("pool-next"); + }); + + test("pausing the runtime-active round-robin account promotes an eligible replacement", async () => { + const config = makeConfig({ + codexAccounts: [ + { id: "pool-active", email: "active@example.test", isMain: false }, + { id: "pool-next", email: "next@example.test", isMain: false }, + ], + activeCodexAccountId: "pool-active", + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 2, + }); + for (const id of ["pool-active", "pool-next"]) { + saveCodexAccountCredential(id, { + accessToken: `access-${id}`, + refreshToken: `refresh-${id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `acct-${id}`, + }); + } + resetCodexRoutingForManualSelection("pool-active"); + expect(resolveCodexAccountForThread("round-robin-pause", config)).toBe("pool-active"); + + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pool-active", paused: true }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ + id: "pool-active", + paused: true, + activeCodexAccountId: "pool-next", + }); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(resolveCodexAccountForThread("round-robin-pause", config)).toBe("pool-next"); + }); + + test("pausing a persisted non-active round-robin selection preserves the runtime account", async () => { + const config = makeConfig({ + codexAccounts: [ + { id: "pool-persisted", email: "persisted@example.test", isMain: false }, + { id: "pool-runtime", email: "runtime@example.test", isMain: false }, + ], + activeCodexAccountId: "pool-persisted", + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 2, + }); + for (const id of ["pool-persisted", "pool-runtime"]) { + saveCodexAccountCredential(id, { + accessToken: `access-${id}`, + refreshToken: `refresh-${id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `acct-${id}`, + }); + } + resetCodexRoutingForManualSelection("pool-runtime"); + expect(resolveCodexAccountForThread("runtime-selection", config)).toBe("pool-runtime"); + + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pool-persisted", paused: true }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ + id: "pool-persisted", + paused: true, + activeCodexAccountId: "pool-runtime", + }); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(resolveCodexAccountForThread("runtime-selection", config)).toBe("pool-runtime"); + }); + + test("PUT /api/codex-auth/accounts/pause-exhausted pauses only freshly confirmed exhausted accounts", async () => { + const config = makeConfig({ + codexAccounts: [ + { id: "exhausted", email: "exhausted@example.test", plan: "plus", isMain: false }, + { id: "available", email: "available@example.test", plan: "plus", isMain: false }, + { id: "free-weekly-only", email: "free@example.test", plan: "free", isMain: false }, + { id: "upgraded-plus", email: "upgraded@example.test", plan: "free", isMain: false }, + { id: "stale-unknown", email: "stale@example.test", plan: "plus", isMain: false }, + { id: "already-paused", email: "paused@example.test", plan: "plus", isMain: false }, + ], + activeCodexAccountId: "exhausted", + pausedCodexAccountIds: ["already-paused"], + }); + for (const account of config.codexAccounts ?? []) { + saveCodexAccountCredential(account.id, { + accessToken: `access-${account.id}`, + refreshToken: `refresh-${account.id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: account.id, + }); + } + updateAccountQuota("stale-unknown", 100); + expect(resolveCodexAccountForThread("bulk-pause-thread", config)).toBe("exhausted"); + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const accountId = new Headers(init?.headers).get("ChatGPT-Account-Id"); + if (accountId === "stale-unknown") return new Response(null, { status: 502 }); + const weekly = accountId === "exhausted" || accountId === "already-paused" || accountId === "free-weekly-only" || accountId === "upgraded-plus" + ? 100 + : 72; + const monthly = accountId === "free-weekly-only" || accountId === "upgraded-plus" ? 20 : 40; + return Response.json({ + plan_type: accountId === "free-weekly-only" ? "free" : "plus", + rate_limit: { + primary_window: { used_percent: weekly, limit_window_seconds: 7 * 24 * 60 * 60 }, + tertiary_window: { used_percent: monthly, limit_window_seconds: 30 * 24 * 60 * 60 }, + }, + }); + }) as typeof fetch; + + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ + pausedAccountIds: ["exhausted", "upgraded-plus"], + pausedCount: 2, + activeCodexAccountId: "free-weekly-only", + checkedAccountCount: 5, + failedAccountCount: 1, + complete: false, + appliesImmediately: true, + }); + expect(config.pausedCodexAccountIds).toEqual(["already-paused", "exhausted", "upgraded-plus"]); + expect(config.activeCodexAccountId).toBe("free-weekly-only"); + expect(resolveCodexAccountForThread("bulk-pause-thread", config)).toBe("free-weekly-only"); + }); + + test("bulk pause preserves a known Free main plan when WHAM omits plan_type", async () => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", account_id: "main-account" }, + })); + let call = 0; + globalThis.fetch = (async () => { + call += 1; + return Response.json({ + email: "main@example.test", + ...(call === 1 ? { plan_type: "free" } : {}), + rate_limit: { + primary_window: { used_percent: call === 1 ? 10 : 100, limit_window_seconds: 7 * 24 * 60 * 60 }, + tertiary_window: { used_percent: 20, limit_window_seconds: 30 * 24 * 60 * 60 }, + }, + }); + }) as typeof fetch; + const config = makeConfig(); + + const prime = new Request("http://localhost/api/codex-auth/accounts?refresh=1"); + expect((await handleCodexAuthAPI(prime, new URL(prime.url), config))!.status).toBe(200); + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ + pausedAccountIds: [], + pausedCount: 0, + checkedAccountCount: 1, + failedAccountCount: 0, + }); + expect(config.pausedCodexAccountIds).toBeUndefined(); + }); + + test("bulk pause fails closed when the main quota plan cannot be established", async () => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", account_id: "main-account" }, + })); + globalThis.fetch = (async () => Response.json({ + email: "main@example.test", + rate_limit: { + primary_window: { used_percent: 100, limit_window_seconds: 7 * 24 * 60 * 60 }, + tertiary_window: { used_percent: 20, limit_window_seconds: 30 * 24 * 60 * 60 }, + }, + })) as typeof fetch; + const config = makeConfig(); + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(502); + expect(await resp!.json()).toMatchObject({ ok: false, checkedAccountCount: 0, failedAccountCount: 1 }); + expect(config.pausedCodexAccountIds).toBeUndefined(); + }); + + test("bulk pause reports an error when every quota refresh fails", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "offline", email: "offline@example.test", plan: "plus", isMain: false }], + }); + saveCodexAccountCredential("offline", { + accessToken: "offline-access", + refreshToken: "offline-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "offline", + }); + globalThis.fetch = (async () => new Response(null, { status: 502 })) as typeof fetch; + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(502); + expect(await resp!.json()).toMatchObject({ ok: false, checkedAccountCount: 0, failedAccountCount: 1 }); + expect(config.pausedCodexAccountIds).toBeUndefined(); + }); + + test("bulk pause discards an exhausted result after the account is deleted and recreated", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "reused", email: "old@example.test", plan: "plus", isMain: false }], + }); + saveCodexAccountCredential("reused", { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "old-account", + }); + let releaseFetch!: () => void; + let markFetchStarted!: () => void; + const fetchStarted = new Promise(resolve => { markFetchStarted = resolve; }); + const fetchGate = new Promise(resolve => { releaseFetch = resolve; }); + globalThis.fetch = (async () => { + markFetchStarted(); + await fetchGate; + return Response.json({ + plan_type: "plus", + rate_limit: { primary_window: { used_percent: 100 } }, + }); + }) as typeof fetch; + + const bulkReq = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const bulkPromise = handleCodexAuthAPI(bulkReq, new URL(bulkReq.url), config); + await fetchStarted; + const deleteReq = new Request("http://localhost/api/codex-auth/accounts?id=reused", { method: "DELETE" }); + expect((await handleCodexAuthAPI(deleteReq, new URL(deleteReq.url), config))!.status).toBe(200); + config.codexAccounts = [{ id: "reused", email: "new@example.test", plan: "plus", isMain: false }]; + saveCodexAccountCredential("reused", { + accessToken: "new-access", + refreshToken: "new-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "new-account", + }); + releaseFetch(); + const resp = await bulkPromise; + + expect(resp!.status).toBe(502); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(getAccountQuota("reused")).toBeNull(); + }); + + test("PUT /api/codex-auth/accounts/pause-exhausted includes a freshly exhausted main account", async () => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", account_id: "main-account" }, + })); + globalThis.fetch = (async () => Response.json({ + email: "main@example.test", + plan_type: "plus", + rate_limit: { primary_window: { used_percent: 100 } }, + })) as typeof fetch; + const config = makeConfig(); + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(await resp!.json()).toMatchObject({ + pausedAccountIds: [MAIN_CODEX_ACCOUNT_ID], + pausedCount: 1, + }); + expect(config.pausedCodexAccountIds).toEqual([MAIN_CODEX_ACCOUNT_ID]); + }); + + test("PUT /api/codex-auth/active rejects null when the effective main account is paused", async () => { + const config = makeConfig({ pausedCodexAccountIds: [MAIN_CODEX_ACCOUNT_ID] }); + const req = new Request("http://localhost/api/codex-auth/active", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId: null }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(409); + expect(await resp!.json()).toEqual({ error: "Account is paused" }); + expect(config.activeCodexAccountId).toBeUndefined(); + }); + + test("resuming restores eligibility and manual activation rejects paused accounts", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "work", email: "work@example.test", isMain: false }], + pausedCodexAccountIds: ["work", MAIN_CODEX_ACCOUNT_ID], + }); + const activateReq = new Request("http://localhost/api/codex-auth/active", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId: "work" }), + }); + const activateResp = await handleCodexAuthAPI(activateReq, new URL(activateReq.url), config); + expect(activateResp!.status).toBe(409); + expect(config.activeCodexAccountId).toBeUndefined(); + + const resumeReq = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "work", paused: false }), + }); + const resumeResp = await handleCodexAuthAPI(resumeReq, new URL(resumeReq.url), config); + + expect(resumeResp!.status).toBe(200); + expect(config.pausedCodexAccountIds).toEqual([MAIN_CODEX_ACCOUNT_ID]); + }); + + test("account list exposes persisted pause state for main and added accounts", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "work", email: "work@example.test", isMain: false }], + pausedCodexAccountIds: [MAIN_CODEX_ACCOUNT_ID, "work"], + }); + const req = new Request("http://localhost/api/codex-auth/accounts"); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + const data = await resp!.json() as { accounts: Array<{ id: string; paused: boolean }> }; + + expect(data.accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)?.paused).toBe(true); + expect(data.accounts.find(account => account.id === "work")?.paused).toBe(true); + }); + test("PUT /api/codex-auth/accounts/alias changes display metadata only", async () => { const config = makeConfig({ codexAccounts: [{ id: "work", email: "work@example.test", plan: "plus", isMain: false }], @@ -1154,6 +1529,7 @@ describe("codex-auth API", () => { recordCodexUpstreamOutcome(config, "pool-delete", 500); expect(getCodexUpstreamHealth("pool-delete")).not.toBeNull(); markAccountNeedsReauth("pool-delete"); + config.pausedCodexAccountIds = ["pool-delete"]; const closed: { code?: number; reason?: string }[] = []; let cancelled = false; const ws = { @@ -1182,6 +1558,7 @@ describe("codex-auth API", () => { expect(resp!.status).toBe(200); expect(config.codexAccounts).toEqual([]); expect(config.activeCodexAccountId).toBeUndefined(); + expect(config.pausedCodexAccountIds).toBeUndefined(); expect(getCodexAccountCredential("pool-delete")).toBeNull(); expect(getAccountQuota("pool-delete")).toBeNull(); expect(isAccountNeedsReauth("pool-delete")).toBe(false); diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index cd7e7d9bc5..065c2b8547 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -167,6 +167,37 @@ describe("Codex auth context", () => { )).rejects.toBeInstanceOf(CodexPoolAuthenticationError); }); + test("pause excludes new auth selection without invalidating an in-flight context", async () => { + const cfg = config(); + cfg.codexAccounts?.push({ + id: "pool-b", + email: "pool-b@example.test", + isMain: false, + chatgptAccountId: "pool_b_acc", + }); + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", + refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_a_acc", + }); + saveCodexAccountCredential("pool-b", { + accessToken: "pool_b_token", + refreshToken: "pool_b_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_b_acc", + }); + + const captured = await resolveCodexAuthContext(new Headers(), cfg, "pool"); + expect(captured).toMatchObject({ kind: "pool", accountId: "pool-a" }); + + cfg.pausedCodexAccountIds = ["pool-a"]; + + expect(isCodexAuthContextUsable(captured, cfg)).toBe(true); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + }); + test("selected pool headers replace inbound main auth", () => { const headers = headersForCodexAuthContext( new Headers({ authorization: "Bearer main_token", "chatgpt-account-id": "main_acc", "openai-beta": "responses=experimental" }), diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index ce1e14cbb4..09d952818e 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -37,7 +37,7 @@ import { parseUsageQuota, updateAccountQuota, } from "../src/codex/auth-api"; -import { CODEX_UNKNOWN_USAGE_SCORE } from "../src/codex/quota"; +import { CODEX_UNKNOWN_USAGE_SCORE, isCodexQuotaExhausted } from "../src/codex/quota"; import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; import { routeModel } from "../src/router"; import { consumeForInspection } from "../src/server/relay"; @@ -127,6 +127,16 @@ describe("codex routing", () => { expect(computeCodexUsageScore({})).toBe(CODEX_UNKNOWN_USAGE_SCORE); }); + test("bulk pause exhaustion requires an explicit 100% relevant window", () => { + expect(isCodexQuotaExhausted(null, "plus")).toBe(false); + expect(isCodexQuotaExhausted({}, "plus")).toBe(false); + expect(isCodexQuotaExhausted({ weeklyPercent: 99.9 }, "plus")).toBe(false); + expect(isCodexQuotaExhausted({ weeklyPercent: 100 }, "plus")).toBe(true); + expect(isCodexQuotaExhausted({ monthlyPercent: 100 }, "plus")).toBe(true); + expect(isCodexQuotaExhausted({ weeklyPercent: 100, monthlyPercent: 20 }, "free")).toBe(false); + expect(isCodexQuotaExhausted({ weeklyPercent: 20, monthlyPercent: 100 }, "go")).toBe(true); + }); + test("weekly threshold breach switches new threads", () => { const config = makeConfig(); updateAccountQuota("a", 85); @@ -160,6 +170,21 @@ describe("codex routing", () => { expect(resolveCodexAccountForThread("after-success", config)).toBe("a"); }); + test("paused main account is excluded even when it is the active and lowest-usage candidate", () => { + writeFileSync(join(TEST_DIR, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", account_id: "main-chatgpt-id" }, + })); + const config = makeConfig({ + codexAccounts: [{ id: "a", email: "a@test", isMain: false }], + activeCodexAccountId: MAIN_CODEX_ACCOUNT_ID, + pausedCodexAccountIds: [MAIN_CODEX_ACCOUNT_ID], + }); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 1); + updateAccountQuota("a", 20); + + expect(resolveCodexAccountForThread("paused-main", config)).toBe("a"); + }); + test("go plan pool switching ignores the weekly window", () => { const config = makeConfig({ codexAccounts: [ @@ -192,6 +217,25 @@ describe("codex routing", () => { expect(pickLowestUsageCodexAccount(config)).toBe("b"); }); + test("paused accounts are excluded from new selection and existing affinity reuse", () => { + const config = makeConfig(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + expect(resolveCodexAccountForThread("paused-affinity", config)).toBe("a"); + + config.pausedCodexAccountIds = ["a"]; + + expect(pickLowestUsageCodexAccount(config)).toBe("b"); + expect(resolveCodexAccountForThread("paused-affinity", config)).toBe("b"); + }); + + test("all paused accounts fail closed instead of falling back to a configured account", () => { + const config = makeConfig({ pausedCodexAccountIds: ["a", "b"] }); + + expect(pickLowestUsageCodexAccount(config)).toBeNull(); + expect(resolveCodexAccountForThread("all-paused", config)).toBeNull(); + }); + test("upstream outcome classifier separates caller, credential, and transient failures", () => { expect(classifyCodexUpstreamOutcome(200)).toBe("success"); expect(classifyCodexUpstreamOutcome(401)).toBe("credential"); @@ -314,6 +358,26 @@ describe("codex routing", () => { expect(resolveCodexAccountForThread("quota-next", config)).toBe("b"); }); + test("429 fallback skips paused candidates", () => { + const config = makeConfig({ + codexAccounts: [ + { id: "a", email: "a@test", isMain: false }, + { id: "b", email: "b@test", isMain: false }, + { id: "c", email: "c@test", isMain: false }, + ], + pausedCodexAccountIds: ["b"], + }); + saveTestCredential("c"); + updateAccountQuota("a", 10); + updateAccountQuota("b", 1); + updateAccountQuota("c", 30); + + recordCodexUpstreamOutcome(config, "a", 429, { retryAfter: "60" }); + + expect(config.activeCodexAccountId).toBe("c"); + expect(resolveCodexAccountForThread("quota-skip-paused", config)).toBe("c"); + }); + test("2xx responses clear transient failures without clearing an unexpired cooldown", () => { const config = makeConfig(); const now = 1_800_000_000_000; diff --git a/tests/config.test.ts b/tests/config.test.ts index 54cfa7292b..b5946f2c01 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -232,7 +232,6 @@ describe("opencodex config defaults", () => { }, defaultProvider: "openai", }; - writeConfig({ ...base, injectionModel: "gpt-5.6-terra", @@ -312,6 +311,29 @@ describe("opencodex config defaults", () => { expect(loadConfig().syncCodexSubagentDefaults).toBeUndefined(); }); + test("paused Codex account ids persist and reject malformed values", () => { + const base = { + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + }; + writeConfig({ ...base, pausedCodexAccountIds: ["__main__", "pool-a"] }); + expect(loadConfig().pausedCodexAccountIds).toEqual(["__main__", "pool-a"]); + + for (const invalid of ["pool-a", ["bad/account"], [1]]) { + writeConfig({ ...base, pausedCodexAccountIds: invalid }); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain("pausedCodexAccountIds"); + } + }); + test("loads valid config from OPENCODEX_HOME", () => { writeConfig({ port: 12345, diff --git a/tests/rate-limit-reset-credits.test.ts b/tests/rate-limit-reset-credits.test.ts index bfe9eaa767..9d66dc1f65 100644 --- a/tests/rate-limit-reset-credits.test.ts +++ b/tests/rate-limit-reset-credits.test.ts @@ -263,7 +263,7 @@ describe("rate-limit reset credits", () => { expect(source).toContain(" onOpenReset(a)} />"); // Next-session still renders BESIDE the ticket; health projection also suppresses // it for projected reauth/cooldown (not only the legacy needsReauth flag). - expect(source).toContain("{isNext(a.id) && !showReauth && !inCooldown && ("); + expect(source).toContain("{isNext(a) && !showReauth && !inCooldown && ("); expect(source).toContain("{t(accountModeState === \"direct\" ? \"codexAuth.poolPrepared\" : \"codexAuth.nextSession\")}"); const styles = await Bun.file("gui/src/styles.css").text(); expect(styles).toContain(".card-badges { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }");