From de1d887395c628fce1ef933a1a6b742d5264cf1e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:22:56 +0900 Subject: [PATCH 1/2] feat(codex): let an operator keep a downgraded account out of pool rotation When a ChatGPT subscription lapses, the account is downgraded to Free and keeps taking production traffic until requests start failing. codexPool.excludedPlans lets an operator name the plan keys automatic selection skips. It is absent by default, so an existing install rotates exactly as before. The filter goes where pause already goes, in both isCodexAccountSelectable and the getEligiblePoolAccounts pool-row chain. That is not redundancy: the eligible list is only consulted when routing picks a NEW account, while an account that is already active or already bound to a thread is served straight out of isCodexAccountSelectable. A lapsed subscription leaves behind exactly that account, so filtering only the eligible list would have shipped a config key that reads correctly and changes nothing for the reporter. Two deliberate limits. The main account is exempt, because selection-only routing withholds its plan rather than reading the fenced native credential, so a rule covering it would answer differently under drain than under ordinary routing. And unlike pause, an excluded account still answers when no unexcluded candidate remains: #4211 asks for a selection policy rather than a hard block, and pausing every account is still how you stop serving entirely. No minimumPlan. Ranking ChatGPT plans against each other needs a total ordering this repository does not have. A malformed policy degrades to no policy rather than failing the parse, so a hand-edited typo cannot trip the backup-and-defaults repair path. Because that makes it a successful parse, the write path rejects it and loadConfig warns on all three success paths instead of letting it disappear in silence. Refs #4211 --- .../docs/fr/guides/codex-integration.md | 12 ++ .../content/docs/guides/codex-integration.md | 12 ++ .../docs/ja/guides/codex-integration.md | 12 ++ .../docs/ko/guides/codex-integration.md | 12 ++ .../docs/ru/guides/codex-integration.md | 12 ++ .../docs/tr/guides/codex-integration.md | 12 ++ .../docs/zh-cn/guides/codex-integration.md | 12 ++ .../docs/zh-tw/guides/codex-integration.md | 12 ++ scripts/test-layout/layout.json | 1 + src/codex/routing.ts | 49 ++++- src/config.ts | 62 ++++++ src/types/config.ts | 27 +++ .../codex-pool-plan-exclusion.test.ts | 178 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 14 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 tests/codex-integration/codex-pool-plan-exclusion.test.ts diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index 1d9704e55f..e8a4deae4f 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -389,6 +389,18 @@ Lorsqu'un compte quitte la sélection du pool, la raison accompagne la décision Un renouvellement du compte principal qui n'aboutit pas répond toujours `503` avec `Retry-After`, car une nouvelle tentative peut réussir. Le message précise désormais qu'un échec persistant signifie que le compte principal doit être réauthentifié, au lieu de demander seulement de réessayer. +### Écarter de la rotation un compte rétrogradé + +`codexPool.excludedPlans` liste les clés de forfait que la sélection automatique du pool ignore, comparées sans tenir compte de la casse au forfait enregistré sur chaque compte. Absent par défaut : une installation existante effectue exactement la même rotation qu'avant. + +```bash +ocx config set codexPool '{"excludedPlans":["free"]}' +``` + +C'est une politique de sélection, pas un blocage. Un compte écarté conserve ses identifiants, son historique de quota et son affinité de thread, reste visible dans la liste des comptes et demeure joignable par sélection explicite comme `work/gpt-5.4`. Seule la rotation automatique cesse de le choisir, y compris lorsqu'il est déjà le compte actif ou déjà lié à un thread — l'état exact que laisse un abonnement expiré. + +Deux limites volontaires. Le compte Codex principal n'est jamais écarté par forfait, car le routage en mode sélection seule ne lit pas son forfait dans les identifiants natifs protégés ; une règle le couvrant se contredirait. Et lorsqu'il ne reste aucun compte non écarté, le compte écarté répond quand même au lieu d'échouer : mettre tous les comptes en pause reste le moyen d'arrêter complètement le service. Il n'existe pas de `minimumPlan`, car classer les forfaits ChatGPT entre eux exige un ordre total qui n'existe pas ici. + ## Restauration de Codex natif `ocx stop` arrête le proxy et le service d'arrière-plan installé, puis tente de restaurer Codex natif. OpenCodex retire les éléments de routage dont il peut vérifier la propriété et signale une restauration incomplète si les fichiers de configuration ne peuvent pas être récupérés en toute sécurité. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index c1e296eb49..a99b880fc1 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -720,6 +720,18 @@ When an account leaves pool selection, the reason travels with the decision inst A main-account refresh that does not complete still answers `503` with `Retry-After`, because a retry may still succeed. The message now adds that a failure which persists means the main account needs reauthentication, rather than only asking for another attempt. +### Keeping a downgraded account out of rotation + +`codexPool.excludedPlans` lists plan keys that automatic pool selection skips, matched case-insensitively against the plan stored on each account. It is absent by default, so an existing install rotates exactly as before. + +```bash +ocx config set codexPool '{"excludedPlans":["free"]}' +``` + +This is a selection policy, not a block. An excluded account keeps its credential, quota history, and thread affinity, stays visible on the account surface, and is still reachable by explicit account selection such as `work/gpt-5.4`. What changes is that automatic rotation stops choosing it, including when it is already the active account or already bound to a thread — which is the state a lapsed subscription leaves behind. + +Two deliberate limits. The main Codex account is never excluded by plan, because selection-only routing withholds its plan rather than reading the fenced native credential, so a rule covering it would disagree with itself. And when no unexcluded account remains, the excluded one still answers rather than failing closed; pausing every account is still the way to stop serving entirely. There is no `minimumPlan` counterpart, because ranking ChatGPT plans against each other needs a total ordering that does not exist here. + ## Restoring native Codex `ocx stop` stops the proxy and any installed background service, then attempts to restore native Codex. OpenCodex removes verified routing artifacts and reports an incomplete restore when it cannot safely recover configuration files. diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index e64a87e77b..4ce54074d3 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -255,6 +255,18 @@ ocx service install # persistent: auto-starts on login and respawns on crash メインアカウントの更新が完了しない場合も、再試行で成功する可能性があるため `Retry-After` 付きの `503` を返します。ただしメッセージには、失敗が続くならメインアカウントの再認証が必要である旨を加えました。 +### ダウングレードしたアカウントをローテーションから外す + +`codexPool.excludedPlans` は、自動的なプール選択がスキップするプランキーの一覧です。各アカウントに保存されたプランと大文字小文字を区別せずに照合します。既定では未設定なので、既存の環境のローテーションは変わりません。 + +```bash +ocx config set codexPool '{"excludedPlans":["free"]}' +``` + +これはブロックではなく選択ポリシーです。除外されたアカウントも資格情報・使用量履歴・スレッドアフィニティを保持し、アカウント一覧に表示され、`work/gpt-5.4` のような明示的な指定では引き続き利用できます。変わるのは自動ローテーションが選ばなくなる点で、すでにアクティブなアカウントやスレッドに紐づいている場合も含みます。サブスクリプションが失効した直後は、まさにその状態です。 + +意図的な制限が2つあります。メインの Codex アカウントはプランによって除外されません。選択のみのルーティングは保護されたネイティブ資格情報を読まずにプランを伏せるため、メインを対象にすると挙動が食い違うからです。また、除外されていないアカウントが1つも残らない場合は、失敗させずに除外済みのアカウントが応答します。完全に停止したい場合は従来どおり全アカウントを一時停止してください。`minimumPlan` に相当する設定はありません。ChatGPT のプランを順位付けするには、ここに存在しない全順序が必要になるためです。 + ## ネイティブ Codexの復元 `ocx stop` はプロキシとインストール済みのバックグラウンドサービスを停止し、ネイティブ Codex の復元を試みます。OpenCodex は所有を確認できるルーティング設定を削除し、設定ファイルを安全に復元できない場合は未完了として報告します。 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 12c26d382e..584e77adb0 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -266,6 +266,18 @@ ChatGPT 계정을 추가하거나 재인증할 때 OpenCodex는 일반적으로 메인 계정 갱신이 끝나지 않은 경우에도 재시도로 성공할 수 있으므로 `Retry-After`와 함께 `503`을 반환합니다. 다만 실패가 계속되면 메인 계정을 다시 인증해야 한다는 내용을 메시지에 덧붙였습니다. +### 등급이 내려간 계정을 로테이션에서 빼기 + +`codexPool.excludedPlans`는 자동 풀 선택이 건너뛸 플랜 키 목록입니다. 각 계정에 저장된 플랜과 대소문자를 구분하지 않고 비교합니다. 기본값은 없음이므로 기존 설치의 로테이션은 그대로입니다. + +```bash +ocx config set codexPool '{"excludedPlans":["free"]}' +``` + +차단이 아니라 선택 정책입니다. 제외된 계정도 자격 증명과 사용량 기록, 스레드 어피니티를 그대로 유지하고 계정 목록에도 계속 보이며 `work/gpt-5.4` 같은 명시적 지정으로는 여전히 쓸 수 있습니다. 달라지는 것은 자동 로테이션이 그 계정을 고르지 않는다는 점이고, 이미 활성 계정이거나 스레드에 묶여 있는 경우도 포함합니다. 구독이 만료된 계정이 바로 그 상태입니다. + +의도한 제한이 두 가지 있습니다. 메인 Codex 계정은 플랜으로 제외하지 않습니다. 선택 전용 라우팅은 보호된 네이티브 자격 증명을 읽지 않고 플랜을 감추기 때문에, 메인까지 적용하면 상황에 따라 판정이 어긋납니다. 그리고 제외되지 않은 계정이 하나도 남지 않으면 실패시키지 않고 제외된 계정이 그대로 응답합니다. 완전히 멈추려면 지금처럼 모든 계정을 일시 중지하면 됩니다. `minimumPlan`에 해당하는 설정은 없습니다. ChatGPT 플랜에 순위를 매기려면 여기 존재하지 않는 전순서가 필요합니다. + ## 네이티브 Codex 복원 `ocx stop`은 proxy와 설치된 background service를 중지한 뒤 네이티브 Codex 복원을 시도합니다. OpenCodex 소유로 확인된 라우팅 항목을 제거하며, 설정 파일을 안전하게 복구할 수 없으면 미완료로 보고합니다. diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 44dcc35bff..e695830370 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -382,6 +382,18 @@ v1/base/v2 при делегировании и fallback — в Незавершённое обновление основного аккаунта по-прежнему отвечает `503` с `Retry-After`, потому что повтор может пройти. Теперь сообщение добавляет, что стойкий сбой означает необходимость повторной аутентификации основного аккаунта, а не только очередную попытку. +### Как убрать понижённый аккаунт из ротации + +`codexPool.excludedPlans` перечисляет ключи тарифов, которые автоматический выбор пула пропускает, сравнивая их с тарифом аккаунта без учёта регистра. По умолчанию ключ отсутствует, поэтому существующая установка работает ровно как раньше. + +```bash +ocx config set codexPool '{"excludedPlans":["free"]}' +``` + +Это политика выбора, а не блокировка. Исключённый аккаунт сохраняет учётные данные, историю квот и привязку к треду, остаётся видимым в списке и по-прежнему доступен при явном выборе вроде `work/gpt-5.4`. Меняется только то, что автоматическая ротация перестаёт его выбирать — в том числе когда он уже активен или уже привязан к треду, а именно это состояние остаётся после истёкшей подписки. + +Два намеренных ограничения. Основной аккаунт Codex никогда не исключается по тарифу: маршрутизация в режиме «только выбор» скрывает его тариф, чтобы не читать защищённые нативные учётные данные, и правило для него противоречило бы само себе. А если не осталось ни одного неисключённого аккаунта, исключённый всё равно отвечает вместо отказа; чтобы остановить обслуживание полностью, по-прежнему нужно поставить на паузу все аккаунты. Аналога `minimumPlan` нет: чтобы ранжировать тарифы ChatGPT, нужен полный порядок, которого здесь не существует. + ## Восстановление нативного Codex `ocx stop` останавливает прокси и установленную фоновую службу, затем пытается восстановить нативный Codex. OpenCodex удаляет настройки маршрутизации, принадлежность которых может подтвердить, и сообщает о неполном восстановлении, если файлы конфигурации нельзя безопасно восстановить. diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index fcfa9f299d..23b9b87f2e 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -439,6 +439,18 @@ Bir hesap havuz seçiminden çıktığında neden, görüntüleme için yeniden Tamamlanmayan bir ana hesap yenilemesi, yeniden denemede başarılı olabileceği için hâlâ `Retry-After` ile `503` yanıtı verir. Mesaj artık kalıcı bir başarısızlığın ana hesabın yeniden kimlik doğrulaması gerektirdiğini de belirtiyor. +### Sürümü düşen bir hesabı rotasyondan çıkarma + +`codexPool.excludedPlans`, otomatik havuz seçiminin atladığı plan anahtarlarını listeler ve her hesapta saklanan planla büyük/küçük harf gözetmeden karşılaştırır. Varsayılan olarak yoktur; mevcut bir kurulum tam olarak eskisi gibi rotasyon yapar. + +```bash +ocx config set codexPool '{"excludedPlans":["free"]}' +``` + +Bu bir engelleme değil, seçim politikasıdır. Dışarıda bırakılan hesap kimlik bilgisini, kota geçmişini ve iş parçacığı bağını korur, hesap listesinde görünmeye devam eder ve `work/gpt-5.4` gibi açık bir seçimle hâlâ erişilebilir. Değişen tek şey, otomatik rotasyonun onu artık seçmemesidir; hesap zaten etkin olsa ya da bir iş parçacığına bağlı olsa bile. Süresi dolan bir abonelik tam olarak bu durumu bırakır. + +İki kasıtlı sınır var. Ana Codex hesabı plana göre hiçbir zaman dışarıda bırakılmaz: yalnızca-seçim yönlendirmesi korunan yerel kimlik bilgisini okumamak için planını saklar, dolayısıyla ana hesabı kapsayan bir kural kendisiyle çelişirdi. Ayrıca dışarıda bırakılmamış hiçbir hesap kalmadığında, dışarıda bırakılan hesap başarısız olmak yerine yine yanıt verir; hizmeti tamamen durdurmak için hâlâ tüm hesapları duraklatmak gerekir. `minimumPlan` karşılığı yoktur, çünkü ChatGPT planlarını sıralamak burada bulunmayan bir tam sıralama gerektirir. + ## Yerel Codex'i geri yükleme `ocx stop`, proxy'yi ve kurulu arka plan servisini durdurur, ardından yerel Codex'i geri yüklemeyi dener. OpenCodex yalnızca sahipliğini doğrulayabildiği yönlendirme öğelerini kaldırır; yapılandırma dosyaları güvenle geri yüklenemiyorsa işlemin tamamlanmadığını bildirir. diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 92a3e94368..71d2b81473 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -326,6 +326,18 @@ fallback 行为,参见 [Sub-agent Surface](/guides/sub-agent-surface/)。 主账号刷新未完成时仍返回带 `Retry-After` 的 `503`,因为重试仍可能成功。消息中现在补充说明:若持续失败,则主账号需要重新认证,而不只是再试一次。 +### 让降级的账号退出轮换 + +`codexPool.excludedPlans` 列出自动账号池选择要跳过的套餐键,与每个账号上保存的套餐不区分大小写比对。默认不存在,因此现有安装的轮换完全不变。 + +```bash +ocx config set codexPool '{"excludedPlans":["free"]}' +``` + +这是选择策略,不是封禁。被排除的账号保留凭据、用量历史和线程亲和性,仍显示在账号列表中,也仍可通过 `work/gpt-5.4` 这类显式选择使用。改变的只是自动轮换不再选它,包括它已经是活跃账号或已绑定线程的情况——订阅到期后留下的正是这种状态。 + +有两处刻意的限制。主 Codex 账号不会因套餐被排除:仅选择模式的路由不读取受保护的原生凭据而隐去其套餐,覆盖主账号的规则会自相矛盾。另外,当没有未被排除的账号时,被排除的账号仍会应答而不是失败;要彻底停止服务,仍然是暂停全部账号。没有对应的 `minimumPlan`,因为给 ChatGPT 套餐排序需要一个这里并不存在的全序。 + ## 恢复原生 Codex `ocx stop` 会停止 proxy 和已安装的后台服务,然后尝试恢复原生 Codex。OpenCodex 只移除能够确认归属的路由配置;如果无法安全恢复配置文件,会报告恢复未完成。 diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index 4da96fe7f1..c9f8090987 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -333,6 +333,18 @@ ocx service install # 常駐:登入時自動啟動,崩潰後自動重新 主帳號更新未完成時仍回傳帶 `Retry-After` 的 `503`,因為重試仍可能成功。訊息現在補充說明:若持續失敗,代表主帳號需要重新認證,而不只是再試一次。 +### 讓降級的帳號退出輪換 + +`codexPool.excludedPlans` 列出自動帳號池選擇要略過的方案鍵,與每個帳號上儲存的方案不分大小寫比對。預設不存在,因此既有安裝的輪換完全不變。 + +```bash +ocx config set codexPool '{"excludedPlans":["free"]}' +``` + +這是選擇策略,不是封鎖。被排除的帳號保留憑證、用量紀錄與執行緒親和性,仍顯示在帳號清單中,也仍可透過 `work/gpt-5.4` 這類明確選擇使用。改變的只是自動輪換不再挑它,包括它已經是使用中帳號或已綁定執行緒的情況——訂閱到期後留下的正是這種狀態。 + +有兩處刻意的限制。主 Codex 帳號不會因方案被排除:僅選擇模式的路由不讀取受保護的原生憑證而隱去其方案,涵蓋主帳號的規則會自相矛盾。此外,當沒有未被排除的帳號時,被排除的帳號仍會回應而不是失敗;要完全停止服務,仍然是暫停所有帳號。沒有對應的 `minimumPlan`,因為為 ChatGPT 方案排序需要一個這裡並不存在的全序。 + ## 恢復原生 Codex `ocx stop` 會停止 proxy 與已安裝的背景服務,然後嘗試恢復原生 Codex。OpenCodex 只移除能確認歸屬的路由設定;若無法安全恢復設定檔,會回報恢復未完成。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2ab3271e2b..18bb3beefa 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -455,6 +455,7 @@ "codex-native-residue.test.ts": "codex-integration", "codex-plan.test.ts": "codex-integration", "codex-plugins-doctor.test.ts": "codex-integration", + "codex-pool-plan-exclusion.test.ts": "codex-integration", "codex-pool-rotation.test.ts": "codex-integration", "codex-prompt-adopt.test.ts": "codex-integration", "codex-prompt-base-variants.test.ts": "codex-integration", diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 5d8cc17d15..a1f1f4fbd9 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -19,7 +19,7 @@ import { selectPriorityTier, } from "./pool-rotation"; import { CODEX_EXHAUSTED_USAGE_PERCENT, CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; -import { isThirtyDayOnlyCodexPlan } from "./plan"; +import { codexPlanKey, isThirtyDayOnlyCodexPlan } from "./plan"; import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan, @@ -1004,6 +1004,50 @@ export function isCodexAccountSoftAvoided(accountId: string, now = Date.now()): return getCodexAccountSoftAvoidUntil(accountId, now) !== null; } +/** + * Plan keys the operator excluded from automatic rotation. Absent or empty means no policy, so an + * existing install rotates exactly as before. Compared with `codexPlanKey` because the stored plan + * is an unrestricted provider string whose casing this repository does not control. + */ +function excludedCodexPoolPlanKeys(config: OcxConfig): ReadonlySet | undefined { + const configured = config.codexPool?.excludedPlans; + if (!configured?.length) return undefined; + const keys = configured + .map(plan => codexPlanKey(plan)) + .filter((key): key is string => key !== undefined); + return keys.length > 0 ? new Set(keys) : undefined; +} + +/** + * Whether the operator's plan policy removes this account from automatic selection. + * + * Modelled on pause rather than usability: an excluded account keeps its credential, quota history, + * and affinity, stays visible on the account surface, and is still reachable by explicit account + * selection. Only automatic rotation skips it, which is the distinction #4211 asked for. + * + * It is checked in the same two places pause is checked, and that is not redundancy. The eligible + * list is consulted only when routing picks a NEW account; an already-active or already-affined + * account is served straight from {@link isCodexAccountSelectable}. A lapsed subscription leaves + * behind exactly that account, so a policy that filtered only the eligible list would miss the case + * it exists for. + * + * `__main__` is exempt. {@link getPoolAccountPlanForSelection} withholds the main plan during a + * selection-only drain so routing never reads the fenced native credential for it, so a rule that + * covered main would disagree with itself between drain and ordinary routing. + */ +function isCodexAccountPlanExcluded( + config: OcxConfig, + accountId: string, + precomputed?: ReadonlySet, +): boolean { + if (accountId === MAIN_CODEX_ACCOUNT_ID) return false; + // Callers that test a whole list pass the set once rather than rebuilding it per row. + const excluded = precomputed ?? excludedCodexPoolPlanKeys(config); + if (!excluded) return false; + const plan = codexPlanKey(getPoolAccountPlan(config, accountId)); + return plan !== undefined && excluded.has(plan); +} + function isCodexAccountSelectable( config: OcxConfig, accountId: string, @@ -1012,6 +1056,7 @@ function isCodexAccountSelectable( selectionOptions?: CodexAccountUsabilityOptions, ): boolean { return !isCodexAccountPaused(config, accountId) + && !isCodexAccountPlanExcluded(config, accountId) && getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null && !isCodexAccountSoftAvoided(accountId, now) && isCodexAccountUsable(config, accountId, selectionOptions); @@ -1242,10 +1287,12 @@ function getEligiblePoolAccounts( selectionOptions?: CodexAccountUsabilityOptions, skipFailoverReadyCandidates = false, ): readonly string[] { + const excludedPlans = excludedCodexPoolPlanKeys(config); const ids = (config.codexAccounts ?? []) .filter(account => isSelectableCodexPoolAccount(account) && account.id !== excludeId && !isCodexAccountPaused(config, account.id) + && !isCodexAccountPlanExcluded(config, account.id, excludedPlans) && !isAccountNeedsReauth(account.id) && (!skipFailoverReadyCandidates || !shouldFailover(config, account.id, now))) .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null) diff --git a/src/config.ts b/src/config.ts index 4311e54eef..bda3b8a8ae 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1111,6 +1111,17 @@ const clientConnectionSchema = z.object({ }).optional(), }).strict(); +/** + * Codex pool selection policy section. + * + * `.strict()` like its neighbour: a typo in an optional feature section should surface as a + * rejected write rather than a silently ignored key that leaves the operator believing they + * excluded something. + */ +const codexPoolSchema = z.object({ + excludedPlans: z.array(z.string().trim().min(1)).optional(), +}).strict(); + /** * Quota-reset notification section. * @@ -1249,6 +1260,10 @@ const configSchema = z.object({ codexDesktopAuthless: z.boolean().optional().catch(undefined), codexClientCompaction: z.boolean().optional().catch(undefined), pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(), + // A malformed policy degrades to "no policy" rather than failing the parse, so a hand-edited + // typo cannot trip the backup-and-defaults repair path and wipe providers or pool accounts. + // Silently ignoring it would be its own trap, so the write path rejects it and loadConfig warns. + codexPool: codexPoolSchema.optional().catch(undefined), codexQuotaAutoRefresh: codexQuotaAutoRefreshSchema.optional().catch(undefined), codexAccountNamespaces: codexAccountNamespacesSchema.optional(), // Selection order is a preference, not a safety control like pause: a malformed @@ -2160,6 +2175,20 @@ function malformedQuotaResetNotifyWarning(rawParsed: unknown): string | null { return `quotaResetNotify${field ? `.${field}` : ""} ignored: invalid quota-reset notification configuration`; } +/** + * Same silent-in-the-wrong-direction failure as the notification block: a dropped pool policy means + * the accounts the operator meant to exclude keep taking traffic, and the only visible symptom is + * traffic going somewhere it was supposed to stop going. + */ +function malformedCodexPoolWarning(rawParsed: unknown): string | null { + const raw = rawConfigRecord(rawParsed); + if (!raw || !Object.hasOwn(raw, "codexPool")) return null; + const result = codexPoolSchema.safeParse(raw.codexPool); + if (result.success) return null; + const field = result.error.issues[0]?.path.join("."); + return `codexPool${field ? `.${field}` : ""} ignored: invalid Codex pool selection policy`; +} + /** * Warn once per load that the section was dropped. * @@ -2172,6 +2201,18 @@ function warnDegradedQuotaResetNotify(rawParsed: unknown): void { if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); } +/** + * Warn once per load that the pool policy was dropped. + * + * `.catch(undefined)` turns a malformed policy into a SUCCESSFUL parse, so without this the proxy + * starts, rotates onto the accounts the operator meant to exclude, and prints nothing. The visible + * symptom would be traffic going exactly where it was told not to go. + */ +function warnDegradedCodexPool(rawParsed: unknown): void { + const warning = malformedCodexPoolWarning(rawParsed); + if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); +} + type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults"; function rawConfigRecord(rawParsed: unknown): Record | null { @@ -2331,6 +2372,7 @@ export function loadConfig(): OcxConfig { warnDegradedRuntimeRole(parsed); warnDegradedOptionalRemoteBlocks(parsed); warnDegradedQuotaResetNotify(parsed); + warnDegradedCodexPool(parsed); return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } // Schema validation failed — merge defaults into the raw object instead of @@ -2359,6 +2401,7 @@ export function loadConfig(): OcxConfig { warnDegradedRuntimeRole(parsed); warnDegradedOptionalRemoteBlocks(parsed); warnDegradedQuotaResetNotify(parsed); + warnDegradedCodexPool(parsed); return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } // Still failing, but if every complaint is about one or more named entries @@ -2383,6 +2426,7 @@ export function loadConfig(): OcxConfig { warnDegradedRuntimeRole(parsed); warnDegradedOptionalRemoteBlocks(parsed); warnDegradedQuotaResetNotify(parsed); + warnDegradedCodexPool(parsed); return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } } @@ -2527,6 +2571,8 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf if (clientWarning) warnings.push(clientWarning); const notifyWarning = malformedQuotaResetNotifyWarning(rawParsed); if (notifyWarning) warnings.push(notifyWarning); + const codexPoolWarning = malformedCodexPoolWarning(rawParsed); + if (codexPoolWarning) warnings.push(codexPoolWarning); if (syncDisabledReason) { warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`); } @@ -2675,6 +2721,21 @@ function quotaResetNotifyError(value: unknown): string | null { return `schema_invalid: quotaResetNotify${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`; } +/** + * The read path degrades a malformed pool policy to undefined, which for an exclusion policy means + * the excluded accounts quietly keep serving traffic. Reject it on write so `ocx config set` cannot + * create a policy that looks applied and is not. + */ +function codexPoolError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "codexPool") || raw.codexPool === undefined) return null; + const result = codexPoolSchema.safeParse(raw.codexPool); + if (result.success) return null; + const issue = result.error.issues[0]; + const field = issue?.path.join("."); + return `schema_invalid: codexPool${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`; +} + /** * Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a * malformed selection-order map to undefined, which on a write would drop every entry the @@ -2850,6 +2911,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? upstreamHostCircuitThresholdError(value) ?? agentTaskRecoveryError(value) ?? quotaResetNotifyError(value) + ?? codexPoolError(value) ?? googleAntigravityStaticCatalogVersionError(value) ?? codexAccountPrioritiesError(value) ?? codexQuotaAutoRefreshError(value) diff --git a/src/types/config.ts b/src/types/config.ts index f719e30161..3e5ac97a69 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -746,6 +746,14 @@ export interface OcxConfig { codexAccounts?: CodexAccount[]; /** Account ids administratively excluded from future pool selection until resumed. */ pausedCodexAccountIds?: string[]; + /** + * Codex pool selection policy. Absent means no policy, so an existing install rotates exactly + * as before. + * + * Not in `getDefaultConfig()` on purpose — that function carries no optional-feature keys, so + * absence is the only default state this policy has. + */ + codexPool?: OcxCodexPoolConfig; /** Opt-in per-account activation of newly reset Codex quota windows. */ codexQuotaAutoRefresh?: Record = {}): OcxConfig { + return { + providers: {}, + codexAccounts: [ + { id: "downgraded", email: "downgraded@test", isMain: false, plan: "free" }, + { id: "paid", email: "paid@test", isMain: false, plan: "plus" }, + ], + activeCodexAccountId: "downgraded", + autoSwitchThreshold: 80, + upstreamFailoverThreshold: 3, + ...overrides, + } as OcxConfig; +} + +function saveTestCredential(id: string): void { + saveCodexAccountCredential(id, { + accessToken: `access-${id}`, + refreshToken: `refresh-${id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `acct-${id}`, + }); +} + +/** + * A `free` plan is thirty-day-only, so its usage score reads the monthly window while `plus` + * reads the weekly one. Recording both windows keeps these cases about the plan policy instead of + * about which account happened to have an observed window. + */ +function recordUsage(id: string, percent: number): void { + updateAccountQuota(id, percent, undefined, percent); +} + +describe("codex pool plan exclusion", () => { + beforeEach(() => { + previousOpencodexHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-plan-exclusion-")); + setIcaclsRunnerForTests(() => ICACLS_OK); + setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); + process.env.OPENCODEX_HOME = testDir; + process.env.CODEX_HOME = testDir; + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + clearPoolRotationState(); + for (const id of ACCOUNT_IDS) clearAccountNeedsReauth(id); + for (const id of ACCOUNT_IDS) saveTestCredential(id); + }); + + afterEach(async () => { + const owned = testDir; + testDir = ""; + try { + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); + for (const id of ACCOUNT_IDS) clearAccountNeedsReauth(id); + await flushConfigDirHardeningForTests(); + } finally { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (owned) removeTreeWithRetry(owned); + } + }); + + test("no policy leaves rotation exactly as it was", () => { + const config = makeConfig(); + recordUsage("downgraded", 10); + recordUsage("paid", 20); + expect(pickLowestUsageCodexAccount(config)).toBe("downgraded"); + expect(resolveCodexAccountForThread("no-policy", config)).toBe("downgraded"); + }); + + test("an empty exclusion list is not a policy", () => { + const config = makeConfig({ codexPool: { excludedPlans: [] } }); + recordUsage("downgraded", 10); + recordUsage("paid", 20); + expect(pickLowestUsageCodexAccount(config)).toBe("downgraded"); + }); + + test("an excluded plan is skipped when routing picks a new account", () => { + const config = makeConfig({ codexPool: { excludedPlans: ["free"] } }); + recordUsage("downgraded", 10); + recordUsage("paid", 20); + // Lower usage would otherwise win outright. + expect(pickLowestUsageCodexAccount(config)).toBe("paid"); + }); + + test("an account already serving a thread stops serving it once its plan is excluded", () => { + // The reported case: the account was paid, took traffic, and was then downgraded. It is both + // the active account and the affinity target, so the eligible list alone never sees it. + const config = makeConfig(); + recordUsage("downgraded", 10); + recordUsage("paid", 20); + expect(resolveCodexAccountForThread("lapsed-subscription", config)).toBe("downgraded"); + + config.codexPool = { excludedPlans: ["free"] }; + + expect(resolveCodexAccountForThread("lapsed-subscription", config)).toBe("paid"); + expect(previewCodexAccountForRequest("lapsed-subscription", config)).toBe("paid"); + }); + + test("plan matching ignores casing and surrounding whitespace on both sides", () => { + const config = makeConfig({ + codexAccounts: [ + { id: "downgraded", email: "downgraded@test", isMain: false, plan: " Free " }, + { id: "paid", email: "paid@test", isMain: false, plan: "plus" }, + ], + codexPool: { excludedPlans: ["FREE"] }, + } as Partial); + recordUsage("downgraded", 10); + recordUsage("paid", 20); + expect(pickLowestUsageCodexAccount(config)).toBe("paid"); + }); + + test("an account with no recorded plan is never excluded by a plan policy", () => { + const config = makeConfig({ + codexAccounts: [ + { id: "downgraded", email: "downgraded@test", isMain: false }, + { id: "paid", email: "paid@test", isMain: false, plan: "plus" }, + ], + codexPool: { excludedPlans: ["free"] }, + } as Partial); + recordUsage("downgraded", 10); + recordUsage("paid", 20); + expect(pickLowestUsageCodexAccount(config)).toBe("downgraded"); + }); + + test("a plan nobody holds excludes nobody", () => { + const config = makeConfig({ codexPool: { excludedPlans: ["go"] } }); + recordUsage("downgraded", 10); + recordUsage("paid", 20); + expect(pickLowestUsageCodexAccount(config)).toBe("downgraded"); + }); + + test("the last remaining account still serves rather than stranding the operator", () => { + // Deliberately unlike pause. #4211 asks for a selection policy, not a hard block, so with no + // unexcluded candidate left the excluded account keeps answering instead of failing closed. + const config = makeConfig({ + codexAccounts: [{ id: "downgraded", email: "downgraded@test", isMain: false, plan: "free" }], + codexPool: { excludedPlans: ["free"] }, + } as Partial); + recordUsage("downgraded", 10); + expect(pickLowestUsageCodexAccount(config)).toBeNull(); + expect(resolveCodexAccountForThread("last-account", config)).toBe("downgraded"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2c2c04c2e4..cf967a487b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -290,6 +290,7 @@ "codex-native-residue.test.ts": "codex-integration", "codex-plan.test.ts": "codex-integration", "codex-plugins-doctor.test.ts": "codex-integration", + "codex-pool-plan-exclusion.test.ts": "codex-integration", "codex-pool-rotation.test.ts": "codex-integration", "codex-prompt-adopt.test.ts": "codex-integration", "codex-prompt-base-variants.test.ts": "codex-integration", From 46e2a3774c4242e7a190523669b95b334305982f Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:22:56 +0900 Subject: [PATCH 2/2] docs(devlog): record the L3 WP3 plan-policy unit and its packet departure --- .../030_wp3_4211_plan_exclusion.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 devlog/_plan/260911_l3_account_pool/030_wp3_4211_plan_exclusion.md diff --git a/devlog/_plan/260911_l3_account_pool/030_wp3_4211_plan_exclusion.md b/devlog/_plan/260911_l3_account_pool/030_wp3_4211_plan_exclusion.md new file mode 100644 index 0000000000..f40e5d4d0f --- /dev/null +++ b/devlog/_plan/260911_l3_account_pool/030_wp3_4211_plan_exclusion.md @@ -0,0 +1,79 @@ +# WP3 — #4211 keep Free-tier ChatGPT accounts out of Codex pool selection + +## Where this departs from the packet, and why + +The packet's recorded decision was to filter in `getEligiblePoolAccounts` at `routing.ts:1248` +"rather than in `isCodexAccountUsable`". That choice is right and was kept. But the feasibility +audit that produced it did not consider a third site, and shipping only the recorded one would have +left the feature inert for the exact case the issue reports. + +A read-only subagent traced it end to end. `getEligiblePoolAccounts` is the choke point for +picking a **new** account. An account that is already the active account, or already bound to a +thread by affinity, is served straight out of `isCodexAccountSelectable`, which builds its own +predicate and never consults the eligible list. Both paths return the account without the eligible +list being built at all — affinity reuse at `routing.ts:2126`, keep-active at `:2198` — and +priority preemption cannot rescue it because every account defaults to priority 0, so +`priorityOf(eligible[0]) <= priorityOf(active)` and it returns null. + +The reporter's account is precisely that account: it was paid, it was taking traffic, and then the +subscription lapsed. Filtering only the eligible list would have shipped a config key that reads +correctly and changes nothing for them. + +So the filter goes where pause already goes. Pause is checked in **both** places — +`isCodexAccountSelectable` at `:1014` and the `getEligiblePoolAccounts` pool-row chain at `:1248` +— and pause is the mechanism the issue itself names as today's manual workaround. Copying its two +insertion points is the smallest change that makes the policy true. + +## Decisions this lane had to make + +**The main account is exempt.** `getPoolAccountPlanForSelection` withholds the main plan during a +selection-only drain so routing never reads the fenced native credential for it. A rule covering +main would therefore exclude it under ordinary routing and not under drain — the same account, +two answers. Exempting it keeps the two consistent and avoids introducing a drain-time credential +read. Confirmed by audit: `isCodexAccountPlanExcluded` returns at the `__main__` check before any +plan lookup, so no new native read exists on any routing path. + +**The last remaining account still serves.** Pause fails closed: all-paused returns `{status: +"none"}`. Plan exclusion deliberately does not copy that leftover. #4211 asks for "a selection +policy, not a hard block" and for an explicit route to keep working, and stranding an operator +whose remaining accounts are all excluded is a worse outcome than serving one downgraded request. +Pausing every account remains the way to stop serving entirely. Pinned by a test. + +**No `minimumPlan`.** Per the packet: ranking plans needs an ordering this repository does not +have. + +**Malformed policy degrades rather than failing the parse**, matching `quotaResetNotify`, because a +hand-edited typo must not trip the backup-and-defaults repair path and wipe providers or pool +accounts. The write path rejects it and `loadConfig` warns on all three success paths, so it cannot +degrade silently. + +## Out of scope + +`docs-site/src/content/docs/reference/configuration/providers.md` carries the field table where +`pausedCodexAccountIds` and `codexAccountPriorities` are listed, and `codexPool.excludedPlans` +belongs beside them. That file is not in the L3 owned list, so this lane documented the key in the +Codex integration guide it does own and reports the reference-table row as a follow-up. + +## Audit + +Four read-only `xai/grok-4.6` subagents. Two returned **fail** and both were folded in rather than +argued with. + +- **Routing (fail → pass).** Four of the eight tests would have failed. Three because a `free` plan + is thirty-day-only and scores on the monthly window, while the fixture recorded weekly only, so + the account scored `CODEX_UNKNOWN_USAGE_SCORE` and lost the ranking even with no policy — the + "no policy changes nothing" tests would have passed for the wrong reason and then failed. One + because `previewCodexAccountForRequest` takes `(threadId, config)` and was called with one + argument. Both fixed; the per-row `Set` rebuild it also flagged was hoisted. +- **Config (fail → fixed).** The schema comment claimed `loadConfig` warns, and it did not: + the warning had only been wired into the diagnostics array that `ocx config show --source` + prints, not into the `warnDegraded*` helpers the proxy calls at start. Since `.catch(undefined)` + makes a malformed policy a *successful* parse, that gap meant the proxy would start, rotate onto + the accounts the operator meant to exclude, and print nothing. +- **Re-audit (pass).** All eight tests predicted to pass; both gates match pause; `__main__` exempt + before any plan read; an absent `codexPool` is a total no-op. + +## Verification + +Local suite, typecheck, and build: NOT RUN by operator instruction. Hosted CI on the pushed head is +the evidence.