From 06dbb9a8f9df34afd6a2e0253cda8dad865c8873 Mon Sep 17 00:00:00 2001 From: Alvin0412 Date: Tue, 28 Jul 2026 01:25:46 +0800 Subject: [PATCH 1/7] feat(codex): add persisted account pause controls --- .../docs/ja/reference/configuration.md | 3 + .../docs/ko/reference/configuration.md | 3 + .../content/docs/reference/configuration.md | 4 + .../docs/ru/reference/configuration.md | 4 + .../docs/zh-cn/reference/configuration.md | 3 + gui/src/components/CodexAccountPool.tsx | 22 ++++- .../components/codex-account-pool-cards.tsx | 27 +++++-- .../codex-account-pool-main-card.tsx | 34 ++++++-- gui/src/hooks/useCodexAccountPool.ts | 34 ++++++++ gui/src/i18n/de.ts | 8 ++ gui/src/i18n/en.ts | 8 ++ gui/src/i18n/ja.ts | 8 ++ gui/src/i18n/ko.ts | 8 ++ gui/src/i18n/ru.ts | 8 ++ gui/src/i18n/zh.ts | 8 ++ gui/src/icons.tsx | 2 + .../codex-account-pool-behaviour.test.tsx | 24 +++++- .../codex-account-pool-controller.test.ts | 16 +++- src/codex/account-lifecycle.ts | 2 + src/codex/account-pause.ts | 20 +++++ src/codex/auth-api.ts | 38 ++++++++- src/codex/routing.ts | 13 ++- src/codex/subagent-model-fallback.ts | 2 + src/config.ts | 1 + src/types.ts | 2 + structure/08_openai-provider-tiers.md | 5 ++ tests/codex-auth-api.test.ts | 80 +++++++++++++++++++ tests/codex-auth-context.test.ts | 31 +++++++ tests/codex-routing.test.ts | 54 +++++++++++++ tests/config.test.ts | 23 ++++++ tests/rate-limit-reset-credits.test.ts | 2 +- 31 files changed, 472 insertions(+), 25 deletions(-) create mode 100644 src/codex/account-pause.ts diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index d6189c18b0..b2d3386892 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -50,6 +50,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 自動切替をオフにします。 | | `upstreamFailoverThreshold?` | `number` | `3` | 一時的な上流失敗が連続して起きたのち、以降の新しいセッションを別の適合 pool アカウントに failover する回数。`0` なら失敗ベースの failover をオフにします。 | @@ -72,6 +73,8 @@ pool アカウントの追加と quota 更新はダッシュボードの **Codex ないアカウント metadata だけを保存し、access/refresh token は強化された Codex アカウント credential store に別途 保管します。既存 thread id はアカウント affinity を維持し、新しいセッションは quota、cooldown、health に 応じて自動ルーティングされる場合があります。 +一時停止したアカウントと quota metadata は表示されたままですが、自動切り替え、再試行/failover 選択、cooldown 復旧プローブ、手動有効化の対象外です。 +状態は再起動後も保持され、すべてのアカウントが一時停止中なら Pool ルーティングは別のアカウントを暗黙に選ばず失敗します。 ::: ### 管理型レコード形式 diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index 667ad827d4..32fa866418 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/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에 보이는 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 자동 전환을 끕니다. | | `upstreamFailoverThreshold?` | `number` | `3` | 일시적인 업스트림 실패가 연속으로 발생한 뒤, 이후 새 세션을 다른 적합한 pool 계정으로 failover할 횟수. `0`이면 실패 기반 failover를 끕니다. | @@ -74,6 +75,8 @@ pool 계정 추가와 quota 갱신은 대시보드의 **Codex Auth** 페이지 아닌 계정 metadata만 저장하고, access/refresh token은 강화된 Codex 계정 credential store에 따로 보관합니다. 기존 thread id는 계정 affinity를 유지하며, 새 세션은 quota, cooldown, health에 따라 자동 라우팅될 수 있습니다. +일시 중지된 계정과 quota metadata는 계속 표시되지만 자동 전환, 재시도/failover 선택, cooldown 복구 probe, 수동 활성화에서는 제외됩니다. +상태는 재시작 후에도 유지되며, 모든 계정이 일시 중지되면 Pool 라우팅은 계정을 몰래 선택하지 않고 실패합니다. ::: ### 관리형 레코드 형태 diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 122253b742..f661aa12d6 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -52,6 +52,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. | | `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient upstream failures before future new sessions fail over to another eligible pool account. Set `0` to disable failure failover. | @@ -75,6 +76,9 @@ Use the dashboard's **Codex Auth** page to add pool accounts and refresh quotas. non-secret account metadata only; access and refresh tokens are kept in the hardened Codex account credential store. Existing thread ids keep account affinity, while new sessions can auto-route based on quota, cooldown, and health. +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. ::: ### claudeCode (OcxClaudeCodeConfig) diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index e360f0f9a7..e98808b522 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -55,6 +55,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`, чтобы отключить автопереключение по квоте. | | `upstreamFailoverThreshold?` | `number` | `3` | Число подряд идущих временных сбоев вышестоящей стороны, после которого будущие новые сессии переключаются (failover) на другой подходящий аккаунт пула. Установите `0`, чтобы отключить переключение по сбоям. | @@ -80,6 +81,9 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е защищённом хранилище учётных данных аккаунтов Codex. Существующие id потоков сохраняют привязку к аккаунту, а новые сессии могут маршрутизироваться автоматически на основе квоты, cooldown и работоспособности. +Приостановленный аккаунт и его метаданные квоты остаются видимыми, но исключаются из автоматического переключения, +повторов/failover, проб восстановления cooldown и ручной активации. Состояние сохраняется после перезапуска; +если приостановлены все аккаунты, маршрутизация Pool завершается ошибкой, а не выбирает аккаунт скрытно. ::: ### claudeCode (OcxClaudeCodeConfig) 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 17f07ee08f..3b5e9771a4 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -49,6 +49,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 自动切换。 | | `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次临时上游失败后,让后续新 session failover 到其他合格 pool account。设为 `0` 可禁用失败切换。 | @@ -70,6 +71,8 @@ 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 可按 quota、cooldown 和 health 自动路由。 +暂停后仍会显示账号及其 quota metadata,但不会参与自动切换、重试/failover 选择、cooldown 恢复探测或手动激活。 +暂停状态会跨重启保留;如果所有账号均已暂停,Pool 路由会明确失败,而不会暗中选择某个账号。 ::: ### 受管 record 形状 diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 9e0027e36c..650da60645 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -53,7 +53,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, load } = controller; const [confirm, setConfirm] = useState(null); const [showAdd, setShowAdd] = useState(false); const [reauthId, setReauthId] = useState(null); @@ -163,6 +163,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; @@ -223,7 +237,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 ( @@ -254,6 +268,8 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban threshold={autoSwitch.threshold ?? 0} switchActionLabel={switchActionLabel} onSwitch={setConfirm} + onTogglePause={togglePaused} + pauseUpdatingId={pauseUpdatingId} onOpenReset={openResetPopup} onCopyDoctor={copyDoctor} copiedDoctorFor={copiedDoctorFor} @@ -281,6 +297,8 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban threshold={autoSwitch.threshold ?? 0} onOpenReset={openResetPopup} onSwitch={setConfirm} + onTogglePause={togglePaused} + pauseUpdatingId={pauseUpdatingId} 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 0bd2ae258f..225e85e6ae 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"; @@ -24,6 +24,8 @@ export function CodexAccountPoolCards({ threshold, onOpenReset, onSwitch, + onTogglePause, + pauseUpdatingId, onReauth, onEditAlias, onRemove, @@ -37,6 +39,8 @@ export function CodexAccountPoolCards({ threshold: number; onOpenReset: (account: CodexAccountEntry) => void; onSwitch: (account: CodexAccountEntry) => void; + onTogglePause: (account: CodexAccountEntry) => void; + pauseUpdatingId: string | null; onReauth: (id: string) => void; onEditAlias: (account: CodexAccountEntry) => void; onRemove: (id: string) => void; @@ -44,7 +48,7 @@ export function CodexAccountPoolCards({ copiedDoctorFor?: DoctorCopyFeedback | null; }) { const t = useT(); - const isNext = (id: string) => activeId === id; + const isNext = (account: CodexAccountEntry) => !account.paused && activeId === account.id; return ( <> @@ -55,24 +59,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 && ( @@ -87,6 +92,15 @@ export function CodexAccountPoolCards({ {doctorCopyButtonLabel(t, copiedDoctorFor, a.id)} )} + @@ -104,6 +118,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 eb74f43f81..8d54b25724 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"; @@ -24,6 +24,8 @@ export function CodexAccountPoolMainCard({ threshold, switchActionLabel, onSwitch, + onTogglePause, + pauseUpdatingId, onOpenReset, onCopyDoctor, copiedDoctorFor, @@ -35,6 +37,8 @@ export function CodexAccountPoolMainCard({ threshold: number; switchActionLabel: string; onSwitch: (entry: CodexAccountEntry) => void; + onTogglePause: (entry: CodexAccountEntry) => void; + pauseUpdatingId: string | null; onOpenReset: (account: CodexAccountEntry) => void; onCopyDoctor?: (accountId: string) => void; copiedDoctorFor?: DoctorCopyFeedback | null; @@ -46,6 +50,7 @@ export function CodexAccountPoolMainCard({ email: main?.email || mainFallbackLabel, plan: main?.plan, isMain: true, + paused: main?.paused ?? false, hasCredential: true, quota: main?.quota ?? null, }; @@ -63,17 +68,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 && ( @@ -83,12 +91,24 @@ export function CodexAccountPoolMainCard({ {doctorCopyButtonLabel(t, copiedDoctorFor, 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")}
)} diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index f23b7f73c8..f96834698e 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,12 @@ export interface CodexAccountPoolController { activeId: string | null; loadState: CodexAccountLoadState; switchingId: string | null; + pauseUpdatingId: string | null; activeNeedsReauth: boolean; load(refreshQuota?: boolean): Promise; switchAccount(id: string | null): Promise>; + setAccountPaused(id: string, paused: boolean): Promise; saveAlias(id: string, alias: string): Promise; removeAccount(id: string): Promise; syncAfterAccountAdded(): Promise; @@ -78,6 +82,7 @@ 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); // 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 +96,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 pauseUpdatingRef = useRef(null); const subscribeLoadObserver = useCallback((observer: CodexAccountLoadObserver) => { observersRef.current.add(observer); @@ -230,6 +236,32 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou } }, [apiBase, load]); + const setAccountPaused = useCallback(async (id: string, paused: boolean) => { + if (pauseUpdatingRef.current) return { ok: false, reason: "busy" } as const; + pauseUpdatingRef.current = 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 ? { ...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 { + pauseUpdatingRef.current = null; + setPauseUpdatingId(null); + } + }, [apiBase, load]); + const removeAccount = useCallback(async (id: string) => { try { const response = await fetch( @@ -261,9 +293,11 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou activeId, loadState, switchingId, + pauseUpdatingId, activeNeedsReauth, load, switchAccount, + setAccountPaused, saveAlias, removeAccount, syncAfterAccountAdded, diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index fe87c7fddb..40c4aedeab 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -625,6 +625,14 @@ export const de: Record = { "codexAuth.quotaRefreshed": "Kontingente aktualisiert", "codexAuth.quotaRefreshFailed": "Kontingente konnten nicht aktualisiert 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 3d246d3701..18e611e49c 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -939,6 +939,14 @@ export const en = { "codexAuth.quotaRefreshed": "Quotas refreshed", "codexAuth.quotaRefreshFailed": "Failed to refresh quotas", "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 57802d2772..24d2323aa1 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -893,6 +893,14 @@ export const ja: Record = { "codexAuth.quotaRefreshed": "クォータを更新しました", "codexAuth.quotaRefreshFailed": "クォータの更新に失敗しました", "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 d0843495af..b9801b7840 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -642,6 +642,14 @@ export const ko: Record = { "codexAuth.quotaRefreshed": "할당량을 다시 조회했습니다", "codexAuth.quotaRefreshFailed": "할당량 재조회에 실패했습니다", "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 2a4f62aa16..f0ae5456ea 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -938,6 +938,14 @@ export const ru: Record = { "codexAuth.quotaRefreshed": "Квоты обновлены", "codexAuth.quotaRefreshFailed": "Не удалось обновить квоты", "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 ee5ae01481..74feffe218 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -642,6 +642,14 @@ export const zh: Record = { "codexAuth.quotaRefreshed": "额度已刷新", "codexAuth.quotaRefreshFailed": "额度刷新失败", "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/codex-account-pool-behaviour.test.tsx b/gui/tests/codex-account-pool-behaviour.test.tsx index 84a58bb0c9..5bf979e08e 100644 --- a/gui/tests/codex-account-pool-behaviour.test.tsx +++ b/gui/tests/codex-account-pool-behaviour.test.tsx @@ -36,12 +36,21 @@ beforeEach(() => { originalFetch = globalThis.fetch; calls = []; - accounts = [{ id: "a1", email: "account-one", isMain: true, hasCredential: true, quota: 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 + ? { ...account, paused: body.paused } + : account + )); + return { ok: true, json: async () => ({ activeCodexAccountId: null }) } as unknown as Response; + } if (path.startsWith("codex-auth/accounts")) { return { ok: true, json: async () => ({ accounts }) } as unknown as Response; } @@ -105,6 +114,19 @@ 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("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..f9f8fc210c 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", "activeNeedsReauth", + "load", "switchAccount", "setAccountPaused", "saveAlias", "removeAccount", "syncAfterAccountAdded", ]) { expect(hook).toContain(member); } @@ -31,6 +31,18 @@ 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("pause is a token lease, so two holders cannot cancel each other", async () => { const hook = await read("../src/hooks/useCodexAccountPool.ts"); 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 5d1fba5da3..6f5474bb2a 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -10,7 +10,8 @@ import { TokenRefreshError, } from "./account-store"; import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; -import { clearCodexAccountCooldown, resetCodexRoutingForManualSelection } from "./routing"; +import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause"; +import { clearCodexAccountCooldown, clearThreadAccountMapForAccount, 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"; @@ -95,6 +96,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); @@ -106,6 +108,7 @@ function poolAccountDto( ...(account.plan !== undefined ? { plan: account.plan } : {}), ...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}), isMain: false, + paused, quota: quota ? { ...quota } : null, needsReauth, hasCredential, @@ -371,6 +374,7 @@ export interface CodexAuthAccountDto { plan?: string | null; logLabel?: string; isMain: boolean; + paused: boolean; quota: (StoredAccountQuota | (Omit & { updatedAt: number })) | null; needsReauth?: boolean; hasCredential: boolean; @@ -480,7 +484,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); @@ -493,6 +497,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, @@ -583,6 +588,32 @@ 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); + if (runtimeConfig.activeCodexAccountId === id) runtimeConfig.activeCodexAccountId = undefined; + } + saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ + ok: true, + id, + paused: body.paused, + activeCodexAccountId: runtimeConfig.activeCodexAccountId ?? 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. @@ -602,6 +633,9 @@ 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 && isCodexAccountPaused(runtimeConfig, body.accountId)) { + return jsonResponse({ error: "Account is paused" }, 409); + } if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) { const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === body.accountId); if (!exists) return jsonResponse({ error: "Account not found" }, 400); diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 645f4c59b7..eccf43b9c5 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 { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; @@ -389,7 +390,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); } @@ -441,7 +443,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)) @@ -450,6 +452,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) @@ -590,7 +593,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; } @@ -608,6 +611,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; } @@ -671,7 +675,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" }; @@ -682,6 +686,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 f9da2927d5..d66ae8db33 100644 --- a/src/config.ts +++ b/src/config.ts @@ -486,6 +486,7 @@ const configSchema = z.object({ contextCapValue: z.number().int().positive().optional(), multiAgentGuidanceEnabled: z.boolean().optional(), 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 76fdd5b3ad..33b24c8a12 100644 --- a/src/types.ts +++ b/src/types.ts @@ -640,6 +640,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..fafebced47 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -18,6 +18,11 @@ 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. + ```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 deb3d44cd7..e419064cd1 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -1103,6 +1103,84 @@ 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: null }); + expect(config.pausedCodexAccountIds).toEqual(["pool-active"]); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(resolveCodexAccountForThread("pause-thread", config)).toBe("pool-next"); + }); + + 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 }], @@ -1148,6 +1226,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 = { @@ -1176,6 +1255,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 8b3b5cbbb8..288ed15a30 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -160,6 +160,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 +207,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"); @@ -313,6 +347,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 cfd179caa0..a1e7074f3c 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -174,6 +174,29 @@ describe("opencodex config defaults", () => { } }); + 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; }"); From ebac7ba8e07807b89c67c38d1eb699193d4eea8a Mon Sep 17 00:00:00 2001 From: Alvin0412 Date: Tue, 28 Jul 2026 02:02:19 +0800 Subject: [PATCH 2/7] feat(codex): pause exhausted accounts in bulk --- README.md | 3 +- .../docs/ja/reference/configuration.md | 1 + .../docs/ko/reference/configuration.md | 1 + .../content/docs/reference/configuration.md | 2 + .../docs/ru/reference/configuration.md | 1 + .../docs/zh-cn/reference/configuration.md | 1 + gui/src/components/CodexAccountPool.tsx | 20 ++++- .../components/codex-account-pool-cards.tsx | 4 +- .../codex-account-pool-main-card.tsx | 37 ++++++++-- gui/src/hooks/useCodexAccountPool.ts | 40 +++++++++- gui/src/i18n/de.ts | 5 ++ gui/src/i18n/en.ts | 5 ++ gui/src/i18n/ja.ts | 5 ++ gui/src/i18n/ko.ts | 5 ++ gui/src/i18n/ru.ts | 5 ++ gui/src/i18n/zh.ts | 5 ++ .../codex-account-pool-behaviour.test.tsx | 28 +++++++ .../codex-account-pool-controller.test.ts | 14 +++- .../codex-account-pool-toast-tone.test.tsx | 4 + src/codex/auth-api.ts | 57 +++++++++++++++ src/codex/quota.ts | 15 ++++ structure/08_openai-provider-tiers.md | 2 + tests/codex-auth-api.test.ts | 73 +++++++++++++++++++ tests/codex-routing.test.ts | 12 ++- 24 files changed, 327 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 66808ae6b4..953a0dfc89 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 b2d3386892..e8284a2b3a 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -75,6 +75,7 @@ pool アカウントの追加と quota 更新はダッシュボードの **Codex 応じて自動ルーティングされる場合があります。 一時停止したアカウントと quota metadata は表示されたままですが、自動切り替え、再試行/failover 選択、cooldown 復旧プローブ、手動有効化の対象外です。 状態は再起動後も保持され、すべてのアカウントが一時停止中なら Pool ルーティングは別のアカウントを暗黙に選ばず失敗します。 +**上限到達を一括停止** は全アカウントを先に更新し、関連する quota window が今回 100% と確認できたアカウントだけを停止します。quota が不明、または更新に失敗したアカウントは変更しません。 ::: ### 管理型レコード形式 diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index 32fa866418..ccb6de48d9 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -77,6 +77,7 @@ pool 계정 추가와 quota 갱신은 대시보드의 **Codex Auth** 페이지 자동 라우팅될 수 있습니다. 일시 중지된 계정과 quota metadata는 계속 표시되지만 자동 전환, 재시도/failover 선택, cooldown 복구 probe, 수동 활성화에서는 제외됩니다. 상태는 재시작 후에도 유지되며, 모든 계정이 일시 중지되면 Pool 라우팅은 계정을 몰래 선택하지 않고 실패합니다. +**한도 도달 계정 일시 중지**는 먼저 모든 계정을 새로고친 뒤 관련 quota window가 이번 응답에서 100%로 확인된 계정만 일시 중지합니다. quota가 없거나 새로고침에 실패한 계정은 변경하지 않습니다. ::: ### 관리형 레코드 형태 diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index f661aa12d6..fa2e77562a 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -79,6 +79,8 @@ on quota, cooldown, and health. 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. ::: ### claudeCode (OcxClaudeCodeConfig) diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index e98808b522..b4b3288280 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -84,6 +84,7 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е Приостановленный аккаунт и его метаданные квоты остаются видимыми, но исключаются из автоматического переключения, повторов/failover, проб восстановления cooldown и ручной активации. Состояние сохраняется после перезапуска; если приостановлены все аккаунты, маршрутизация Pool завершается ошибкой, а не выбирает аккаунт скрытно. +**Приостановить исчерпанные** сначала обновляет все аккаунты и приостанавливает только те, для которых актуальное окно квоты в этом ответе подтверждено на уровне 100%. Аккаунты с неизвестной квотой или неудачным обновлением не меняются. ::: ### claudeCode (OcxClaudeCodeConfig) 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 3b5e9771a4..7706751acd 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -73,6 +73,7 @@ metadata;access/refresh token 存放在加固的 Codex account credential stor 保留 account affinity,新 session 可按 quota、cooldown 和 health 自动路由。 暂停后仍会显示账号及其 quota metadata,但不会参与自动切换、重试/failover 选择、cooldown 恢复探测或手动激活。 暂停状态会跨重启保留;如果所有账号均已暂停,Pool 路由会明确失败,而不会暗中选择某个账号。 +**暂停已达上限账号** 会先刷新全部账号,只暂停相关 quota window 本次明确返回 100% 的账号;未知额度或刷新失败的账号保持不变。 ::: ### 受管 record 形状 diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 650da60645..5d9adb1756 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -27,7 +27,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; @@ -53,7 +53,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, pauseUpdatingId, 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); @@ -199,6 +199,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); @@ -246,7 +258,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}} @@ -270,6 +284,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban onSwitch={setConfirm} onTogglePause={togglePaused} pauseUpdatingId={pauseUpdatingId} + pauseBusy={pauseUpdatingId !== null || pausingExhausted} onOpenReset={openResetPopup} onCopyDoctor={copyDoctor} copiedDoctorFor={copiedDoctorFor} @@ -299,6 +314,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban 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 225e85e6ae..faf8f8777d 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -26,6 +26,7 @@ export function CodexAccountPoolCards({ onSwitch, onTogglePause, pauseUpdatingId, + pauseBusy, onReauth, onEditAlias, onRemove, @@ -41,6 +42,7 @@ export function CodexAccountPoolCards({ 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; @@ -96,7 +98,7 @@ export function CodexAccountPoolCards({ type="button" className={`btn btn-sm ${a.paused ? "btn-primary" : "btn-ghost"}`} onClick={() => onTogglePause(a)} - disabled={pauseUpdatingId !== null} + disabled={pauseBusy} > {a.paused ? : } {pauseUpdatingId === a.id ? t("common.saving") : t(a.paused ? "codexAuth.resume" : "codexAuth.pause")} diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index 8d54b25724..cf235cf75d 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -26,6 +26,7 @@ export function CodexAccountPoolMainCard({ onSwitch, onTogglePause, pauseUpdatingId, + pauseBusy, onOpenReset, onCopyDoctor, copiedDoctorFor, @@ -39,6 +40,7 @@ export function CodexAccountPoolMainCard({ onSwitch: (entry: CodexAccountEntry) => void; onTogglePause: (entry: CodexAccountEntry) => void; pauseUpdatingId: string | null; + pauseBusy: boolean; onOpenReset: (account: CodexAccountEntry) => void; onCopyDoctor?: (accountId: string) => void; copiedDoctorFor?: DoctorCopyFeedback | null; @@ -96,7 +98,7 @@ export function CodexAccountPoolMainCard({ type="button" className={`btn btn-sm ${main.paused ? "btn-primary" : "btn-ghost"}`} onClick={() => onTogglePause(mainSwitchEntry)} - disabled={pauseUpdatingId !== null} + disabled={pauseBusy} > {main.paused ? : } {pauseUpdatingId === main.id ? t("common.saving") : t(main.paused ? "codexAuth.resume" : "codexAuth.pause")} @@ -123,20 +125,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 f96834698e..8cae6e1030 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -59,11 +59,13 @@ export interface CodexAccountPoolController { 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; @@ -83,6 +85,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou 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); @@ -96,7 +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 pauseUpdatingRef = useRef(null); + const pauseMutationRef = useRef<"bulk" | { accountId: string } | null>(null); const subscribeLoadObserver = useCallback((observer: CodexAccountLoadObserver) => { observersRef.current.add(observer); @@ -237,8 +240,8 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou }, [apiBase, load]); const setAccountPaused = useCallback(async (id: string, paused: boolean) => { - if (pauseUpdatingRef.current) return { ok: false, reason: "busy" } as const; - pauseUpdatingRef.current = id; + 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`, { @@ -257,11 +260,38 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou } catch { return { ok: false, reason: "request" } as const; } finally { - pauseUpdatingRef.current = null; + 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) ? { ...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( @@ -294,10 +324,12 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou 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 40c4aedeab..ef711a93e7 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -624,6 +624,11 @@ 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", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 18e611e49c..e7e0b7c0f1 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -938,6 +938,11 @@ 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", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 24d2323aa1..805552d9eb 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -892,6 +892,11 @@ 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": "再開", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b9801b7840..04bc6c9cca 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -641,6 +641,11 @@ 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": "재개", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index f0ae5456ea..5f04a73eb8 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -937,6 +937,11 @@ 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": "Возобновить", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 74feffe218..c265897b1f 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -641,6 +641,11 @@ 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": "恢复", diff --git a/gui/tests/codex-account-pool-behaviour.test.tsx b/gui/tests/codex-account-pool-behaviour.test.tsx index 5bf979e08e..ae1604f78a 100644 --- a/gui/tests/codex-account-pool-behaviour.test.tsx +++ b/gui/tests/codex-account-pool-behaviour.test.tsx @@ -51,6 +51,17 @@ beforeEach(() => { )); return { ok: true, json: async () => ({ activeCodexAccountId: null }) } as unknown as Response; } + if (path === "codex-auth/accounts/pause-exhausted") { + accounts = accounts.map(account => ( + typeof account === "object" && account !== null && "id" in account && account.id === "a2" + ? { ...account, paused: true } + : account + )); + return { + ok: true, + json: async () => ({ pausedAccountIds: ["a2"], pausedCount: 1, activeCodexAccountId: null }), + } as unknown as Response; + } if (path.startsWith("codex-auth/accounts")) { return { ok: true, json: async () => ({ accounts }) } as unknown as Response; } @@ -127,6 +138,23 @@ test("pausing an account writes the persisted endpoint and updates shared state" expect(seen.current!.activeId).toBeNull(); }); +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("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 f9f8fc210c..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", "pauseUpdatingId", "activeNeedsReauth", - "load", "switchAccount", "setAccountPaused", "saveAlias", "removeAccount", "syncAfterAccountAdded", + "accounts", "activeId", "loadState", "switchingId", "pauseUpdatingId", "pausingExhausted", "activeNeedsReauth", + "load", "switchAccount", "setAccountPaused", "pauseExhaustedAccounts", "saveAlias", "removeAccount", "syncAfterAccountAdded", ]) { expect(hook).toContain(member); } @@ -43,6 +43,16 @@ test("main and added account cards expose the same persisted pause control", asy 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/auth-api.ts b/src/codex/auth-api.ts index 6f5474bb2a..a7a52fa219 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -19,6 +19,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } import { clearAccountQuota, getAccountQuota, + isCodexQuotaExhausted, listAccountQuotas, parseUsageQuota, setAccountQuotaFromParsed, @@ -274,6 +275,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; } @@ -352,6 +355,7 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini } return { info: result, + ...(quota ? { freshQuota: quota } : {}), ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), }; } catch { @@ -363,6 +367,8 @@ 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 `rate_limit_reset_credits.available_count`. */ freshResetCredits?: number; } @@ -404,6 +410,7 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co return { quota: getAccountQuota(accountId), needsReauth: false, + freshQuota: quota, ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), }; } catch (e) { @@ -506,6 +513,43 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa return [main, ...withQuota]; } +async function pauseExhaustedCodexAccounts(config: OcxConfig): Promise { + const poolAccounts = (config.codexAccounts ?? []).filter(account => !account.isMain); + 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), + }; + }), + ]); + + const exhaustedIds: string[] = []; + if ( + !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) + && isCodexQuotaExhausted(mainResult.freshQuota ?? null, mainResult.info.plan) + ) { + exhaustedIds.push(MAIN_CODEX_ACCOUNT_ID); + } + for (const { account, quotaResult } of poolResults) { + if ( + !isCodexAccountPaused(config, account.id) + && isCodexQuotaExhausted(quotaResult?.freshQuota ?? null, account.plan) + ) { + exhaustedIds.push(account.id); + } + } + + for (const id of exhaustedIds) { + setCodexAccountPaused(config, id, true); + clearThreadAccountMapForAccount(id); + if (config.activeCodexAccountId === id) config.activeCodexAccountId = undefined; + } + return exhaustedIds; +} + export async function handleCodexAuthAPI( req: Request, url: URL, @@ -614,6 +658,19 @@ export async function handleCodexAuthAPI( }); } + if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") { + const runtimeConfig = getRuntimeConfig(config); + const pausedAccountIds = await pauseExhaustedCodexAccounts(runtimeConfig); + if (pausedAccountIds.length > 0) saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ + ok: true, + pausedAccountIds, + pausedCount: pausedAccountIds.length, + activeCodexAccountId: runtimeConfig.activeCodexAccountId ?? 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. 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/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index fafebced47..1a3ce47641 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -22,6 +22,8 @@ provider may not fall through to Codex-login credentials. 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 diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index e419064cd1..abf0f0b3c9 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -1143,6 +1143,79 @@ describe("codex-auth API", () => { expect(resolveCodexAccountForThread("pause-thread", config)).toBe("pool-next"); }); + 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: "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" + ? 100 + : 72; + const monthly = accountId === "free-weekly-only" ? 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"], + pausedCount: 1, + activeCodexAccountId: null, + appliesImmediately: true, + }); + expect(config.pausedCodexAccountIds).toEqual(["already-paused", "exhausted"]); + expect(resolveCodexAccountForThread("bulk-pause-thread", config)).toBe("free-weekly-only"); + }); + + 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("resuming restores eligibility and manual activation rejects paused accounts", async () => { const config = makeConfig({ codexAccounts: [{ id: "work", email: "work@example.test", isMain: false }], diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index 288ed15a30..5f199b5ef9 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); From 84247cd90453afc7791679442cd7edf1f7ccf042 Mon Sep 17 00:00:00 2001 From: Alvin0412 Date: Tue, 28 Jul 2026 02:34:33 +0800 Subject: [PATCH 3/7] fix(gui): reconcile main account pause sentinel --- .../codex-account-pool-main-card.tsx | 2 +- gui/src/hooks/useCodexAccountPool.ts | 6 ++++- .../codex-account-pool-behaviour.test.tsx | 27 ++++++++++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index cf235cf75d..3c362d15ec 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -101,7 +101,7 @@ export function CodexAccountPoolMainCard({ disabled={pauseBusy} > {main.paused ? : } - {pauseUpdatingId === main.id ? t("common.saving") : t(main.paused ? "codexAuth.resume" : "codexAuth.pause")} + {pauseUpdatingId === "__main__" ? t("common.saving") : t(main.paused ? "codexAuth.resume" : "codexAuth.pause")} )} {t("codexAuth.appLogin")} diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index 8cae6e1030..0e12717f98 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -251,7 +251,11 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou }); 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 ? { ...account, paused } : account)); + 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); diff --git a/gui/tests/codex-account-pool-behaviour.test.tsx b/gui/tests/codex-account-pool-behaviour.test.tsx index ae1604f78a..da433ced78 100644 --- a/gui/tests/codex-account-pool-behaviour.test.tsx +++ b/gui/tests/codex-account-pool-behaviour.test.tsx @@ -22,6 +22,7 @@ let calls: string[] = []; let originalFetch: typeof globalThis.fetch; let accounts: unknown[] = []; let threshold = 80; +let nextAccountsResponseGate: Promise | null = null; beforeEach(() => { previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; @@ -36,6 +37,7 @@ beforeEach(() => { originalFetch = globalThis.fetch; calls = []; + nextAccountsResponseGate = null; accounts = [{ id: "a1", email: "account-one", isMain: true, paused: false, hasCredential: true, quota: null }]; Object.defineProperty(globalThis, "fetch", { configurable: true, @@ -45,7 +47,8 @@ beforeEach(() => { 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 + 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 )); @@ -63,6 +66,9 @@ beforeEach(() => { } 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")) { @@ -138,6 +144,25 @@ test("pausing an account writes the persisted endpoint and updates shared state" 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("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 }, From 36946b9ec36eef5e3c79e1b7dcdc870e5876b25e Mon Sep 17 00:00:00 2001 From: Alvin0412 Date: Tue, 28 Jul 2026 03:24:21 +0800 Subject: [PATCH 4/7] fix(codex): classify bulk pause with fresh plan --- src/codex/auth-api.ts | 10 ++++++++-- tests/codex-auth-api.test.ts | 11 ++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index a7a52fa219..8e274b51a6 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -369,6 +369,8 @@ interface PoolQuotaResult { 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; /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */ freshResetCredits?: number; } @@ -403,7 +405,10 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co }); 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 = typeof data.plan_type === "string" && data.plan_type.trim() !== "" + ? data.plan_type + : undefined; + const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan }); const freshResetCredits = quota?.resetCredits; if (!quota) return { quota: existing ?? null, needsReauth: false }; setAccountQuotaFromParsed(accountId, quota); @@ -411,6 +416,7 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co quota: getAccountQuota(accountId), needsReauth: false, freshQuota: quota, + ...(freshPlan !== undefined ? { freshPlan } : {}), ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), }; } catch (e) { @@ -536,7 +542,7 @@ async function pauseExhaustedCodexAccounts(config: OcxConfig): Promise for (const { account, quotaResult } of poolResults) { if ( !isCodexAccountPaused(config, account.id) - && isCodexQuotaExhausted(quotaResult?.freshQuota ?? null, account.plan) + && isCodexQuotaExhausted(quotaResult?.freshQuota ?? null, quotaResult?.freshPlan ?? account.plan) ) { exhaustedIds.push(account.id); } diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index abf0f0b3c9..e30afdac19 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -1149,6 +1149,7 @@ describe("codex-auth API", () => { { 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 }, ], @@ -1169,10 +1170,10 @@ describe("codex-auth API", () => { 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" + const weekly = accountId === "exhausted" || accountId === "already-paused" || accountId === "free-weekly-only" || accountId === "upgraded-plus" ? 100 : 72; - const monthly = accountId === "free-weekly-only" ? 20 : 40; + const monthly = accountId === "free-weekly-only" || accountId === "upgraded-plus" ? 20 : 40; return Response.json({ plan_type: accountId === "free-weekly-only" ? "free" : "plus", rate_limit: { @@ -1187,12 +1188,12 @@ describe("codex-auth API", () => { expect(resp!.status).toBe(200); expect(await resp!.json()).toMatchObject({ - pausedAccountIds: ["exhausted"], - pausedCount: 1, + pausedAccountIds: ["exhausted", "upgraded-plus"], + pausedCount: 2, activeCodexAccountId: null, appliesImmediately: true, }); - expect(config.pausedCodexAccountIds).toEqual(["already-paused", "exhausted"]); + expect(config.pausedCodexAccountIds).toEqual(["already-paused", "exhausted", "upgraded-plus"]); expect(resolveCodexAccountForThread("bulk-pause-thread", config)).toBe("free-weekly-only"); }); From c38617a5a125c2f5eb4d4341e8f848a4fbd71640 Mon Sep 17 00:00:00 2001 From: Alvin0412 Date: Tue, 28 Jul 2026 11:34:27 +0800 Subject: [PATCH 5/7] fix(codex): harden paused account reconciliation --- gui/src/hooks/useCodexAccountPool.ts | 3 +- .../active-account-reauth-aggregate.test.ts | 3 +- .../codex-account-pool-behaviour.test.tsx | 30 +++- src/codex/auth-api.ts | 100 ++++++++++--- tests/codex-auth-api.test.ts | 133 +++++++++++++++++- 5 files changed, 239 insertions(+), 30 deletions(-) diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index 0e12717f98..6c747d0e9a 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -320,7 +320,8 @@ 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, 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 da433ced78..c27b448514 100644 --- a/gui/tests/codex-account-pool-behaviour.test.tsx +++ b/gui/tests/codex-account-pool-behaviour.test.tsx @@ -23,6 +23,7 @@ let originalFetch: typeof globalThis.fetch; let accounts: unknown[] = []; let threshold = 80; let nextAccountsResponseGate: Promise | null = null; +let pauseResponseActiveId: string | null = null; beforeEach(() => { previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; @@ -38,6 +39,7 @@ beforeEach(() => { originalFetch = globalThis.fetch; calls = []; nextAccountsResponseGate = null; + pauseResponseActiveId = null; accounts = [{ id: "a1", email: "account-one", isMain: true, paused: false, hasCredential: true, quota: null }]; Object.defineProperty(globalThis, "fetch", { configurable: true, @@ -52,7 +54,7 @@ beforeEach(() => { ? { ...account, paused: body.paused } : account )); - return { ok: true, json: async () => ({ activeCodexAccountId: null }) } as unknown as Response; + return { ok: true, json: async () => ({ activeCodexAccountId: pauseResponseActiveId }) } as unknown as Response; } if (path === "codex-auth/accounts/pause-exhausted") { accounts = accounts.map(account => ( @@ -163,6 +165,32 @@ test("pausing the main sentinel updates its distinct account row before reload", }); }); +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 }, diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 8e274b51a6..44c1c32c6b 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, @@ -11,7 +12,12 @@ import { } from "./account-store"; import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause"; -import { clearCodexAccountCooldown, clearThreadAccountMapForAccount, resetCodexRoutingForManualSelection } from "./routing"; +import { + clearCodexAccountCooldown, + clearThreadAccountMapForAccount, + pickLowestUsageCodexAccount, + 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"; @@ -36,7 +42,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, @@ -212,6 +218,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; } @@ -337,11 +347,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(), }; @@ -371,6 +382,8 @@ interface PoolQuotaResult { 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; } @@ -398,24 +411,26 @@ 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 freshPlan = typeof data.plan_type === "string" && data.plan_type.trim() !== "" - ? data.plan_type - : undefined; + 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 } : {}), }; @@ -519,8 +534,21 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa return [main, ...withQuota]; } -async function pauseExhaustedCodexAccounts(config: OcxConfig): Promise { +interface PauseExhaustedResult { + pausedAccountIds: string[]; + checkedAccountCount: number; + failedAccountCount: number; +} + +function selectFallbackAfterPause(config: OcxConfig, pausedActiveId: string): void { + const activeId = config.activeCodexAccountId ?? MAIN_CODEX_ACCOUNT_ID; + if (activeId !== pausedActiveId) return; + config.activeCodexAccountId = pickLowestUsageCodexAccount(config, pausedActiveId) ?? undefined; +} + +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 => { @@ -532,28 +560,44 @@ async function pauseExhaustedCodexAccounts(config: OcxConfig): Promise }), ]); + let checkedAccountCount = 0; + let failedAccountCount = 0; const exhaustedIds: string[] = []; - if ( - !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) - && isCodexQuotaExhausted(mainResult.freshQuota ?? null, mainResult.info.plan) - ) { - exhaustedIds.push(MAIN_CODEX_ACCOUNT_ID); + 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) { - if ( - !isCodexAccountPaused(config, account.id) - && isCodexQuotaExhausted(quotaResult?.freshQuota ?? null, quotaResult?.freshPlan ?? account.plan) - ) { + 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); } } + const activeId = config.activeCodexAccountId ?? MAIN_CODEX_ACCOUNT_ID; for (const id of exhaustedIds) { setCodexAccountPaused(config, id, true); clearThreadAccountMapForAccount(id); - if (config.activeCodexAccountId === id) config.activeCodexAccountId = undefined; } - return exhaustedIds; + if (exhaustedIds.includes(activeId)) selectFallbackAfterPause(config, activeId); + return { pausedAccountIds: exhaustedIds, checkedAccountCount, failedAccountCount }; } export async function handleCodexAuthAPI( @@ -652,7 +696,7 @@ export async function handleCodexAuthAPI( setCodexAccountPaused(runtimeConfig, id, body.paused); if (body.paused) { clearThreadAccountMapForAccount(id); - if (runtimeConfig.activeCodexAccountId === id) runtimeConfig.activeCodexAccountId = undefined; + selectFallbackAfterPause(runtimeConfig, id); } saveRuntimeConfig(config, runtimeConfig); return jsonResponse({ @@ -666,12 +710,24 @@ export async function handleCodexAuthAPI( if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") { const runtimeConfig = getRuntimeConfig(config); - const pausedAccountIds = await pauseExhaustedCodexAccounts(runtimeConfig); + 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: runtimeConfig.activeCodexAccountId ?? null, appliesImmediately: true, }); diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index e30afdac19..4c79bf278a 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -7,7 +7,7 @@ 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 { @@ -22,7 +22,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 +117,8 @@ beforeEach(() => { clearAccountNeedsReauth("__main__"); clearAccountQuota(); clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearMainAccountInfoCache(); + setMainAccountPlan(null); clearCodexWebSocketRegistry(); resetMainCodexAccountIdentityTrackingForTests(); }); @@ -125,6 +127,8 @@ afterEach(() => { clearAccountNeedsReauth("__main__"); clearAccountQuota(); clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearMainAccountInfoCache(); + setMainAccountPlan(null); clearCodexWebSocketRegistry(); globalThis.fetch = previousFetch; if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; @@ -1137,9 +1141,9 @@ describe("codex-auth API", () => { 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: null }); + expect(await resp!.json()).toMatchObject({ id: "pool-active", paused: true, activeCodexAccountId: "pool-next" }); expect(config.pausedCodexAccountIds).toEqual(["pool-active"]); - expect(config.activeCodexAccountId).toBeUndefined(); + expect(config.activeCodexAccountId).toBe("pool-next"); expect(resolveCodexAccountForThread("pause-thread", config)).toBe("pool-next"); }); @@ -1190,13 +1194,132 @@ describe("codex-auth API", () => { expect(await resp!.json()).toMatchObject({ pausedAccountIds: ["exhausted", "upgraded-plus"], pausedCount: 2, - activeCodexAccountId: null, + 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" }, From c1be956c5b8e421ae25c81fe7ec3a82272fd676e Mon Sep 17 00:00:00 2001 From: Alvin0412 Date: Tue, 28 Jul 2026 11:49:08 +0800 Subject: [PATCH 6/7] fix(codex): close pause sentinel gaps --- gui/src/hooks/useCodexAccountPool.ts | 6 ++- .../codex-account-pool-behaviour.test.tsx | 39 ++++++++++++++++++- src/codex/auth-api.ts | 9 +++-- tests/codex-auth-api.test.ts | 14 +++++++ 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index 6c747d0e9a..dd5485e3c3 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -282,7 +282,11 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou activeCodexAccountId?: string | null; }; const pausedIds = new Set(result.pausedAccountIds ?? []); - setAccounts(current => current.map(account => pausedIds.has(account.id) ? { ...account, paused: true } : account)); + 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); diff --git a/gui/tests/codex-account-pool-behaviour.test.tsx b/gui/tests/codex-account-pool-behaviour.test.tsx index c27b448514..2f9e390350 100644 --- a/gui/tests/codex-account-pool-behaviour.test.tsx +++ b/gui/tests/codex-account-pool-behaviour.test.tsx @@ -24,6 +24,8 @@ 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; @@ -40,6 +42,8 @@ beforeEach(() => { calls = []; 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, @@ -57,14 +61,20 @@ beforeEach(() => { 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 && account.id === "a2" + 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: ["a2"], pausedCount: 1, activeCodexAccountId: null }), + json: async () => ({ + pausedAccountIds: bulkPausedAccountIds, + pausedCount: bulkPausedAccountIds.length, + activeCodexAccountId: bulkResponseActiveId, + }), } as unknown as Response; } if (path.startsWith("codex-auth/accounts")) { @@ -208,6 +218,31 @@ test("bulk pausing writes one endpoint and updates every returned account", asyn 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)); + }); +}); + test("two pause holders both have to release before polling resumes", async () => { const seen = await mountController(); const controller = seen.current!; diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 44c1c32c6b..5a035b3015 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -752,15 +752,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 && isCodexAccountPaused(runtimeConfig, body.accountId)) { + const targetAccountId = body.accountId ?? MAIN_CODEX_ACCOUNT_ID; + if (isCodexAccountPaused(runtimeConfig, targetAccountId)) { return jsonResponse({ error: "Account is paused" }, 409); } - if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) { - const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === body.accountId); + 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/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 4c79bf278a..8b4047c675 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -1340,6 +1340,20 @@ describe("codex-auth API", () => { 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 }], From 98aefc446314cfb0ec772d31cec5c218cc6fcafe Mon Sep 17 00:00:00 2001 From: Alvin0412 Date: Tue, 28 Jul 2026 11:55:43 +0800 Subject: [PATCH 7/7] test(gui): verify bulk pause reload state --- gui/tests/codex-account-pool-behaviour.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gui/tests/codex-account-pool-behaviour.test.tsx b/gui/tests/codex-account-pool-behaviour.test.tsx index 2f9e390350..2b02a4516f 100644 --- a/gui/tests/codex-account-pool-behaviour.test.tsx +++ b/gui/tests/codex-account-pool-behaviour.test.tsx @@ -241,6 +241,9 @@ test("bulk pausing translates the main sentinel to its distinct account row", as 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 () => {