From 57a013155897f4cfd317c8417eaac58f1705590f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:57:42 +0900 Subject: [PATCH 1/8] fix(codex): enforce and explain automatic plan exclusions --- .../_plan/260912_accounts/020_eligibility.md | 21 ++++++++++++++++++ .../021_eligibility_delivery.md | 9 ++++++++ .../docs/fr/guides/codex-integration.md | 3 +-- .../content/docs/guides/codex-integration.md | 3 +-- .../docs/ja/guides/codex-integration.md | 3 +-- .../docs/ko/guides/codex-integration.md | 3 +-- .../docs/ru/guides/codex-integration.md | 3 +-- .../docs/tr/guides/codex-integration.md | 3 +-- .../docs/zh-cn/guides/codex-integration.md | 3 +-- .../docs/zh-tw/guides/codex-integration.md | 3 +-- .../components/codex-account-pool-cards.tsx | 10 +++++++-- gui/src/hooks/useCodexAccountPool.ts | 2 ++ gui/src/i18n/de.ts | 2 ++ gui/src/i18n/en.ts | 2 ++ gui/src/i18n/fr.ts | 2 ++ gui/src/i18n/ja.ts | 2 ++ gui/src/i18n/ko.ts | 2 ++ gui/src/i18n/ru.ts | 2 ++ gui/src/i18n/tr.ts | 2 ++ gui/src/i18n/zh-TW.ts | 2 ++ gui/src/i18n/zh.ts | 2 ++ .../codex-account-pool-pinned-badge.test.tsx | 19 ++++++++++++++++ src/cli/account-api.ts | 8 +++++++ src/cli/account.ts | 3 +++ src/codex/auth-api.ts | 11 ++++++++++ src/codex/routing.ts | 4 +++- structure/catalog.md | 2 ++ structure/clients/claude-desktop.md | 2 ++ structure/codex-home.md | 2 ++ structure/config.md | 2 ++ structure/design-methodology.md | 2 ++ structure/gui-and-management-api.md | 2 ++ structure/ops/docs-and-release.md | 2 ++ structure/overview.md | 2 ++ structure/providers/openai-tiers.md | 6 +++++ structure/runtime.md | 2 ++ structure/subagents.md | 2 ++ tests/cli/cli-account.test.ts | 12 ++++++++++ .../codex-integration/codex-auth-api.test.ts | 16 ++++++++++++++ .../codex-auth-context.test.ts | 22 +++++++++++++++++++ .../codex-pool-plan-exclusion.test.ts | 18 +++++++++++---- 41 files changed, 200 insertions(+), 23 deletions(-) create mode 100644 devlog/_plan/260912_accounts/020_eligibility.md create mode 100644 devlog/_plan/260912_accounts/021_eligibility_delivery.md diff --git a/devlog/_plan/260912_accounts/020_eligibility.md b/devlog/_plan/260912_accounts/020_eligibility.md new file mode 100644 index 0000000000..734bc42d89 --- /dev/null +++ b/devlog/_plan/260912_accounts/020_eligibility.md @@ -0,0 +1,21 @@ +# Finish automatic plan policy and visible exclusion reasons + +Cycle eligibility; C3 selection policy. Depends only on roadmap, independent dev PR. #4238 already added excludedPlans; do not reimplement its selector. Source: routing.ts:1044-1090 and 1326; explicit fixedAccountId path auth-context.ts:826/915. + +MODIFY `src/codex/routing.ts`: export the existing normalized policy predicate (or move the pure plan calculation into `src/codex/plan.ts` and reuse it). Add the predicate to BOTH configured-account fallback guards at preview :2152 and detailed resolve :2391. Before, an all-excluded pool returns its excluded active row; after, ordinary selection returns null/none. Explicit fixed routes retain existing auth, pause, entitlement checks. Native __main__ remains exempt, avoiding physical auth reads on selection-only paths. + +```diff +- && !isCodexAccountPaused(config, active) ++ && !isCodexAccountPaused(config, active) ++ && !isCodexAccountPlanExcluded(config, active) +``` + +MODIFY `src/codex/auth-api.ts`: poolAccountDto adds optional `selectionExcludedReason: "plan_excluded"`, derived from the SAME predicate and config, never from credential health; include current plan already in DTO. MODIFY `src/cli/account-api.ts` AccountRow/CodexAccountDto mapping and `src/cli/account.ts` statusText to show `not-auto-selected(plan=)`. MODIFY `gui/src/components/codex-account-pool-types.ts`, pool-card badge in `codex-account-pool-cards.tsx`, and all locale catalogs: separate localized reason; do not mutate paused/needsReauth and do not disable explicit routing. Unknown plan and empty policy remain eligible; reauth renewal clears the reason dynamically. + +Field chain: existing excludedPlans config create/save/load → same normalized predicate → account DTO JSON → CLI/GUI optional union → status and badge. No new config field or minimumPlan ordering. Enforcing tier: runtime automatic selection only; explicit fixed account intentionally bypasses this selection rule, not auth; residual unknown-plan and native-main exemptions documented, no hard account-block claim. + +MODIFY existing `tests/codex-integration/codex-pool-plan-exclusion.test.ts`: replace last-account soft fallback test with none/preview none; test normalized plan update and explicit fixed route. Extend account API/CLI and card tests for reason and renewal clearing. Sync ownership docs and providers configuration pages that describe the old soft exception. Retain source attribution of #4238; no recarry of already-landed commits. Local tests/build/typecheck NOT RUN. Hosted CI plus rendered artifact from final tip supplies execution proof. + +Exclusion reason derives from the routing config plan, not a display-only freshly observed plan if persistence failed. This preserves truth between selection and explanation. + +P revalidation on dev d6fb87197a: keep exported existing predicate in routing.ts; pass runtimeConfig into both poolAccountDto calls. Alongside closed selectionExcludedReason include selectionExcludedPlan from the same routing config when excluded, so a display-only fresh WHAM tier cannot mislabel the reason. CLI/card render this policy plan. Exact GUI type owner is hooks/useCodexAccountPool.ts; component type file re-exports it. Docs source is guides/codex-integration.md in every locale; revise all-excluded fallback paragraphs there. Callback D delivered PR4352 and left hosted acceptance open; this cycle is independent from current dev. diff --git a/devlog/_plan/260912_accounts/021_eligibility_delivery.md b/devlog/_plan/260912_accounts/021_eligibility_delivery.md new file mode 100644 index 0000000000..ed4405ac17 --- /dev/null +++ b/devlog/_plan/260912_accounts/021_eligibility_delivery.md @@ -0,0 +1,9 @@ +# Plan exclusion completion + +Built on already-landed #4238, independently from current dev d6fb87197a. Existing normalized predicate is shared with the account DTO; both preview and real automatic fallback reject excluded plans when no eligible account remains. Explicit account-qualified routes retain normal auth, pause and entitlement checks. Native main remains exempt. + +CLI and dashboard display the policy's routing-plan reason separately from credential health and a possibly newer display-only plan. The automatic Set-as-next action is suppressed for excluded rows because pinning does not bypass this policy; explicit account-qualified routes remain available. All nine UI locale catalogs and eight affected integration guides are synchronized. Source ownership docs link the canonical plan-exclusion contract. + +Regression sources cover all-excluded preview/resolve, renewal, explicit route with pause/reauth, API reasons, CLI normalization and card display/renewal. No new test file or dependency. Local suites/build/typecheck/install: NOT RUN. Hosted CI and rendered preview remain pending. Source searches: isCodexAccountPlanExcluded, getPoolAccountPlan, poolAccountDto, CodexAccountEntry, selection guards and excludedPlans docs; reused the existing predicate rather than a parallel policy. + +Prior callback cycle delivered PR4352 and remains pending hosted verification. This is an independent dev PR, with no callback code and no manual chain dependency. 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 fa2457395d..44c98858bd 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -399,8 +399,7 @@ 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.5`. 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. - +Le compte Codex principal reste exempt de l’exclusion par forfait : le routage en mode sélection seule ne lit pas ses identifiants natifs protégés. Si tous les comptes éligibles du pool sont exclus, la sélection automatique ne renvoie aucun compte. Les routes désignant explicitement un compte restent disponibles, avec les contrôles de pause, d’authentification et de droits du modèle. La carte et le CLI affichent le forfait exclu séparément de l’état des identifiants. Il n’existe pas de réglage `minimumPlan`, faute d’ordre total des forfaits. ## 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 ffc383ac8c..6a52f5b584 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -730,8 +730,7 @@ 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.5`. 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. - +The main Codex account remains exempt from plan exclusion; selection-only routing does not read its fenced native credential. If every eligible pool account is excluded, automatic selection returns no account. Explicit account-qualified routes remain available and still enforce pause, authentication and model entitlement. The account card and CLI show the excluded routing plan separately from credential health. There is no `minimumPlan` setting because the plan names do not define a total order. ## 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 b1a499dcbc..31a90c96ff 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -265,8 +265,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' これはブロックではなく選択ポリシーです。除外されたアカウントも資格情報・使用量履歴・スレッドアフィニティを保持し、アカウント一覧に表示され、`work/gpt-5.5` のような明示的な指定では引き続き利用できます。変わるのは自動ローテーションが選ばなくなる点で、すでにアクティブなアカウントやスレッドに紐づいている場合も含みます。サブスクリプションが失効した直後は、まさにその状態です。 -意図的な制限が2つあります。メインの Codex アカウントはプランによって除外されません。選択のみのルーティングは保護されたネイティブ資格情報を読まずにプランを伏せるため、メインを対象にすると挙動が食い違うからです。また、除外されていないアカウントが1つも残らない場合は、失敗させずに除外済みのアカウントが応答します。完全に停止したい場合は従来どおり全アカウントを一時停止してください。`minimumPlan` に相当する設定はありません。ChatGPT のプランを順位付けするには、ここに存在しない全順序が必要になるためです。 - +メイン Codex アカウントはプラン除外の対象外です。選択のみのルーティングは保護されたネイティブ資格情報を読みません。利用可能なプールアカウントがすべて除外されると、自動選択はアカウントを返しません。アカウントを明示したルートは引き続き利用でき、一時停止・認証・モデル権限の検査は維持されます。カードと CLI は資格情報の状態とは別に、除外されたルーティングプランを表示します。プランに全順序がないため `minimumPlan` 設定はありません。 ## ネイティブ 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 0dc6021bd6..604a6c1276 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -276,8 +276,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' 차단이 아니라 선택 정책입니다. 제외된 계정도 자격 증명과 사용량 기록, 스레드 어피니티를 그대로 유지하고 계정 목록에도 계속 보이며 `work/gpt-5.5` 같은 명시적 지정으로는 여전히 쓸 수 있습니다. 달라지는 것은 자동 로테이션이 그 계정을 고르지 않는다는 점이고, 이미 활성 계정이거나 스레드에 묶여 있는 경우도 포함합니다. 구독이 만료된 계정이 바로 그 상태입니다. -의도한 제한이 두 가지 있습니다. 메인 Codex 계정은 플랜으로 제외하지 않습니다. 선택 전용 라우팅은 보호된 네이티브 자격 증명을 읽지 않고 플랜을 감추기 때문에, 메인까지 적용하면 상황에 따라 판정이 어긋납니다. 그리고 제외되지 않은 계정이 하나도 남지 않으면 실패시키지 않고 제외된 계정이 그대로 응답합니다. 완전히 멈추려면 지금처럼 모든 계정을 일시 중지하면 됩니다. `minimumPlan`에 해당하는 설정은 없습니다. ChatGPT 플랜에 순위를 매기려면 여기 존재하지 않는 전순서가 필요합니다. - +메인 Codex 계정에는 플랜 제외 정책을 적용하지 않습니다. 선택 전용 라우팅은 보호된 네이티브 자격 증명을 읽지 않습니다. 풀의 모든 사용 가능한 계정이 제외되면 자동으로 계정을 선택하지 않습니다. 계정을 직접 지정한 경로는 계속 사용할 수 있으며 일시 중지·인증·모델 사용 권한 검사는 그대로 적용됩니다. 계정 카드와 CLI에는 자격 증명 상태와 별도로 제외된 플랜이 표시됩니다. 플랜 사이에 정해진 순위가 없으므로 `minimumPlan` 설정은 없습니다. ## 네이티브 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 4e5acb9ee6..8bed86f84c 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -392,8 +392,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' Это политика выбора, а не блокировка. Исключённый аккаунт сохраняет учётные данные, историю квот и привязку к треду, остаётся видимым в списке и по-прежнему доступен при явном выборе вроде `work/gpt-5.5`. Меняется только то, что автоматическая ротация перестаёт его выбирать — в том числе когда он уже активен или уже привязан к треду, а именно это состояние остаётся после истёкшей подписки. -Два намеренных ограничения. Основной аккаунт Codex никогда не исключается по тарифу: маршрутизация в режиме «только выбор» скрывает его тариф, чтобы не читать защищённые нативные учётные данные, и правило для него противоречило бы само себе. А если не осталось ни одного неисключённого аккаунта, исключённый всё равно отвечает вместо отказа; чтобы остановить обслуживание полностью, по-прежнему нужно поставить на паузу все аккаунты. Аналога `minimumPlan` нет: чтобы ранжировать тарифы ChatGPT, нужен полный порядок, которого здесь не существует. - +Основной аккаунт Codex не исключается по тарифу: маршрутизация только для выбора не читает защищённые нативные учётные данные. Если все доступные аккаунты пула исключены, автоматический выбор не возвращает аккаунт. Явные маршруты к аккаунту доступны, но проверки паузы, аутентификации и прав на модель сохраняются. Карточка и CLI показывают исключённый тариф отдельно от состояния учётных данных. Настройки `minimumPlan` нет, поскольку тарифы не имеют полного порядка. ## Восстановление нативного 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 09493150a4..e307dd002c 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -449,8 +449,7 @@ 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.5` 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. - +Ana Codex hesabı plan hariç tutma politikasından muaftır; yalnızca seçim yapan yönlendirme korunan yerel kimlik bilgilerini okumaz. Kullanılabilir tüm havuz hesapları hariç tutulursa otomatik seçim hesap döndürmez. Açıkça hesap belirten yollar kullanılabilir; duraklatma, kimlik doğrulama ve model yetkisi denetimleri korunur. Hesap kartı ve CLI, hariç tutulan yönlendirme planını kimlik bilgisi durumundan ayrı gösterir. Planların tam sıralaması olmadığından `minimumPlan` ayarı yoktur. ## 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 1e4cea1d43..db90fd9de5 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 @@ -336,8 +336,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' 这是选择策略,不是封禁。被排除的账号保留凭据、用量历史和线程亲和性,仍显示在账号列表中,也仍可通过 `work/gpt-5.5` 这类显式选择使用。改变的只是自动轮换不再选它,包括它已经是活跃账号或已绑定线程的情况——订阅到期后留下的正是这种状态。 -有两处刻意的限制。主 Codex 账号不会因套餐被排除:仅选择模式的路由不读取受保护的原生凭据而隐去其套餐,覆盖主账号的规则会自相矛盾。另外,当没有未被排除的账号时,被排除的账号仍会应答而不是失败;要彻底停止服务,仍然是暂停全部账号。没有对应的 `minimumPlan`,因为给 ChatGPT 套餐排序需要一个这里并不存在的全序。 - +主 Codex 账号不受套餐排除策略影响;仅选择模式不会读取受保护的原生凭据。如果所有可用的池账号都被排除,自动选择不返回账号。明确指定账号的路由仍可使用,并继续检查暂停、认证和模型权限。账号卡片与 CLI 将被排除的路由套餐与凭据健康状态分开显示。套餐没有全序关系,因此不提供 `minimumPlan` 设置。 ## 恢复原生 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 a17997b848..2582af8b10 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 @@ -343,8 +343,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' 這是選擇策略,不是封鎖。被排除的帳號保留憑證、用量紀錄與執行緒親和性,仍顯示在帳號清單中,也仍可透過 `work/gpt-5.5` 這類明確選擇使用。改變的只是自動輪換不再挑它,包括它已經是使用中帳號或已綁定執行緒的情況——訂閱到期後留下的正是這種狀態。 -有兩處刻意的限制。主 Codex 帳號不會因方案被排除:僅選擇模式的路由不讀取受保護的原生憑證而隱去其方案,涵蓋主帳號的規則會自相矛盾。此外,當沒有未被排除的帳號時,被排除的帳號仍會回應而不是失敗;要完全停止服務,仍然是暫停所有帳號。沒有對應的 `minimumPlan`,因為為 ChatGPT 方案排序需要一個這裡並不存在的全序。 - +主 Codex 帳號不受方案排除策略影響;僅選擇模式不會讀取受保護的原生憑證。如果所有可用的池帳號都被排除,自動選取不會回傳帳號。明確指定帳號的路由仍可使用,並繼續檢查暫停、認證及模型權限。帳號卡片與 CLI 將被排除的路由方案與憑證健康狀態分開顯示。方案沒有全序關係,因此不提供 `minimumPlan` 設定。 ## 恢復原生 Codex `ocx stop` 會停止 proxy 與已安裝的背景服務,然後嘗試恢復原生 Codex。OpenCodex 只移除能確認歸屬的路由設定;若無法安全恢復設定檔,會回報恢復未完成。 diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index 619ec2ecf2..72da4b774c 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -78,6 +78,7 @@ export function CodexAccountPoolCards({ <> {pool.map(a => { const healthStatus = a.health?.status; + const planExcluded = a.selectionExcludedReason === "plan_excluded"; const showReauth = Boolean(a.needsReauth) || oauthHealthShowsReauth(healthStatus); const inCooldown = oauthHealthIsCooldown(healthStatus); const validationPending = a.health?.reason === "validation_pending"; @@ -90,6 +91,11 @@ export function CodexAccountPoolCards({ {a.alias ?? a.email} {a.plan && {a.plan}} + {planExcluded && ( + + {t("codexAuth.planExcluded")} + + )} {a.paused && ( {t("codexAuth.paused")} @@ -102,13 +108,13 @@ export function CodexAccountPoolCards({ {healthLabel} )} {showReauth && !healthLabel && {t("codexAuth.needsReauth")}} - {isNext(a) && !showReauth && !inCooldown && !validationPending && ( + {isNext(a) && !planExcluded && !showReauth && !inCooldown && !validationPending && ( {t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession")} )} - {!a.paused && (!isNext(a) || pinnedId !== a.id) && !showReauth && !inCooldown && !validationPending && ( + {!a.paused && !planExcluded && (!isNext(a) || pinnedId !== a.id) && !showReauth && !inCooldown && !validationPending && ( diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index 2d909b29bd..87aca93743 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -54,6 +54,8 @@ export interface CodexAccountEntry { }; mainAccountHardLock?: MainAccountHardLockStatus; needsReauth?: boolean; + selectionExcludedReason?: "plan_excluded"; + selectionExcludedPlan?: string; health?: { status: "healthy" | "cooldown" | "reauth_required" | "warning"; reason?: string; until?: string }; healthLabel?: string; healthSummary?: string; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index fdb18eb6ed..f48259c80a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1329,6 +1329,8 @@ export const de: Record = { "codexAuth.pause": "Pausieren", "codexAuth.resume": "Fortsetzen", "codexAuth.paused": "PAUSIERT", + "codexAuth.planExcluded": "Nicht automatisch gewählt", + "codexAuth.planExcludedHint": "Tarif {plan} ist von der automatischen Auswahl ausgeschlossen. Explizite Kontorouten bleiben verfügbar.", "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.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 1847a7af7e..88a47c7c66 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1905,6 +1905,8 @@ export const en = { "codexAuth.pause": "Pause", "codexAuth.resume": "Resume", "codexAuth.paused": "PAUSED", + "codexAuth.planExcluded": "Not auto-selected", + "codexAuth.planExcludedHint": "Plan {plan} is excluded from automatic selection. Explicit account routes remain available.", "codexAuth.pauseSucceeded": "{email} is paused", "codexAuth.resumeSucceeded": "{email} is available to the pool again", "codexAuth.pauseFailed": "Could not pause {email}. Nothing was changed.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e465adbb10..71d02a56db 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1837,6 +1837,8 @@ export const fr: Record = { "codexAuth.pause": "Suspendre", "codexAuth.resume": "Reprendre", "codexAuth.paused": "SUSPENDU", + "codexAuth.planExcluded": "Exclu du choix automatique", + "codexAuth.planExcludedHint": "Le forfait {plan} est exclu de la sélection automatique. Les routes explicites vers ce compte restent disponibles.", "codexAuth.pauseSucceeded": "{email} est suspendu", "codexAuth.resumeSucceeded": "{email} est de nouveau disponible dans le groupe", "codexAuth.pauseFailed": "Impossible de suspendre {email}. Aucune modification apportée.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e9a3d9f58b..022888c079 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1762,6 +1762,8 @@ export const ja: Record = { "codexAuth.pause": "一時停止", "codexAuth.resume": "再開", "codexAuth.paused": "一時停止中", + "codexAuth.planExcluded": "自動選択の対象外", + "codexAuth.planExcludedHint": "プラン {plan} は自動選択の対象外です。アカウントを明示的に指定すると利用できます。", "codexAuth.pauseSucceeded": "{email} を一時停止しました", "codexAuth.resumeSucceeded": "{email} をアカウントプールに戻しました", "codexAuth.pauseFailed": "{email} を一時停止できませんでした。変更はありません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 67ee251970..41fbeca7ee 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1365,6 +1365,8 @@ export const ko: Record = { "codexAuth.pause": "일시 중지", "codexAuth.resume": "재개", "codexAuth.paused": "일시 중지됨", + "codexAuth.planExcluded": "자동 선택 제외", + "codexAuth.planExcludedHint": "{plan} 플랜은 자동 선택에서 제외됩니다. 계정을 직접 지정하면 사용할 수 있습니다.", "codexAuth.pauseSucceeded": "{email} 계정을 일시 중지했습니다", "codexAuth.resumeSucceeded": "{email} 계정을 풀에서 다시 사용할 수 있습니다", "codexAuth.pauseFailed": "{email} 계정을 일시 중지하지 못했습니다. 변경 사항이 없습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 56d43fc301..5ca60bb485 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1832,6 +1832,8 @@ export const ru: Record = { "codexAuth.pause": "Приостановить", "codexAuth.resume": "Возобновить", "codexAuth.paused": "ПРИОСТАНОВЛЕН", + "codexAuth.planExcluded": "Не выбирается автоматически", + "codexAuth.planExcludedHint": "Тариф {plan} исключён из автоматического выбора. Явная маршрутизация на аккаунт доступна.", "codexAuth.pauseSucceeded": "Аккаунт {email} приостановлен", "codexAuth.resumeSucceeded": "Аккаунт {email} снова доступен в пуле", "codexAuth.pauseFailed": "Не удалось приостановить {email}. Изменений нет.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b627813bb9..98e50d5556 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1862,6 +1862,8 @@ export const tr: Record = { "codexAuth.pause": "Duraklat", "codexAuth.resume": "Devam Ettir", "codexAuth.paused": "DURAKLATILDI", + "codexAuth.planExcluded": "Otomatik seçilmez", + "codexAuth.planExcludedHint": "{plan} planı otomatik seçimden hariç tutulur. Açık hesap yönlendirmeleri kullanılabilir.", "codexAuth.pauseSucceeded": "{email} duraklatıldı", "codexAuth.resumeSucceeded": "{email} tekrar havuza alındı", "codexAuth.pauseFailed": "{email} duraklatılamadı.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ce06556fd4..a1b9ce40b4 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1453,6 +1453,8 @@ export const zhTW: Record = { "codexAuth.pause": "暫停", "codexAuth.resume": "恢復", "codexAuth.paused": "已暫停", + "codexAuth.planExcluded": "不自動選取", + "codexAuth.planExcludedHint": "方案 {plan} 已排除自動選取。仍可明確指定此帳號。", "codexAuth.pauseSucceeded": "已暫停 {email}", "codexAuth.resumeSucceeded": "{email} 已重新加入帳號池", "codexAuth.pauseFailed": "無法暫停 {email},未做任何變更。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ae2fdfec92..bb222d01ea 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1346,6 +1346,8 @@ export const zh: Record = { "codexAuth.pause": "暂停", "codexAuth.resume": "恢复", "codexAuth.paused": "已暂停", + "codexAuth.planExcluded": "不自动选择", + "codexAuth.planExcludedHint": "套餐 {plan} 已从自动选择中排除。仍可明确指定此账号。", "codexAuth.pauseSucceeded": "已暂停 {email}", "codexAuth.resumeSucceeded": "{email} 已重新加入账号池", "codexAuth.pauseFailed": "无法暂停 {email},未做任何更改。", diff --git a/gui/tests/codex-account-pool-pinned-badge.test.tsx b/gui/tests/codex-account-pool-pinned-badge.test.tsx index 9702d7aff9..bb3e616491 100644 --- a/gui/tests/codex-account-pool-pinned-badge.test.tsx +++ b/gui/tests/codex-account-pool-pinned-badge.test.tsx @@ -280,3 +280,22 @@ test("healthy account cards omit log-label and 30-day usage copy", async () => { expect(main.textContent).not.toContain("Log label: main"); expect(hasPinnedHint(main)).toBe(false); }); + + +test("plan exclusion is visible without presenting the account as the next automatic selection", async () => { + await mountPool(makeController({ + accounts: [mainAccount, { ...account, plan: "plus", selectionExcludedReason: "plan_excluded", selectionExcludedPlan: "free" }], + activeId: account.id, + })); + const card = cardFor(account.email); + const excluded = [...card.querySelectorAll(".badge")].find(el => el.textContent === en["codexAuth.planExcluded"]); + expect(excluded).toBeTruthy(); + expect(excluded!.getAttribute("title")).toContain("free"); + expect([...card.querySelectorAll(".badge")].some(el => el.textContent === en["codexAuth.nextSession"])).toBe(false); + expect(card.textContent).not.toContain(en["codexAuth.paused"]); + expect(switchAction(card)).toBeNull(); + await act(async () => { + root!.render(); + }); + expect(cardFor(account.email).textContent).not.toContain(en["codexAuth.planExcluded"]); +}); diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index e0e573e251..c734f7eb85 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -26,6 +26,8 @@ export interface AccountRow { masked?: string; active: boolean; needsReauth?: boolean; + selectionExcludedReason?: "plan_excluded"; + selectionExcludedPlan?: string; /** Registered credential that is still excluded from routing until validation completes. */ validationPending?: boolean; /** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */ @@ -243,6 +245,8 @@ interface CodexAccountDto { plan?: string; isMain?: boolean; needsReauth?: boolean; + selectionExcludedReason?: "plan_excluded"; + selectionExcludedPlan?: string; health?: { reason?: string }; priority?: number; quota?: CodexQuotaDto | null; @@ -309,6 +313,10 @@ export async function fetchCodexRows( plan: a.plan, active: a.id === activeId, needsReauth: a.needsReauth, + ...(a.selectionExcludedReason === "plan_excluded" ? { + selectionExcludedReason: "plan_excluded" as const, + ...(typeof a.selectionExcludedPlan === "string" ? { selectionExcludedPlan: a.selectionExcludedPlan } : {}), + } : {}), ...(a.health?.reason === "validation_pending" ? { validationPending: true } : {}), priority: typeof a.priority === "number" ? a.priority : 0, paused: a.paused === true, diff --git a/src/cli/account.ts b/src/cli/account.ts index 4a8c6a0427..04e7f52be2 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -101,6 +101,9 @@ function statusText(row: AccountRow): string { if (row.active) parts.push(row.type === "codex" ? "selected" : "active"); if (row.needsReauth) parts.push("needs-reauth"); if (row.validationPending) parts.push("validation-pending"); + if (row.selectionExcludedReason === "plan_excluded") { + parts.push(`not-auto-selected(plan=${row.selectionExcludedPlan ?? row.plan ?? "unknown"})`); + } return parts.join(" "); } diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 09becf51ea..3b5fc83d1e 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -49,6 +49,7 @@ import { clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, + isCodexAccountPlanExcluded, reconcileCodexActiveAfterExclusion, resetCodexRoutingForManualSelection, settleCodexQuotaRecoveryProbe, @@ -379,6 +380,7 @@ export type CodexAccountReauthReason = | "forbidden"; function poolAccountDto( + config: OcxConfig, account: CodexAccount, quotaResult: PoolQuotaResult, hasCredential: boolean, @@ -413,6 +415,10 @@ function poolAccountDto( quota: quota ? { ...quota } : null, needsReauth: needsReauth || health.status === "reauth_required", ...(reauthReason !== undefined ? { reauthReason } : {}), + ...(isCodexAccountPlanExcluded(config, account.id) ? { + selectionExcludedReason: "plan_excluded" as const, + selectionExcludedPlan: codexPlanValue(config.codexAccounts?.find(row => row.id === account.id)?.plan), + } : {}), hasCredential, ...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}), ...oauthAccountHealthFields("codex", account.id, health), @@ -1192,6 +1198,9 @@ export interface CodexAuthAccountDto { * needs the operator; `/api/oauth/accounts` already carries the same field name. */ reauthReason?: CodexAccountReauthReason; + /** Automatic selection policy only; explicit routes retain their usual auth checks. */ + selectionExcludedReason?: "plan_excluded"; + selectionExcludedPlan?: string; hasCredential: boolean; health: OAuthAccountHealth; healthLabel: OAuthHealthLabel; @@ -2006,6 +2015,7 @@ export async function listCodexAuthAccountsSnapshot( const currentCredential = getCodexAccountCredential(accountId); if (!currentCredential) { return [poolAccountDto( + runtimeConfig, currentAccount, { quota: null, needsReauth: true }, false, @@ -2026,6 +2036,7 @@ export async function listCodexAuthAccountsSnapshot( ? { ...currentAccount, plan: quotaResult.freshPlan } : currentAccount; return [poolAccountDto( + runtimeConfig, dtoAccount, effectiveQuotaResult, true, diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 04c5b8b1ab..83ade05b4b 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -1066,7 +1066,7 @@ function excludedCodexPoolPlanKeys(config: OcxConfig): ReadonlySet | und * 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( +export function isCodexAccountPlanExcluded( config: OcxConfig, accountId: string, precomputed?: ReadonlySet, @@ -2155,6 +2155,7 @@ export function previewCodexAccountForRequest( else if ( hasConfiguredPoolAccount(config, active, selectionOptions) && !isCodexAccountPaused(config, active) + && !isCodexAccountPlanExcluded(config, active) ) return active; else return null; } @@ -2391,6 +2392,7 @@ export function resolveCodexAccountForThreadDetailed( } else if ( hasConfiguredPoolAccount(config, active, selectionOptions) && !isCodexAccountPaused(config, active) + && !isCodexAccountPlanExcluded(config, active) ) { return { status: "selected", accountId: active }; } else { diff --git a/structure/catalog.md b/structure/catalog.md index 91d7734848..3d430fa176 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -268,3 +268,5 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +Account-qualified catalog routes bypass automatic plan exclusions while retaining credential and entitlement checks; see [automatic pool plan exclusions](providers/openai-tiers.md#automatic-pool-plan-exclusions). diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 4c86504a08..02a44005da 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -79,3 +79,5 @@ testable on any host: stubbing `process.platform` does not propagate to `os.plat Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +Desktop requests routed to the Codex pool use the shared [automatic plan exclusion contract](../providers/openai-tiers.md#automatic-pool-plan-exclusions); explicit account-qualified targets retain their selection semantics. diff --git a/structure/codex-home.md b/structure/codex-home.md index 7c523dd20a..ed52e53a0c 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -226,3 +226,5 @@ a deliberate user choice: Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +Plan-based automatic exclusions leave native credential files untouched and preserve the native-main exemption in the [selection policy](providers/openai-tiers.md#automatic-pool-plan-exclusions). diff --git a/structure/config.md b/structure/config.md index 48a29a7817..3980d030c9 100644 --- a/structure/config.md +++ b/structure/config.md @@ -195,3 +195,5 @@ Client connection metadata stores a stable `apiKeyId` and a non-secret rotation Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +`codexPool.excludedPlans` is interpreted only by automatic selection; its all-excluded and explicit-route behavior follows the [plan exclusion contract](providers/openai-tiers.md#automatic-pool-plan-exclusions). diff --git a/structure/design-methodology.md b/structure/design-methodology.md index bcaf940d72..a8e9a822f3 100644 --- a/structure/design-methodology.md +++ b/structure/design-methodology.md @@ -36,3 +36,5 @@ surfaces, run through all 3 stages in order. - Design methodology: Product-Personality-Selection (dev-uiux-design §1) - 6 design dials: mood, lightness, density, shape, typography, motion - 7 axes total: design → domain → feature/data/security/ops/cost (derived) + +The Codex account card separates automatic plan-policy exclusion from credential health and suppresses an unavailable next-session action; see the [account selection contract](providers/openai-tiers.md#automatic-pool-plan-exclusions). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index a860a78fc2..ec5a815667 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -515,3 +515,5 @@ survives availability drift, while complete/native custom orders await explicit Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +Codex account DTOs and cards expose the routing-plan exclusion separately from credential health; the [plan exclusion contract](providers/openai-tiers.md#automatic-pool-plan-exclusions) also governs CLI projection. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index a7ef656162..0b0366a2ab 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -303,3 +303,5 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](../providers/openai-tiers.md#quota-cache-and-short-window-history). + +The account CLI and translated Codex integration guides follow the [automatic plan exclusion contract](../providers/openai-tiers.md#automatic-pool-plan-exclusions), including all-excluded pools and explicit routes. diff --git a/structure/overview.md b/structure/overview.md index 1802d31b72..11ce44ed33 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -103,3 +103,5 @@ would pass while the rule was violated. - **INV-HOME-01** — `CODEX_HOME` wins over `~/.codex` when present and valid. - **INV-SLUG-01** — Routed model slugs use `provider/model`. + +Codex plan exclusions constrain automatic pool selection without deleting credentials; [account-policy reasons](providers/openai-tiers.md#automatic-pool-plan-exclusions) remain distinct from health and pause. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index b8d1d56675..7c9dce0813 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -398,3 +398,9 @@ model settings, and noncanonical `openai` rows never receive that recovery path. `GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401, and allowlisted terminal 403 codes as `needsReauth`; generic permission failures remain non-terminal, and a successful main usage refresh clears the runtime mark. + +## Automatic pool plan exclusions + +`src/codex/routing.ts` applies optional `codexPool.excludedPlans` to both candidate selection and existing active/affined accounts. An all-excluded pool returns no automatic candidate, including preview and configured-account fallback. Native main remains exempt and unknown plans remain eligible. Explicit account-qualified routes retain pause, credential and entitlement checks while bypassing only this automatic policy. + +`src/codex/auth-api.ts` projects `selectionExcludedReason: "plan_excluded"` and `selectionExcludedPlan` from the routing config, even when a newer display-only WHAM plan could not be persisted. The dashboard and account CLI show the policy reason separately from credential health; renewal clears the derived fields. The automatic next-session action and badge are omitted for excluded rows. diff --git a/structure/runtime.md b/structure/runtime.md index 495745051e..c9718b89df 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -192,3 +192,5 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +Automatic Codex pool selection and account status share the [plan exclusion contract](providers/openai-tiers.md#automatic-pool-plan-exclusions). diff --git a/structure/subagents.md b/structure/subagents.md index 3f00e96302..44d922bdcc 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -202,3 +202,5 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +Subagent automatic pool preview returns no candidate when all pool plans are excluded; explicit account-qualified models retain the [selection-policy distinction](providers/openai-tiers.md#automatic-pool-plan-exclusions). diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 214270e4be..fdfa2b6151 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -586,6 +586,18 @@ afterEach(() => { }); describe("ocx account CLI (issue #180 matrix)", () => { + test("plan exclusions survive the API projection and use the policy plan", async () => { + codexAccounts = [{ id: "policy", plan: "plus", selectionExcludedReason: "plan_excluded", selectionExcludedPlan: "free", paused: false }]; + const human = await run(["list", "openai"]); + expect(human.code).toBe(0); + expect(human.stdout).toContain("not-auto-selected(plan=free)"); + const machine = await run(["list", "openai", "--json"]); + expect(JSON.parse(machine.stdout).accounts[0]).toMatchObject({ selectionExcludedReason: "plan_excluded", selectionExcludedPlan: "free" }); + codexAccounts = [{ id: "policy", plan: "plus", selectionExcludedReason: "unrecognized", selectionExcludedPlan: "free" }]; + expect((await run(["list", "openai"])).stdout).not.toContain("not-auto-selected"); + expect(JSON.parse((await run(["list", "openai", "--json"])).stdout).accounts[0]).not.toHaveProperty("selectionExcludedReason"); + }); + test.each([100, 12])("pending validation stays visible at %s percent usage without exposing raw health details", async weeklyPercent => { codexAccounts = [{ id: "pending", email: "p***@example.test", quota: { weeklyPercent }, health: { status: "warning", reason: "validation_pending", message: RAW_SENTINEL } }]; diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 89813bb058..4ef25c381c 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -1051,6 +1051,22 @@ describe("codex-auth API", () => { } }); + test("account DTO exposes the routing plan exclusion and clears it on renewal", async () => { + const cfg = makeConfig({ codexPool: { excludedPlans: ["free"] } }); + seedPoolAccount(cfg, { id: "plan-row", email: "plan@example.test", plan: "free" }); + const read = async () => { + const request = new Request("http://localhost/api/codex-auth/accounts"); + const response = await handleCodexAuthAPI(request, new URL(request.url), cfg); + const body = await response!.json() as { accounts: CodexAuthAccountDto[] }; + return body.accounts.find(account => account.id === "plan-row")!; + }; + expect(await read()).toMatchObject({ selectionExcludedReason: "plan_excluded", selectionExcludedPlan: "free", paused: false }); + cfg.codexAccounts![0].plan = "plus"; + const renewed = await read(); + expect(renewed).not.toHaveProperty("selectionExcludedReason"); + expect(renewed).not.toHaveProperty("selectionExcludedPlan"); + }); + test("GET /api/codex-auth/accounts returns array with main", async () => { const req = new Request("http://localhost/api/codex-auth/accounts", { method: "GET" }); const url = new URL(req.url); diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index 580f6d15bd..5c50d021e0 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -1523,6 +1523,28 @@ describe("Codex auth context", () => { .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); }); + test("explicit account routing bypasses plan policy while retaining pause and reauth checks", async () => { + const cfg = config(); + cfg.codexAccounts!.find(account => account.id === "pool-a")!.plan = "free"; + cfg.codexPool = { excludedPlans: ["free"] }; + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, chatgptAccountId: "pool_a_acc", + }); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + accountId: "pool-a", modelId: "gpt-5.5", + })).resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); + cfg.pausedCodexAccountIds = ["pool-a"]; + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + accountId: "pool-a", modelId: "gpt-5.5", + })).rejects.toThrow("Selected Codex account is unavailable"); + cfg.pausedCodexAccountIds = []; + markAccountNeedsReauth("pool-a"); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + accountId: "pool-a", modelId: "gpt-5.5", + })).rejects.toThrow("Selected Codex account needs reauthentication"); + }); + test("exact selection reports reauthentication without falling back to the active Pool account", async () => { const cfg = config(); cfg.activeCodexAccountId = "pool-b"; diff --git a/tests/codex-integration/codex-pool-plan-exclusion.test.ts b/tests/codex-integration/codex-pool-plan-exclusion.test.ts index 52a76fb57a..36fe8aed2a 100644 --- a/tests/codex-integration/codex-pool-plan-exclusion.test.ts +++ b/tests/codex-integration/codex-pool-plan-exclusion.test.ts @@ -6,6 +6,7 @@ import { clearCodexUpstreamHealth, clearThreadAccountMap, pickLowestUsageCodexAccount, + isCodexAccountPlanExcluded, previewCodexAccountForRequest, resolveCodexAccountForThread, } from "../../src/codex/routing"; @@ -164,15 +165,24 @@ describe("codex pool plan exclusion", () => { 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. + test("automatic routing refuses the last excluded account", () => { 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"); + expect(resolveCodexAccountForThread("last-account", config)).toBeNull(); + expect(previewCodexAccountForRequest("last-account", config)).toBeNull(); }); + test("renewal clears the policy reason without pausing or deleting the account", () => { + const config = makeConfig({ codexPool: { excludedPlans: ["free"] } }); + expect(isCodexAccountPlanExcluded(config, "downgraded")).toBe(true); + config.codexAccounts![0].plan = "plus"; + expect(isCodexAccountPlanExcluded(config, "downgraded")).toBe(false); + expect(isCodexAccountPlanExcluded(config, "__main__")).toBe(false); + expect(config.codexAccounts).toHaveLength(2); + expect(config.pausedCodexAccountIds).toBeUndefined(); + }); + }); From e9007429c5e7948e30ff253bd2135cc4bbda82ac Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:30:31 +0900 Subject: [PATCH 2/8] feat(codex): bind quota observations to credential publication identity --- devlog/_plan/260912_accounts/000_plan.md | 2 + .../260912_accounts/048_history_identity.md | 19 +++++ .../049_history_identity_delivery.md | 7 ++ devlog/_plan/260912_accounts/050_history.md | 4 +- devlog/_plan/260912_accounts/060_capacity.md | 2 + src/codex/account-store.ts | 78 +++++++++++++++++ src/codex/quota-types.ts | 8 ++ src/types/accounts.ts | 2 + structure/catalog.md | 2 + structure/codex-home.md | 2 + structure/config.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/providers/openai-tiers.md | 6 ++ structure/runtime.md | 2 + structure/subagents.md | 2 + .../codex-account-store.test.ts | 84 +++++++++++++++++++ 17 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260912_accounts/048_history_identity.md create mode 100644 devlog/_plan/260912_accounts/049_history_identity_delivery.md diff --git a/devlog/_plan/260912_accounts/000_plan.md b/devlog/_plan/260912_accounts/000_plan.md index df54929c99..ed3e4574c1 100644 --- a/devlog/_plan/260912_accounts/000_plan.md +++ b/devlog/_plan/260912_accounts/000_plan.md @@ -46,3 +46,5 @@ Two design follow-ups encountered inherited-model capacity errors; one same-hand ## Roadmap cycle outcome Independent design reflection and A re-audit passed with the source restrictions in 001_roadmap_audit.md. B freezes the contracts as documentation only. C checks document paths/numbering and git whitespace; local product suites NOT RUN. D next direction: execute 010_callback.md independently, then the remaining dependency-ordered cycles. Runtime behavior has not improved yet; the rejected hypotheses were native history identity by sentinel alone, attempt timing inferred from untimed attempts, and one-shot implying one physical request through a retrying primitive. + +History P split:048_history_identity.md supplies stable publication identity and fenced writer capture before050 history. This is a new foundation cycle, registered in the same goalplan; intended manual chain history-identity → history → capacity. It is independent of reset-first. Staged login samples are omitted until a fenced post-publication observation; native history remains nondurable and excluded from capacity. diff --git a/devlog/_plan/260912_accounts/048_history_identity.md b/devlog/_plan/260912_accounts/048_history_identity.md new file mode 100644 index 0000000000..d8273fab4e --- /dev/null +++ b/devlog/_plan/260912_accounts/048_history_identity.md @@ -0,0 +1,19 @@ +# Bind quota history to credential publication identity + +New foundation cycle history-identity, C4 credential metadata, before history and capacity. Current source saveCodexAccountCredential publishes a new generation, while normal refresh CAS also increments generation and preserves replacedAt. Neither generation equality nor a millisecond timestamp alone establishes durable quota-history continuity. Reuse the credential store and its mutation lock; no new store or token-derived fingerprint. + +MODIFY src/types/accounts.ts CodexAccountCredentialRecord: optional private quotaHistoryIdentity UUID, not credential material and never projected to API/CLI. MODIFY src/codex/account-store.ts: every explicit save creates a fresh UUID; saveCodexAccountCredentialIfGeneration and commitRefreshedCodexCredentialWithAliases preserve each record's own UUID, including aliases. Deletes retain no old history identity. Existing credential projection excludes metadata automatically. + +Add PoolQuotaWriter type in dependency-free src/codex/quota-types.ts: +```ts +export interface PoolQuotaWriter { accountId: string; credentialGeneration: number; historyIdentity: string } +``` +Add capturePoolQuotaWriter(accountId, dispatched:{accessToken,chatgptAccountId,generation}) in account-store.ts. Under existing withCredentialMutationLockSync, read record and require exact dispatched credential and generation, live/nondeleted state. For a legacy/malformed missing UUID initialize one once and persist under that lock without changing credential generation; do not mint on normal reads. A mismatch returns undefined. Lock/persistence failures at this optional evidence boundary return undefined, never fail the request. Credentials remain transient and never enter returned proof. Existing valid UUID capture needs only read matching record, no mutation lock or rewrite; legacy slow path rechecks under lock. + +Add isPoolQuotaWriterLive(writer): compare current live record's UUID and generation. Add poolQuotaHistoryIdentity(accountId): read valid current UUID only, never initialize or mutate. These separate append admission from retention, which matches UUID across ordinary refresh. Both are narrow production interfaces for the next history layer, not public management capabilities. + +Tests extend existing codex-account-store.test.ts: new saves unique; same-millisecond explicit replacement changes UUID; refresh preserves; alias refresh preserves distinct destination identities; stale dispatched access/generation/account cannot capture; legacy initialization stable and does not advance generation; metadata omitted from getCodexAccountCredential/load compatibility projection; delete/recreate invalidates old writer. Local tests/build/typecheck/install NOT RUN. Hosted cumulative history/capacity tip verifies these regression sources. Source security review separate from runtime proof. + +Field chain: explicit save/legacy capture creates UUID → existing atomic credential record serialization → existing read with UUID validity checked at history boundary → capture/live/retention helpers → next cycle's auth-context/WHAM/header history admission. All explicit record reconstructions are enumerated: save at161, validation spreads186/234 preserve, refresh279/338 preserve, alias366 preserves its own, deletion387 drops. Source ownership docs updated with private metadata semantics. No credential/token/string values enter docs or log output. + +A implementation checks accepted: legacy tag init uses plain persist, preserving both generation and credentialMutationEpoch. UUID validation stays at history boundary; malformed optional metadata never discards usable credentials. Catch read/hardening failures as well as lock/write failures and return no optional proof. Capture excludes the reserved native-main sentinel. If a CAS caller supplies a different upstream account identity, rotate the history UUID instead of treating that as ordinary same-account refresh. diff --git a/devlog/_plan/260912_accounts/049_history_identity_delivery.md b/devlog/_plan/260912_accounts/049_history_identity_delivery.md new file mode 100644 index 0000000000..6af02c6026 --- /dev/null +++ b/devlog/_plan/260912_accounts/049_history_identity_delivery.md @@ -0,0 +1,7 @@ +# Quota history identity foundation + +Adds a private random publication UUID to pool credential records. Explicit saves rotate it, refresh CAS preserves it for the same upstream account, and aliases retain distinct identities. Captured writer proofs require exact dispatched credential generation and access/account pairing; legacy identity initialization occurs under the existing lock without changing the credential generation or mutation epoch. Read/lock/write failure yields no optional proof. Metadata never enters credential-only projection. + +Regression sources cover refresh versus same-time replacement, aliases, deletion/recreation, legacy stable initialization, stale capture, malformed metadata, secret-free proof and identity-changing CAS. The latter rotates owner history and does not propagate the new identity into old aliases. No new test file/dependency. Local suites/build/typecheck/install NOT RUN. Source checks are not runtime proof; hosted final cumulative history/capacity tip remains required. + +Structural decision: proof type stays in quota-types.ts (type-only), credential record/lock/persistence stay in account-store.ts, future pure history leaf consumes plain evidence. Rejected generation-only retention because ordinary refresh increments it; rejected timestamp identity because publication can share a millisecond. The small foundation is the first ordinary manual-chain PR, then history, then capacity. No merge. diff --git a/devlog/_plan/260912_accounts/050_history.md b/devlog/_plan/260912_accounts/050_history.md index fab9e603be..dd96c9019e 100644 --- a/devlog/_plan/260912_accounts/050_history.md +++ b/devlog/_plan/260912_accounts/050_history.md @@ -20,4 +20,6 @@ Field chain: creation is guarded quota commit; serialization is existing atomic Reflection REF-04: fixed aggregate bounds: 64 account identities, 4096 rows, 2 MiB serialized history payload and 4 MiB whole cache read bound. During append/hydrate evict oldest observed rows, tie-break account key; prune accounts absent from authoritative roster. Never include dynamic raw account identities in logs. History retains actual per-window provenance (response-header or WHAM where available), reset boundary and window family; partial inherited values do not count. Overlarge/malformed cache read fails to empty history without blocking newest quota. Tests include many-account overflow, byte overflow, deterministic ties and remove/restart. -A1 accepted: native main history is deliberately NOT hydrated from disk in this slice. It can be sampled in-process only after identity observation and cleared on identity change; persistence omits __main__. Pool history envelopes bind stable configured account identity and stored credential generation, pruning mismatches on hydrate. This avoids attributing offline identity replacements to an old main label. Acceptance explicitly covers main replacement while stopped and account-id reuse. Main cross-restart history remains a documented limitation; bounded durable history is provided for stored pool accounts. +A1 accepted: native main history is deliberately NOT hydrated from disk in this slice. It can be sampled in-process only after identity observation and cleared on identity change; persistence omits __main__. Pool history envelopes bind a stable private publication UUID; hydration prunes identity mismatches, while ordinary generation changes on refresh retain prior observations. This avoids attributing offline identity replacements to an old main label. Acceptance explicitly covers main replacement while stopped and account-id reuse. Main cross-restart history remains a documented limitation; bounded durable history is provided for stored pool accounts. + +P refinement depends on new048 history-identity cycle. Adopt HIST-01..06: generation gates each physical sample; private random publication UUID persists through refresh and changes on explicit save. Capture PoolQuotaWriter before upstream calls, refresh it after replay token resolution, and forward through every WHAM/WS/HTTP/compact/warmup path. Omit staged login/reauth samples until first post-publication fenced observation; do not retrofit ambient provenance. Native main is excluded from durable endpoint/capacity in this slice. Raw QuotaObservation carries observedAt, wham|response-header source, bounded windows with account|spark family and short|weekly|monthly name, percentage/resetAtMs/duration/primary provenance; no arbitrary upstream label. Envelope private identity binds samples but is omitted from read DTO. Retain best-effort single-writer atomic cache semantics; no multi-process merge/durability claim. Read endpoint GET /api/codex-auth/quota/history?accountId=&limit=<1..200>; CLI ocx account history openai [--limit N] [--json]. Unknown/deleted404, invalid/duplicate selector400, emptyhistory200. No upstream call on reads. diff --git a/devlog/_plan/260912_accounts/060_capacity.md b/devlog/_plan/260912_accounts/060_capacity.md index 931e0b6646..f4c5acb023 100644 --- a/devlog/_plan/260912_accounts/060_capacity.md +++ b/devlog/_plan/260912_accounts/060_capacity.md @@ -16,3 +16,5 @@ export type CodexCapacityEstimate = { MODIFY history read API/CLI projection to attach per-window estimates with sample count and caveat; expose an existing account-card detail surface only if it can be honestly rendered and verified. Field chain: pure estimator creation; API JSON serialization; existing typed CLI/client deserialization; explicit informational display consumers. No persisted estimate schema needed. Tests feed independently hand-calculated intervals, 0% delta, reset rollover, missing timestamps/identity, cross-account records, retries, estimated usage, and extreme numeric input. Sync quota/usage ownership docs and user configuration guidance. Full closure of #3376 requires both history and meaningful capacity; reset-first alone stays partial. Local suites NOT RUN; hosted final cumulative tip is the verifier. A2 accepted: use readUsageSnapshotForManagement; if truncatedPrefixBytes>0, entriesTruncated, entriesDropped>0, missing revision, or invalid timing then return insufficient-evidence with no estimate. Treat each request as interval [timestamp, timestamp+durationMs] (request-log.ts:1039/1072); include only requests wholly contained in a quota-observation interval. Boundary-spanning requests contribute nothing. For included requests count reported physical attempts matching the exact pool label once; do not count both request total and attempts. Without attempts accept request-level reported usage only with matching label and no recovery ambiguity. Native main is excluded from token capacity because its historical label cannot establish identity after replacement. Current pool logLabel must be unique; legacy fallback labels/id reuse require insufficient evidence unless continuity is proven by history generation. Same-reset positive deltas only. Hand-worked boundary-spanning, truncation, missing identity and retry rows are mandatory regression fixtures. + +P future refinement from history sidecar: do not call estimate a mathematical lower bound. It is an observed effective token estimate under rounded/delayed quota and local coverage assumptions. Admit only single-send reported nonestimated attempts; present-but-empty attempt arrays cannot fall back to parent totals. Deduplicate requestId+ordinal and reject conflicting duplicates. Use interval (left,right] with whole request containment to avoid zero-duration double counting. Existing parser can skip malformed rows without a rejected counter: report retained-valid-ledger-only assumption explicitly or add rejected-row metadata before claiming complete coverage. Loglabel alone is not history identity; history publication UUID and current stable unique configured label must bind sample period. All source tests remain hosted-only. diff --git a/src/codex/account-store.ts b/src/codex/account-store.ts index 4e8d5c513e..75ebbe1493 100644 --- a/src/codex/account-store.ts +++ b/src/codex/account-store.ts @@ -13,6 +13,8 @@ import { import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types"; import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch"; +import { isValidCodexAccountId } from "./account-id"; +import type { PoolQuotaWriter } from "./quota-types"; import { CODEX_REFRESH_FLIGHT_CEILING_MS } from "./quota-recovery-timing"; type LegacyCodexAccountStore = Record; @@ -163,6 +165,7 @@ export function saveCodexAccountCredential( generation: (current?.generation ?? 0) + 1, refreshGrantFingerprint, replacedAt: current ? Date.now() : undefined, + quotaHistoryIdentity: crypto.randomUUID(), ...preservedValidationMetadata(current), ...(options.validationPending ? { codexValidationPending: true, @@ -257,6 +260,75 @@ export function readCodexAccountRecord(id: string): CodexAccountCredentialRecord return loadCodexAccountRecordStore()[id] ?? null; } +const QUOTA_HISTORY_IDENTITY_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +function validQuotaHistoryIdentity(value: unknown): value is string { + return typeof value === "string" && QUOTA_HISTORY_IDENTITY_RE.test(value); +} + +type DispatchedPoolCredential = Pick & { generation: number }; + +function matchesDispatchedPoolCredential(record: CodexAccountCredentialRecord | undefined | null, dispatched: DispatchedPoolCredential): record is CodexAccountCredentialRecord & { credential: CodexAccountCredentials } { + return !!record?.credential && record.deletedAt == null + && dispatched.accessToken.length > 0 && dispatched.chatgptAccountId.length > 0 + && Number.isSafeInteger(dispatched.generation) && dispatched.generation >= 0 + && record.generation === dispatched.generation + && record.credential.accessToken === dispatched.accessToken + && record.credential.chatgptAccountId === dispatched.chatgptAccountId; +} + +/** Optional evidence capture; a stale credential or unavailable store never gains a new writer. */ +export function capturePoolQuotaWriter(accountId: string, dispatched: DispatchedPoolCredential): PoolQuotaWriter | undefined { + if (!isValidCodexAccountId(accountId)) return undefined; + try { + const current = readCodexAccountRecord(accountId); + if (!matchesDispatchedPoolCredential(current, dispatched)) return undefined; + if (validQuotaHistoryIdentity(current.quotaHistoryIdentity)) { + return { accountId, credentialGeneration: dispatched.generation, historyIdentity: current.quotaHistoryIdentity }; + } + return withCredentialMutationLockSync(() => { + const store = loadCodexAccountRecordStore(); + const locked = store[accountId]; + if (!matchesDispatchedPoolCredential(locked, dispatched)) return undefined; + if (!validQuotaHistoryIdentity(locked.quotaHistoryIdentity)) { + locked.quotaHistoryIdentity = crypto.randomUUID(); + // Identity metadata is not a new credential; preserve generation and mutation epoch. + persist(store); + } + return { accountId, credentialGeneration: dispatched.generation, historyIdentity: locked.quotaHistoryIdentity }; + }); + } catch { + // History is optional evidence. Permission, lock and disk errors cannot fail inference. + return undefined; + } +} + +/** Read-only retention identity; unlike capture this never initializes legacy metadata. */ +export function poolQuotaHistoryIdentity(accountId: string): string | undefined { + if (!isValidCodexAccountId(accountId)) return undefined; + try { + const record = readCodexAccountRecord(accountId); + return record?.credential && record.deletedAt == null && validQuotaHistoryIdentity(record.quotaHistoryIdentity) + ? record.quotaHistoryIdentity : undefined; + } catch { + return undefined; + } +} + +/** Recheck append admission after upstream I/O; refresh may retire a writer without erasing history. */ +export function isPoolQuotaWriterLive(writer: PoolQuotaWriter): boolean { + if (!isValidCodexAccountId(writer.accountId)) return false; + try { + const record = readCodexAccountRecord(writer.accountId); + return !!record?.credential && record.deletedAt == null + && record.generation === writer.credentialGeneration + && validQuotaHistoryIdentity(writer.historyIdentity) + && record.quotaHistoryIdentity === writer.historyIdentity; + } catch { + return false; + } +} + export function isCodexAccountGenerationLive(id: string, generation: number): boolean { const record = readCodexAccountRecord(id); return !!record?.credential && record.deletedAt == null && record.generation === generation; @@ -281,6 +353,8 @@ export function saveCodexAccountCredentialIfGeneration( generation: generation + 1, refreshGrantFingerprint, replacedAt: current.replacedAt, + quotaHistoryIdentity: current.credential.chatgptAccountId === cred.chatgptAccountId + ? current.quotaHistoryIdentity : crypto.randomUUID(), ...preservedValidationMetadata(current), }; persistCredentialMutation(store); @@ -340,6 +414,8 @@ export function commitRefreshedCodexCredentialWithAliases( generation: generation + 1, refreshGrantFingerprint, replacedAt: current.replacedAt, + quotaHistoryIdentity: current.credential.chatgptAccountId === cred.chatgptAccountId + ? current.quotaHistoryIdentity : crypto.randomUUID(), ...preservedValidationMetadata(current), }; @@ -354,6 +430,7 @@ export function commitRefreshedCodexCredentialWithAliases( priorFingerprint !== undefined && priorCredential.refreshToken !== cred.refreshToken && !!priorCredential.chatgptAccountId + && priorCredential.chatgptAccountId === cred.chatgptAccountId ) { for (const [aliasId, alias] of Object.entries(store)) { if (aliasId === id || alias.deletedAt != null || !alias.credential) continue; @@ -369,6 +446,7 @@ export function commitRefreshedCodexCredentialWithAliases( generation: aliasGeneration, refreshGrantFingerprint, replacedAt: alias.replacedAt, + quotaHistoryIdentity: alias.quotaHistoryIdentity, ...preservedValidationMetadata(alias), }; propagatedAliases.push({ id: aliasId, generation: aliasGeneration }); diff --git a/src/codex/quota-types.ts b/src/codex/quota-types.ts index 6c06de6ae9..cc73a7d5e3 100644 --- a/src/codex/quota-types.ts +++ b/src/codex/quota-types.ts @@ -49,3 +49,11 @@ export type WhamUsageResponse = { rate_limit_reset_credits?: { available_count: number } | null; additional_rate_limits?: WhamAdditionalRateLimit[] | null; }; + + +/** Captured from the exact dispatched pool credential; never a management API field. */ +export interface PoolQuotaWriter { + accountId: string; + credentialGeneration: number; + historyIdentity: string; +} diff --git a/src/types/accounts.ts b/src/types/accounts.ts index 6b60c76f68..3d2ba914ec 100644 --- a/src/types/accounts.ts +++ b/src/types/accounts.ts @@ -29,6 +29,8 @@ export interface CodexAccountCredentialRecord { credential?: CodexAccountCredentials; generation: number; refreshGrantFingerprint?: string; + /** Private non-secret publication identity, stable across same-account token refresh. */ + quotaHistoryIdentity?: string; deletedAt?: number; replacedAt?: number; lastCodexValidatedAt?: number; diff --git a/structure/catalog.md b/structure/catalog.md index 0ba4acca3e..0d2be61254 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -278,3 +278,5 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. diff --git a/structure/codex-home.md b/structure/codex-home.md index b11ddd3f1a..8085223426 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -236,3 +236,5 @@ Injection preflights affected history using the normalized config candidate befo The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. Native restore preflight also checks manifest-owned targets whose rows already returned to `openai`, including interrupted restores. Preimage capture distinguishes absent files from unreadable artifacts and aborts before mutation when a complete snapshot cannot be read. + +Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. diff --git a/structure/config.md b/structure/config.md index 825811d9b3..962827bb4c 100644 --- a/structure/config.md +++ b/structure/config.md @@ -199,3 +199,5 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index a3c61e1f7d..de34929996 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -537,3 +537,5 @@ advances the observation clock, so a retained older row cannot defer evaluation Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 78d0e038ec..85f7578dee 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -312,3 +312,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Private pool credential metadata follows the [quota-history publication identity contract](../providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index a44edff557..10f3016a73 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -402,3 +402,9 @@ successful main usage refresh clears the runtime mark. ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +## Quota history publication identity + +`src/codex/account-store.ts` assigns each explicit pool credential publication a private random `quotaHistoryIdentity`. Same-account token refresh preserves it, including each alias record's own identity; replacement or deletion retires it. A refresh CAS with a changed upstream account identity rotates the tag and does not propagate that changed identity to old aliases. Credential-only projections omit this metadata. + +`capturePoolQuotaWriter` captures the exact dispatched access/account pair and generation. Legacy identity initialization rechecks under the credential mutation lock, persists metadata without advancing credential generation or mutation epoch, and fails to no optional evidence on read/lock/write errors. Append admission uses the captured generation and tag; history retention compares the tag across ordinary refresh. Native main is excluded from this pool proof. These interfaces supply the bounded observation layer; the identity alone is neither a quota sample nor proof of capacity. diff --git a/structure/runtime.md b/structure/runtime.md index 5abf80774d..a83451ecb6 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -219,3 +219,5 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. diff --git a/structure/subagents.md b/structure/subagents.md index f190aab084..c93aa8be21 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -214,3 +214,5 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. diff --git a/tests/codex-integration/codex-account-store.test.ts b/tests/codex-integration/codex-account-store.test.ts index 77a7750d14..e44ce355e0 100644 --- a/tests/codex-integration/codex-account-store.test.ts +++ b/tests/codex-integration/codex-account-store.test.ts @@ -80,6 +80,90 @@ describe("codex-account-store CRUD", () => { expect(store.readCodexAccountRecord("pending")?.lastCodexValidationStatus).toBe("ok"); }); + test("quota history identity survives refresh but explicit publication retires the writer", async () => { + const store = await import("../../src/codex/account-store"); + const credential = { accessToken: "history-access", refreshToken: "history-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "history-account" }; + const generation = store.saveCodexAccountCredential("history", credential); + const writer = store.capturePoolQuotaWriter("history", { ...credential, generation })!; + expect(writer.historyIdentity).toMatch(/^[a-f0-9-]{36}$/); + expect(store.isPoolQuotaWriterLive(writer)).toBe(true); + const refreshed = { ...credential, accessToken: "history-refreshed-access", refreshToken: "history-refreshed-grant" }; + expect(store.saveCodexAccountCredentialIfGeneration("history", generation, refreshed)).toBe(true); + expect(store.poolQuotaHistoryIdentity("history")).toBe(writer.historyIdentity); + expect(store.isPoolQuotaWriterLive(writer)).toBe(false); + const refreshedWriter = store.capturePoolQuotaWriter("history", { ...refreshed, generation: generation + 1 })!; + expect(refreshedWriter.historyIdentity).toBe(writer.historyIdentity); + expect(store.getCodexAccountCredential("history")).toEqual(refreshed); + const clock = spyOn(Date, "now").mockReturnValue(1_800_000_000_000); + try { + store.saveCodexAccountCredential("history", refreshed); + const replaced = store.poolQuotaHistoryIdentity("history"); + expect(replaced).not.toBe(writer.historyIdentity); + store.saveCodexAccountCredential("history", refreshed); + expect(store.poolQuotaHistoryIdentity("history")).not.toBe(replaced); + } finally { clock.mockRestore(); } + expect(store.isPoolQuotaWriterLive(refreshedWriter)).toBe(false); + }); + + test("quota history aliases retain distinct publication identities through refresh", async () => { + const store = await import("../../src/codex/account-store"); + const credential = { accessToken: "alias-access", refreshToken: "alias-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "alias-account" }; + const generation = store.saveCodexAccountCredential("owner", credential); + store.saveCodexAccountCredential("alias", credential); + const ownerIdentity = store.poolQuotaHistoryIdentity("owner"); + const aliasIdentity = store.poolQuotaHistoryIdentity("alias"); + expect(ownerIdentity).not.toBe(aliasIdentity); + const refreshed = { ...credential, accessToken: "alias-refreshed", refreshToken: "alias-new-refresh" }; + expect(store.commitRefreshedCodexCredentialWithAliases("owner", generation, refreshed).committed).toBe(true); + expect(store.poolQuotaHistoryIdentity("owner")).toBe(ownerIdentity); + expect(store.poolQuotaHistoryIdentity("alias")).toBe(aliasIdentity); + store.removeCodexAccountCredential("alias"); + expect(store.poolQuotaHistoryIdentity("alias")).toBeUndefined(); + store.saveCodexAccountCredential("alias", refreshed); + expect(store.poolQuotaHistoryIdentity("alias")).not.toBe(aliasIdentity); + }); + + test("legacy history identity initializes once without advancing credential generation or epoch", async () => { + const store = await import("../../src/codex/account-store"); + const { codexCredentialMutationEpoch } = await import("../../src/codex/credential-mutation-epoch"); + const credential = { accessToken: "legacy-history-access", refreshToken: "legacy-history-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "legacy-history-account" }; + writeFileSync(ACCOUNTS_PATH, JSON.stringify({ legacy: credential })); + const epoch = codexCredentialMutationEpoch(); + expect(store.poolQuotaHistoryIdentity("legacy")).toBeUndefined(); + expect(store.capturePoolQuotaWriter("legacy", { ...credential, generation: 1 })).toBeUndefined(); + expect(store.capturePoolQuotaWriter("legacy", { ...credential, accessToken: "wrong", generation: 0 })).toBeUndefined(); + expect(store.capturePoolQuotaWriter("legacy", { ...credential, chatgptAccountId: "wrong", generation: 0 })).toBeUndefined(); + const writer = store.capturePoolQuotaWriter("legacy", { ...credential, generation: 0 })!; + expect(store.capturePoolQuotaWriter("legacy", { ...credential, generation: 0 })).toEqual(writer); + expect(store.readCodexAccountRecord("legacy")?.generation).toBe(0); + expect(codexCredentialMutationEpoch()).toBe(epoch); + expect(store.loadCodexAccountStore()).toEqual({ legacy: credential }); + expect(JSON.stringify(writer)).not.toContain(credential.accessToken); + expect(JSON.stringify(writer)).not.toContain(credential.refreshToken); + expect(store.capturePoolQuotaWriter("__main__", { ...credential, generation: 0 })).toBeUndefined(); + }); + + test("malformed optional history metadata cannot discard an otherwise usable credential", async () => { + const store = await import("../../src/codex/account-store"); + const credential = { accessToken: "metadata-access", refreshToken: "metadata-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "metadata-account" }; + writeFileSync(ACCOUNTS_PATH, JSON.stringify({ metadata: { credential, generation: 3, quotaHistoryIdentity: 42 } })); + expect(store.getCodexAccountCredential("metadata")).toEqual(credential); + expect(store.poolQuotaHistoryIdentity("metadata")).toBeUndefined(); + expect(store.capturePoolQuotaWriter("metadata", { ...credential, generation: 3 })?.historyIdentity).toMatch(/^[a-f0-9-]{36}$/); + }); + + test("an identity-changing CAS does not retain history or propagate credentials to old aliases", async () => { + const store = await import("../../src/codex/account-store"); + const credential = { accessToken: "old-account-access", refreshToken: "shared-old-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "old-account" }; + const generation = store.saveCodexAccountCredential("owner", credential); + store.saveCodexAccountCredential("alias", credential); + const identity = store.poolQuotaHistoryIdentity("owner"); + const result = store.commitRefreshedCodexCredentialWithAliases("owner", generation, { ...credential, accessToken: "new-account-access", refreshToken: "new-refresh", chatgptAccountId: "new-account" }); + expect(result).toMatchObject({ committed: true, propagatedAliases: [] }); + expect(store.poolQuotaHistoryIdentity("owner")).not.toBe(identity); + expect(store.getCodexAccountCredential("alias")).toEqual(credential); + }); + test("save and load credential round-trip", async () => { const { saveCodexAccountCredential, getCodexAccountCredential } = await import("../../src/codex/account-store"); const cred = { accessToken: "tk_a", refreshToken: "rf_a", expiresAt: Date.now() + 3600_000, chatgptAccountId: "acc_a" }; From 7f3ba610329bed0ccd6df1e05a0ff12adc1ab458 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 21:21:44 +0900 Subject: [PATCH 3/8] test(codex): include plan exclusion in next-session badge coverage --- .../022_eligibility_ci_repair.md | 7 +++++++ .../codex-account-pool-pinned-badge.test.tsx | 18 ++++++++++++++++++ tests/gui/rate-limit-reset-credits.test.ts | 4 ++-- 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 devlog/_plan/260912_accounts/022_eligibility_ci_repair.md diff --git a/devlog/_plan/260912_accounts/022_eligibility_ci_repair.md b/devlog/_plan/260912_accounts/022_eligibility_ci_repair.md new file mode 100644 index 0000000000..eb33329f4c --- /dev/null +++ b/devlog/_plan/260912_accounts/022_eligibility_ci_repair.md @@ -0,0 +1,7 @@ +# Eligibility hosted regression repair + +Exact-head run34680496052 at a1f24df5ed90848f32d2499303b22620d91eed42 failed in Linux test4/4 job103523074988 and macOS2/2 job103523074889. The reset-ticket source oracle still required the old next-session guard without plan exclusion. The implementation correctly retained all health guards and added plan exclusion. + +The oracle now also requires the plan-exclusion guard, preserving ticket co-render and all health checks. A rendered regression fixture confirms eligible accounts show next-session and tickets together; excluded accounts retain tickets and omit next-session. No production code changed. No assertion was removed or loosened. Local suites/build/typecheck/install NOT RUN; remote final-head verification follows. + +Other failures in these runs concern Cline registry/localization/asset/test-layout and native history restoration. They are recorded in task scratch with exact job logs for owner integration; no other-lane files were changed. The parent-updated branch was fast-forwarded without rebase or merge commit. Hostgoal remains blocked and FSMB is unchanged; no new completed PABCD cycle is claimed for this source repair. diff --git a/gui/tests/codex-account-pool-pinned-badge.test.tsx b/gui/tests/codex-account-pool-pinned-badge.test.tsx index bb3e616491..c622a5f793 100644 --- a/gui/tests/codex-account-pool-pinned-badge.test.tsx +++ b/gui/tests/codex-account-pool-pinned-badge.test.tsx @@ -299,3 +299,21 @@ test("plan exclusion is visible without presenting the account as the next autom }); expect(cardFor(account.email).textContent).not.toContain(en["codexAuth.planExcluded"]); }); + + +test("eligible next-session badge coexists with reset tickets while plan exclusion only removes selection", async () => { + const eligible = { ...account, plan: "plus", quota: { weeklyPercent: 10, resetCredits: 2, updatedAt: Date.now() } }; + await mountPool(makeController({ accounts: [mainAccount, eligible], activeId: eligible.id })); + const current = cardFor(account.email); + expect([...current.querySelectorAll(".badge")].some(el => el.textContent === en["codexAuth.nextSession"])).toBe(true); + expect(current.querySelector(".badge-clickable")).not.toBeNull(); + await act(async () => { + root!.render(); + }); + const excluded = cardFor(account.email); + expect([...excluded.querySelectorAll(".badge")].some(el => el.textContent === en["codexAuth.nextSession"])).toBe(false); + expect(excluded.querySelector(".badge-clickable")).not.toBeNull(); +}); diff --git a/tests/gui/rate-limit-reset-credits.test.ts b/tests/gui/rate-limit-reset-credits.test.ts index a65445e3cc..a34de6dc8e 100644 --- a/tests/gui/rate-limit-reset-credits.test.ts +++ b/tests/gui/rate-limit-reset-credits.test.ts @@ -302,8 +302,8 @@ describe("rate-limit reset credits", () => { expect(source).toContain("className=\"card-badges\""); expect(source).toContain(" onOpenReset(a)} />"); // Next-session still renders BESIDE the ticket; health projection also suppresses - // it for projected reauth/cooldown and pending validation. - expect(source).toContain("{isNext(a) && !showReauth && !inCooldown && !validationPending && ("); + // it for plan exclusion, projected reauth/cooldown and pending validation. + expect(source).toContain("{isNext(a) && !planExcluded && !showReauth && !inCooldown && !validationPending && ("); 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 a3dae346ee47d2e178f2526c4290279bb454b207 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:41:29 +0900 Subject: [PATCH 4/8] docs: refine fenced quota history contracts for the follow-up --- devlog/_plan/260912_accounts/050_history.md | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/devlog/_plan/260912_accounts/050_history.md b/devlog/_plan/260912_accounts/050_history.md index dd96c9019e..651a7a1c35 100644 --- a/devlog/_plan/260912_accounts/050_history.md +++ b/devlog/_plan/260912_accounts/050_history.md @@ -23,3 +23,25 @@ Reflection REF-04: fixed aggregate bounds: 64 account identities, 4096 rows, 2 M A1 accepted: native main history is deliberately NOT hydrated from disk in this slice. It can be sampled in-process only after identity observation and cleared on identity change; persistence omits __main__. Pool history envelopes bind a stable private publication UUID; hydration prunes identity mismatches, while ordinary generation changes on refresh retain prior observations. This avoids attributing offline identity replacements to an old main label. Acceptance explicitly covers main replacement while stopped and account-id reuse. Main cross-restart history remains a documented limitation; bounded durable history is provided for stored pool accounts. P refinement depends on new048 history-identity cycle. Adopt HIST-01..06: generation gates each physical sample; private random publication UUID persists through refresh and changes on explicit save. Capture PoolQuotaWriter before upstream calls, refresh it after replay token resolution, and forward through every WHAM/WS/HTTP/compact/warmup path. Omit staged login/reauth samples until first post-publication fenced observation; do not retrofit ambient provenance. Native main is excluded from durable endpoint/capacity in this slice. Raw QuotaObservation carries observedAt, wham|response-header source, bounded windows with account|spark family and short|weekly|monthly name, percentage/resetAtMs/duration/primary provenance; no arbitrary upstream label. Envelope private identity binds samples but is omitted from read DTO. Retain best-effort single-writer atomic cache semantics; no multi-process merge/durability claim. Read endpoint GET /api/codex-auth/quota/history?accountId=&limit=<1..200>; CLI ocx account history openai [--limit N] [--json]. Unknown/deleted404, invalid/duplicate selector400, emptyhistory200. No upstream call on reads. + +## Executable history child contract after identity foundation D + +Parent PR4375/e9007429c5 provides PoolQuotaWriter and store capture/live/retention helpers. This child depends on that branch; the capacity child follows this one. Previous D delivered only identity and deferred hosted proof. + +NEW src/codex/quota-history.ts, pure leaf (imports quota types and pure account-id only): closed HistoryWindow family account|spark, window short|weekly|monthly, usedPercent, optional resetAtMs/windowSeconds/monthlyIsPrimaryWindow; HistorySample observedAt/source/credentialGeneration/windows; private envelope identity/samples. CodexQuotaHistory owns append/hydrate/read/clear/reconcile/serialize. Keep 200 samples/account,30days,64accounts,4096samples,2MiB conservative serialized-byte budget; max5 windows/sample. Track per-sample byte costs incrementally, evict by observedAt then accountId and insertion order. Hydration admits only bounded validated rows (at most64 sorted account keys and last200 rows per account), then global bounds. Unknown fields/labels never survive. Read returns deep copies; private identity never reaches API. No filesystem/config/store import in the leaf. + +MODIFY quota.ts: own the history instance and optional history:{version:1,accounts:{...}} in existing quota-cache version1. Hydrate history before latest-quota six-hour TTL filtering; native-main never hydrates/records in this durable layer. Replace unbounded file allocation with a local fd/readSync loop capped at4MiB+1; oversized/corrupt cache is a cache miss, never an inference failure. Keep latest in-memory state untouched. Existing debounced atomic persistence serializes bounded history, so no new timer/store and no multi-process merge claim. Clear and roster reconcile remove history-only identities too; read compares current store UUID before returning, even after offline replacement. + +setAccountQuotaFromParsed gains optional sixth QuotaObservationEvidence {writer,observedAt,source,raw}. After config/main write guards, append only when writer.accountId matches and isPoolQuotaWriterLive. Convert only fresh raw percentages into closed history windows, normalizing resets with resetAtToMs. Account short/weekly/monthly map directly; Spark uses existing short label plus a new canonical weekly-label constant shared with the WHAM parser. No arbitrary custom labels. Credits-only/metadata-only updates append nothing. The legacy latest-snapshot merge remains unchanged. applyAccountQuotaFromUpstreamHeaders options adds poolWriter; builds evidence from original parse result BEFORE custom-window carry. Missing writer/evidence preserves latest cache but appends no trusted sample. + +MODIFY auth-context.ts pool union with poolQuotaWriter?:PoolQuotaWriter, capture immediately after getValidCodexToken before dispatch. MODIFY core.ts WS closure, rejected-first response, ordinary HTTP, and refreshedAuthCtx to forward/re-capture exact serving writer; compact.ts refresh/rejection follows same rule. MODIFY quota-auto-refresh.ts pool warmup captures before I/O. MODIFY auth-api.ts WHAM initial and refreshed replay capture before fetch, commitPoolQuotaResponse carries writer and sends raw parsed result with observedAt after JSON read; keep all current generation/mayPublish checks. Staged login quota writes intentionally omit history until a post-publication observation; native main and legacy updateAccountQuota omit it. No token material is added to response objects/logs. + +GET /api/codex-auth/quota/history?accountId=&limit=<1..200> is read-only cached data, no upstream/auth refresh/warmup. Add before existing /quota handler; registry entry+capability map. Validate exactly one accountId, optional single numeric limit and no unknown query fields. Invalid/main400, unknown configured pool404, known account200 even empty. DTO: {accountId,observations:[{observedAt,source,windows}],retention:{maxObservations:200,maxAgeDays:30},truncated:boolean}; omit UUID and credential generation. Public array follows ascending observed time, limit chooses newest rows. Capacity is added only in next child. + +NEW src/cli/account-history.ts exports cmdAccountHistory(args,deps). Shape `ocx account history openai [--limit N] [--json]`; reject other provider/main/extraargs before any network. Use resolveBaseUrl/apiJson/apiError/proxyUnreachable from account-api owner. JSON prints DTO; human output prints observed time/source/window/percent/reset and no-observation state. Wire lazy dispatcher and help/capabilities; source-only skill surface generator allowed (not product suite). + +Tests: new pure codex-quota-history.test.ts (register both layout maps), existing quota-store integration hydration harness for raw-vs-carried, writer mismatch/refresh/replacement, stage omission, native omission, clear/reconcile and disk limits; authenticated server route tests+CLI transport fixture. No local runtime execution. All touched source-area ownership docs and English+Korean account command docs synchronized; other translations must not contradict additions. + +Read unavailability refinement: undefined current identity (legacy/missing/unreadable) returns empty/unavailable evidence without deleting a retained envelope. Only a confirmed different UUID or authoritative roster removal clears it; this avoids transient permission/read errors destroying history. Restored matching identity may expose retained valid rows again. Cache eviction/expiry remains bounded. + +Deferred history-plan review findings (actual A entry was refused because persisted active work phase is tun): reuse pre-clamp invalid-percentage checking for all WHAM primary/secondary/tertiary and additional Spark windows, and response-header raw usage fields; any invalid numeric/nonfinite/out-of-range percentage omits the ENTIRE trusted observation while leaving legacy display behavior unchanged. Add before-clamp history parser/evidence guard so clamped values cannot masquerade as measured percentages. Hydration rejects an entire over-limit history payload (>64accounts,>200rows/account,>4096rows,>2MiB) instead of slicing by lexical key/array position; bounded accepted rows are sorted by observedAt before retention. Tests include65th-newestaccount and unordered rows. These need fresh independent A review when history resumes. From e2d213b3f13d6fc956efe014f5d7bc4341d7e09c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 21:36:12 +0900 Subject: [PATCH 5/8] feat(codex): retain bounded credential-fenced quota observations --- devlog/_plan/260912_accounts/050_history.md | 4 +- .../260912_accounts/051_history_delivery.md | 7 + .../ko/reference/cli/providers-accounts.md | 6 + .../docs/reference/cli/providers-accounts.md | 6 + scripts/test-layout/layout.json | 1 + .../ocx/references/01_management_surface.md | 19 ++- src/cli/account-history.ts | 39 +++++ src/cli/account.ts | 5 + src/cli/capabilities.ts | 12 ++ src/codex/auth-api.ts | 27 ++- src/codex/auth-context.ts | 4 + src/codex/quota-auto-refresh.ts | 5 +- src/codex/quota-history.ts | 160 ++++++++++++++++++ src/codex/quota.ts | 101 ++++++++++- src/server/management/route-registry.ts | 1 + src/server/responses/compact.ts | 10 +- src/server/responses/core.ts | 8 +- structure/adapters/registry.md | 2 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/codex-home.md | 2 + structure/config.md | 2 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/ops/service-and-sidecars.md | 2 + structure/overview.md | 2 + structure/providers/openai-tiers.md | 8 + structure/providers/xai-grok.md | 2 + structure/runtime.md | 2 + structure/subagents.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + structure/transports/streaming-health.md | 2 + tests/cli/cli-account.test.ts | 16 ++ .../codex-quota-history.test.ts | 81 +++++++++ .../main-quota-provenance.test.ts | 50 ++++++ tests/fixtures/test-layout-expected.json | 1 + .../responses-compaction-routing.test.ts | 25 +++ .../account-pool-management-api.test.ts | 24 +++ 41 files changed, 635 insertions(+), 19 deletions(-) create mode 100644 devlog/_plan/260912_accounts/051_history_delivery.md create mode 100644 src/cli/account-history.ts create mode 100644 src/codex/quota-history.ts create mode 100644 tests/codex-integration/codex-quota-history.test.ts diff --git a/devlog/_plan/260912_accounts/050_history.md b/devlog/_plan/260912_accounts/050_history.md index 651a7a1c35..1e870236fc 100644 --- a/devlog/_plan/260912_accounts/050_history.md +++ b/devlog/_plan/260912_accounts/050_history.md @@ -28,7 +28,7 @@ P refinement depends on new048 history-identity cycle. Adopt HIST-01..06: genera Parent PR4375/e9007429c5 provides PoolQuotaWriter and store capture/live/retention helpers. This child depends on that branch; the capacity child follows this one. Previous D delivered only identity and deferred hosted proof. -NEW src/codex/quota-history.ts, pure leaf (imports quota types and pure account-id only): closed HistoryWindow family account|spark, window short|weekly|monthly, usedPercent, optional resetAtMs/windowSeconds/monthlyIsPrimaryWindow; HistorySample observedAt/source/credentialGeneration/windows; private envelope identity/samples. CodexQuotaHistory owns append/hydrate/read/clear/reconcile/serialize. Keep 200 samples/account,30days,64accounts,4096samples,2MiB conservative serialized-byte budget; max5 windows/sample. Track per-sample byte costs incrementally, evict by observedAt then accountId and insertion order. Hydration admits only bounded validated rows (at most64 sorted account keys and last200 rows per account), then global bounds. Unknown fields/labels never survive. Read returns deep copies; private identity never reaches API. No filesystem/config/store import in the leaf. +NEW src/codex/quota-history.ts, pure leaf (imports quota types and pure account-id only): closed HistoryWindow family account|spark, window short|weekly|monthly, usedPercent, optional resetAtMs/windowSeconds/monthlyIsPrimaryWindow; HistorySample observedAt/source/credentialGeneration/windows; private envelope identity/samples. CodexQuotaHistory owns append/hydrate/read/clear/reconcile/serialize. Keep 200 samples/account,30days,64accounts,4096samples,2MiB conservative serialized-byte budget; max5 windows/sample. Track per-sample byte costs incrementally, evict by observedAt then accountId and insertion order. Hydration rejects an over-limit envelope before admitting rows (>64 accounts, >200 rows/account, >4096 total samples or >2MiB serialized payload); accepted rows are validated and sorted by timestamp before age retention. Unknown fields/labels never survive. Read returns deep copies; private identity never reaches API. No filesystem/config/store import in the leaf. MODIFY quota.ts: own the history instance and optional history:{version:1,accounts:{...}} in existing quota-cache version1. Hydrate history before latest-quota six-hour TTL filtering; native-main never hydrates/records in this durable layer. Replace unbounded file allocation with a local fd/readSync loop capped at4MiB+1; oversized/corrupt cache is a cache miss, never an inference failure. Keep latest in-memory state untouched. Existing debounced atomic persistence serializes bounded history, so no new timer/store and no multi-process merge claim. Clear and roster reconcile remove history-only identities too; read compares current store UUID before returning, even after offline replacement. @@ -45,3 +45,5 @@ Tests: new pure codex-quota-history.test.ts (register both layout maps), existin Read unavailability refinement: undefined current identity (legacy/missing/unreadable) returns empty/unavailable evidence without deleting a retained envelope. Only a confirmed different UUID or authoritative roster removal clears it; this avoids transient permission/read errors destroying history. Restored matching identity may expose retained valid rows again. Cache eviction/expiry remains bounded. Deferred history-plan review findings (actual A entry was refused because persisted active work phase is tun): reuse pre-clamp invalid-percentage checking for all WHAM primary/secondary/tertiary and additional Spark windows, and response-header raw usage fields; any invalid numeric/nonfinite/out-of-range percentage omits the ENTIRE trusted observation while leaving legacy display behavior unchanged. Add before-clamp history parser/evidence guard so clamped values cannot masquerade as measured percentages. Hydration rejects an entire over-limit history payload (>64accounts,>200rows/account,>4096rows,>2MiB) instead of slicing by lexical key/array position; bounded accepted rows are sorted by observedAt before retention. Tests include65th-newestaccount and unordered rows. These need fresh independent A review when history resumes. + +Implementation review HIST-IMPL-01 accepted: compact final response now records actualoutcomeCtx poolwriter beforebuffering, coveringordinary/401replay/alternate; rejectedfirstaccount retains its separateexistingwrite, so everyresponse contributesonce. Add compactregression withquotaheaders onoriginalsuccess andA429→Bsuccess. This sourcework is user-authorized whilehostgoal remainsblocked; no FSM A/B/C/D advancement claimed. diff --git a/devlog/_plan/260912_accounts/051_history_delivery.md b/devlog/_plan/260912_accounts/051_history_delivery.md new file mode 100644 index 0000000000..3ae2698d79 --- /dev/null +++ b/devlog/_plan/260912_accounts/051_history_delivery.md @@ -0,0 +1,7 @@ +# Bounded raw quota history implementation + +Extends publication identity foundation #4375 with a pure bounded history leaf, existing quota-cache persistence, fenced WHAM/HTTP/WS/compact/warmup producers, a management read route and account history CLI. Invalid upstream percentages never become trusted samples after display clamping. Native-main, staged-login and legacy unproven setters are omitted. + +Regression sources cover chronological retention, limits/corrupt disk, private-field stripping, generation/identity changes, raw-versus-carried windows, cached API auth/validation, CLI argument rejection and compact serving-account attribution. Independent source review identified missing compact final-response capture; it was added with ordinary/alternate regressions. CLI skill surface regenerated by its source-only generator, not a product build or suite. Local suites/build/typecheck/install NOT RUN. + +This child targets the existing history-identity branch at19cbe826d8. The pending plan-only commit was rebased onto the parent-updated branch; foundation product bytes were unchanged. Host goal remains blocked; actual FSMB(tun) remains untouched under explicit user instruction. These are authorized source implementation and independent reviews, not a claimed new persisted PABCD cycle. Complete hosted verification belongs to the eventual cumulative history/capacity tip; no merge or issue closure. diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 0fc4bc2608..4f832d3dbb 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -364,3 +364,9 @@ ocx models remove deepseek/deepseek-v4 --yes 슬래시가 있는 모델 선택기는 라우팅됩니다(`anthropic/claude-opus-5`). 슬래시가 없는 id는 native OpenAI 모델로 취급되므로, 라우팅된 것처럼 보일 수 있는 id에 대해 그 읽기를 강제하려면 `--native`가 필요합니다. `--modalities`는 `text`, `image`, `audio`만 허용합니다. Codex는 이 필드를 닫힌 enum으로 해석하고 다른 값이 하나라도 있으면 카탈로그 전체를 거부하므로, `add`, `edit`, 관리 API는 나중에 카탈로그 작성기가 정리해야 할 값을 저장하지 않도록 잘못된 값을 바로 거부합니다(#759). + +### 저장된 쿼터 기록 + +`ocx account history openai [--limit 1-200] [--json]`은 제공자에게 요청하지 않고 저장된 관측을 읽습니다. 관측 시각, WHAM·응답 헤더 출처, 한도 종류와 사용률을 구분해 표시합니다. 계정마다 최대 200개를 30일간 보관하며 전체 저장량에도 제한이 있습니다. + +일반 토큰 갱신은 기록을 유지합니다. 재로그인·삭제·계정 교체는 이전 기록과 분리합니다. 네이티브 메인 계정과 로그인 저장 전 조회는 포함하지 않습니다. 기록이 없다는 것은 관측 부족이며 사용량 0을 뜻하지 않습니다. 이 명령은 토큰 용량을 추정하거나 쿼터를 소비하지 않습니다. diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index f7e94a1833..f39c1f3a57 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -581,3 +581,9 @@ otherwise look routed. and rejects an entire catalog containing any other value, so `add`, `edit`, and the management API all refuse the bad value rather than storing something the catalog writer would have to strip later (#759). + +### Cached quota history + +`ocx account history openai [--limit 1-200] [--json]` reads stored observations without contacting the provider. The output separates actual observation time, WHAM or response-header source, window family and usage percentage. At most 200 observations per account are retained for 30 days, with global storage bounds. + +Ordinary token refresh preserves history. Reauthentication, removal or account replacement retires the old publication. Native main and probes performed before a login is published are not included. Missing history means insufficient observations, not zero usage. This command does not estimate token capacity or spend quota. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 8f40c0714d..3691347aa2 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -479,6 +479,7 @@ "codex-prompt-text-probe.test.ts": "codex-integration", "codex-quota-auto-refresh-main-admission.test.ts": "codex-integration", "codex-quota-auto-refresh.test.ts": "codex-integration", + "codex-quota-history.test.ts": "codex-integration", "codex-quota-parser-parity.test.ts": "codex-integration", "codex-quota-prime.test.ts": "codex-integration", "codex-quota-rejection.test.ts": "codex-integration", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index f9fec70034..4cf010e1fc 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -104,6 +104,23 @@ Recently detected quota resets and whether reset notifications are enabled. JSON mode: `payload`. +### `ocx account history` + +Cached quota observations for one stored Codex pool account. + +| Method | Route | +|---|---| +| GET | `/api/codex-auth/quota/history` | + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the bounded observation history. | +| `--limit` | number | Return the newest 1 to 200 observations. | + +JSON mode: `payload`. + +- Use account history openai . Reads cached observations only; no refresh or warmup. Native main is not included. + ### `ocx account list` Codex OAuth accounts with pool priority and pause state. @@ -769,6 +786,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 41 +- declared capabilities: 42 - of those, state-changing: 20 - head-resolved invocations: 2 diff --git a/src/cli/account-history.ts b/src/cli/account-history.ts new file mode 100644 index 0000000000..fb813cbdbf --- /dev/null +++ b/src/cli/account-history.ts @@ -0,0 +1,39 @@ +import { isValidCodexAccountId } from "../codex/account-id"; +import { apiError, apiJson, proxyUnreachable, resolveBaseUrl, type AccountDeps } from "./account-api"; + +/** Read cached pool observations without refreshing credentials or spending quota. */ +export async function cmdAccountHistory(args: string[], deps: AccountDeps): Promise { + const [provider, accountId, ...flags] = args; + let json = false; + let limit = 200; + let hasLimit = false; + let valid = provider === "openai" && isValidCodexAccountId(accountId); + for (let index = 0; index < flags.length; index++) { + if (flags[index] === "--json" && !json) json = true; + else if (flags[index] === "--limit" && !hasLimit && /^(?:[1-9]|[1-9][0-9]|1[0-9]{2}|200)$/.test(flags[index + 1] ?? "")) { + limit = Number(flags[++index]); hasLimit = true; + } else valid = false; + } + if (!valid) { + console.error("Usage: ocx account history openai [--limit <1-200>] [--json]"); + return 1; + } + const baseUrl = await resolveBaseUrl(deps); + if (!baseUrl) return proxyUnreachable(); + const result = await apiJson(deps, baseUrl, "GET", `/api/codex-auth/quota/history?accountId=${encodeURIComponent(accountId)}&limit=${limit}`); + if (result.status === 0) return proxyUnreachable(result.transportError); + if (result.status !== 200) return apiError(result.json, "Quota history unavailable", result.status); + if (json) { console.log(JSON.stringify(result.json, null, 2)); return 0; } + const observations = result.json.observations; + if (!Array.isArray(observations)) return apiError({}, "Invalid quota history response", 502); + console.log("OBSERVED\tSOURCE\tWINDOW\tUSED\tRESET"); + if (!observations.length) console.log("No quota observations for this credential publication."); + for (const observation of observations) { + if (!observation || typeof observation !== "object" || !Array.isArray(observation.windows) + || !Number.isFinite(observation.observedAt)) return apiError({}, "Invalid quota history response", 502); + for (const window of observation.windows) { + console.log(`${new Date(observation.observedAt).toISOString()}\t${observation.source}\t${window.family}/${window.window}\t${window.usedPercent}%\t${typeof window.resetAtMs === "number" ? new Date(window.resetAtMs).toISOString() : "unknown"}`); + } + } + return 0; +} diff --git a/src/cli/account.ts b/src/cli/account.ts index 4a8c6a0427..5383707aba 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -41,6 +41,7 @@ const REPLACEMENT_STYLE_OAUTH = new Set(); const ACCOUNT_USAGE = `Usage: ocx account list [provider] [--json] [--all] [--quota [--refresh]] + ocx account history openai [--limit <1-200>] [--json] ocx account current [--json] ocx account use [--json] ocx account refresh [--json] @@ -335,6 +336,10 @@ export async function cmdAccount(args: string[], deps: AccountDeps = {}): Promis const [sub, ...rest] = args; try { if (sub === "list") return await cmdList(rest, deps); + if (sub === "history") { + const { cmdAccountHistory } = await import("./account-history"); + return await cmdAccountHistory(rest, deps); + } if (sub === "current") return await cmdCurrent(rest, deps); if (sub === "use") return await cmdUse(rest, deps); if (sub === "refresh") return await cmdRefresh(rest, deps); diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 63cf313676..add59eeb74 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -235,6 +235,18 @@ export const CAPABILITIES: readonly Capability[] = [ "Headless services usually have no unlocked keychain session; prefer ${ENV_VAR} references there.", ], }, + { + command: ["account", "history"], + summary: "Cached quota observations for one stored Codex pool account.", + routes: [{ method: "GET", path: "/api/codex-auth/quota/history" }], + flags: [ + { name: "--json", value: "boolean", summary: "Emit the bounded observation history." }, + { name: "--limit", value: "number", summary: "Return the newest 1 to 200 observations." }, + ], + mutates: false, + json: "payload", + details: ["Use account history openai . Reads cached observations only; no refresh or warmup. Native main is not included."], + }, { command: ["account", "list"], summary: "Codex OAuth accounts with pool priority and pause state.", diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 09becf51ea..4c112d03b0 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -1,3 +1,6 @@ +import { capturePoolQuotaWriter } from "./account-store"; +import type { PoolQuotaWriter } from "./quota-types"; +import { getAccountQuotaHistory, isValidWhamHistoryObservation } from "./quota"; import { ConfigMutationLockError, loadConfig, @@ -1358,6 +1361,7 @@ async function recoverPoolQuotaFrom401(ctx: { const writerGeneration = captureConfigGeneration(); markQuotaProbeAttempted(ctx.quotaProbeEvidence, refreshed.generation); + const poolWriter = capturePoolQuotaWriter(accountId, refreshed); const replay = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers: { Authorization: `Bearer ${refreshed.accessToken}`, @@ -1376,7 +1380,7 @@ async function recoverPoolQuotaFrom401(ctx: { return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation }; } const result = await commitPoolQuotaResponse(replay, { - accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration, + accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration, poolWriter, mayPublish: ctx.quotaProbeEvidence.mayPublish, }); return result.freshCredentialGeneration === refreshed.generation ? { @@ -1418,11 +1422,13 @@ async function commitPoolQuotaResponse( configuredPlan: string | undefined; generation: number; writerGeneration: number; + poolWriter?: PoolQuotaWriter; mayPublish?: () => boolean; }, ): Promise { const { accountId, existing, configuredPlan, generation, writerGeneration } = ctx; const data = (await resp.json()) as WhamUsageResponse; + const observedAt = Date.now(); if (ctx.mayPublish?.() === false) { return { quota: getAccountQuota(accountId), needsReauth: false, credentialGeneration: generation }; } @@ -1440,7 +1446,8 @@ async function commitPoolQuotaResponse( if (!isCodexAccountGenerationLive(accountId, generation)) { return { quota: null, needsReauth: false, credentialGeneration: generation }; } - setAccountQuotaFromParsed(accountId, quota, writerGeneration); + setAccountQuotaFromParsed(accountId, quota, writerGeneration, undefined, quota, + ctx.poolWriter && isValidWhamHistoryObservation(data) ? { writer: ctx.poolWriter, observedAt, source: "wham", raw: quota } : undefined); return { quota: getAccountQuota(accountId), needsReauth: false, @@ -1464,6 +1471,7 @@ async function fetchFreshPoolAccountQuota( let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation; try { const { accessToken, chatgptAccountId, generation } = await getValidToken(accountId); + const poolWriter = capturePoolQuotaWriter(accountId, { accessToken, chatgptAccountId, generation }); requestCredentialGeneration = generation; onCredentialGeneration?.(generation); markQuotaProbeAttempted(quotaProbeEvidence, generation); @@ -1494,7 +1502,7 @@ async function fetchFreshPoolAccountQuota( return withQuotaProbeEvidence(recovered, quotaProbeEvidence); } const committed = await commitPoolQuotaResponse(resp, { - accountId, existing, configuredPlan, generation, writerGeneration, + accountId, existing, configuredPlan, generation, writerGeneration, poolWriter, mayPublish: quotaProbeEvidence.mayPublish, }); return withQuotaProbeEvidence(committed, quotaProbeEvidence); @@ -2526,6 +2534,19 @@ export async function handleCodexAuthAPI( return jsonResponse({ ok: true }); } + if (url.pathname === "/api/codex-auth/quota/history" && req.method === "GET") { + const accountId = url.searchParams.get("accountId"); + const rawLimit = url.searchParams.get("limit"); + if (url.searchParams.getAll("accountId").length !== 1 || !isValidCodexAccountId(accountId) + || url.searchParams.getAll("limit").length > 1 + || [...url.searchParams.keys()].some(key => key !== "accountId" && key !== "limit") + || (rawLimit !== null && !/^(?:[1-9]|[1-9][0-9]|1[0-9]{2}|200)$/.test(rawLimit))) { + return jsonResponse({ error: "A stored pool accountId and optional limit from 1 to 200 are required" }, 400); + } + if (!configuredPoolAccount(getRuntimeConfig(config), accountId)) return jsonResponse({ error: "Unknown pool account" }, 404); + return jsonResponse({ accountId, ...getAccountQuotaHistory(accountId, rawLimit === null ? 200 : Number(rawLimit)) }); + } + if (url.pathname === "/api/codex-auth/quota" && req.method === "GET") { const quotas: Record = {}; for (const [id, q] of listAccountQuotas()) quotas[id] = q; diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 2cb97df2e9..007d6866f6 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1,3 +1,4 @@ +import type { PoolQuotaWriter } from "./quota-types"; import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { CodexCredentialGenerationConflictError, @@ -6,6 +7,7 @@ import { CodexCredentialRefreshStaleError, getCodexAccountCredential, getValidCodexToken, + capturePoolQuotaWriter, isCodexAccountGenerationLive, readCodexAccountRecord, } from "./account-store"; @@ -120,6 +122,7 @@ export type CodexAuthContext = accountId: string; writerGeneration: number; generation: number; + poolQuotaWriter?: PoolQuotaWriter; accessToken: string; chatgptAccountId: string; /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */ @@ -1030,6 +1033,7 @@ export async function resolveCodexAuthContext( accountId, writerGeneration, generation: token.generation, + poolQuotaWriter: capturePoolQuotaWriter(accountId, token), accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}), diff --git a/src/codex/quota-auto-refresh.ts b/src/codex/quota-auto-refresh.ts index ce11de97e4..cf21a46e16 100644 --- a/src/codex/quota-auto-refresh.ts +++ b/src/codex/quota-auto-refresh.ts @@ -8,7 +8,7 @@ import { isSelectableCodexPoolAccount } from "./account-id"; import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; import { isCodexAccountPaused } from "./account-pause"; import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; -import { getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store"; +import { capturePoolQuotaWriter, getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store"; import { codexAccountLogLabel } from "./account-label"; import { getMainAccountToken, getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import { isMainAccountHardLocked } from "./main-account-hard-lock"; @@ -170,10 +170,11 @@ async function warmAccount(config: OcxConfig, accountId: string): Promise { if (isCodexAccountGenerationLive(accountId, token.generation)) { - applyAccountQuotaFromUpstreamHeaders(accountId, headers, writerGeneration); + applyAccountQuotaFromUpstreamHeaders(accountId, headers, writerGeneration, undefined, { poolWriter }); } } }); } catch (error) { diff --git a/src/codex/quota-history.ts b/src/codex/quota-history.ts new file mode 100644 index 0000000000..a792f89597 --- /dev/null +++ b/src/codex/quota-history.ts @@ -0,0 +1,160 @@ +import { isValidCodexAccountId } from "./account-id"; +import type { PoolQuotaWriter } from "./quota-types"; + +export const QUOTA_HISTORY_LIMITS = { perAccount: 200, accounts: 64, samples: 4096, bytes: 2 * 1024 * 1024, ageMs: 30 * 86400_000 } as const; +export interface QuotaHistoryWindow { + family: "account" | "spark"; + window: "short" | "weekly" | "monthly"; + usedPercent: number; + resetAtMs?: number; + windowSeconds?: number; + monthlyIsPrimaryWindow?: boolean; +} +export interface QuotaHistorySample { + observedAt: number; + source: "wham" | "response-header"; + credentialGeneration: number; + windows: QuotaHistoryWindow[]; +} +type Envelope = { identity: string; samples: QuotaHistorySample[] }; +type Bucket = Envelope & { costs: number[]; overhead: number }; +const encoder = new TextEncoder(); +const byteSize = (value: unknown) => encoder.encode(JSON.stringify(value)).byteLength; +const identityPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const record = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); +const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0; + +/** Reconstruct allowlisted data at the disk boundary; malformed windows cannot be partial evidence. */ +function parseSample(value: unknown, now: number): QuotaHistorySample | undefined { + if (!record(value) || !finite(value.observedAt) || value.observedAt > now + || value.observedAt < now - QUOTA_HISTORY_LIMITS.ageMs + || !Number.isSafeInteger(value.credentialGeneration) || (value.credentialGeneration as number) < 0 + || (value.source !== "wham" && value.source !== "response-header") + || !Array.isArray(value.windows) || !value.windows.length || value.windows.length > 5) return undefined; + const windows: QuotaHistoryWindow[] = []; + const seen = new Set(); + for (const item of value.windows) { + if (!record(item) || (item.family !== "account" && item.family !== "spark") + || (item.window !== "short" && item.window !== "weekly" && item.window !== "monthly") + || (item.family === "spark" && item.window === "monthly") + || !finite(item.usedPercent) || item.usedPercent > 100) return undefined; + const key = `${item.family}:${item.window}`; + if (seen.has(key)) return undefined; + seen.add(key); + if ((item.resetAtMs !== undefined && !finite(item.resetAtMs)) + || (item.windowSeconds !== undefined && (!finite(item.windowSeconds) || item.windowSeconds === 0)) + || (item.monthlyIsPrimaryWindow !== undefined && typeof item.monthlyIsPrimaryWindow !== "boolean")) return undefined; + windows.push({ family: item.family, window: item.window, usedPercent: item.usedPercent, + ...(item.resetAtMs !== undefined ? { resetAtMs: item.resetAtMs as number } : {}), + ...(item.windowSeconds !== undefined ? { windowSeconds: item.windowSeconds as number } : {}), + ...(item.monthlyIsPrimaryWindow === true && item.family === "account" && item.window === "monthly" ? { monthlyIsPrimaryWindow: true } : {}), + }); + } + return { observedAt: value.observedAt, source: value.source, credentialGeneration: value.credentialGeneration as number, windows }; +} + +/** Bounded in-process observations. The quota cache owns persistence and credential admission. */ +export class CodexQuotaHistory { + private accounts = new Map(); + private bytes = 32; + private count = 0; + + append(writer: PoolQuotaWriter, sample: QuotaHistorySample, now = Date.now()): void { + if (!isValidCodexAccountId(writer.accountId) || !identityPattern.test(writer.historyIdentity) + || sample.credentialGeneration !== writer.credentialGeneration) return; + const parsed = parseSample(sample, now); + if (!parsed) return; + let bucket = this.accounts.get(writer.accountId); + if (bucket && bucket.identity !== writer.historyIdentity) { this.clear(writer.accountId); bucket = undefined; } + if (!bucket) { + const overhead = byteSize(writer.accountId) + byteSize({ identity: writer.historyIdentity, samples: [] }) + 8; + bucket = { identity: writer.historyIdentity, samples: [], costs: [], overhead }; + this.accounts.set(writer.accountId, bucket); + this.bytes += overhead; + } + const index = bucket.samples.findIndex(row => row.observedAt > parsed.observedAt); + const position = index < 0 ? bucket.samples.length : index; + const cost = byteSize(parsed) + 1; + bucket.samples.splice(position, 0, parsed); + bucket.costs.splice(position, 0, cost); + this.bytes += cost; + this.count++; + while (bucket.samples.length > QUOTA_HISTORY_LIMITS.perAccount) this.dropFirst(writer.accountId); + this.prune(now); + } + + read(accountId: string, identity: string | undefined, now = Date.now(), limit: number = QUOTA_HISTORY_LIMITS.perAccount): { samples: QuotaHistorySample[]; truncated: boolean } { + this.prune(now); + const bucket = this.accounts.get(accountId); + if (!identity || !bucket) return { samples: [], truncated: false }; + if (bucket.identity !== identity) { this.clear(accountId); return { samples: [], truncated: false }; } + const capped = Math.max(1, Math.min(QUOTA_HISTORY_LIMITS.perAccount, Math.trunc(limit))); + return { samples: structuredClone(bucket.samples.slice(-capped)), truncated: bucket.samples.length > capped }; + } + + clear(accountId?: string): number { + if (accountId === undefined) { + const count = this.accounts.size; + this.accounts.clear(); this.count = 0; this.bytes = 32; + return count; + } + const bucket = this.accounts.get(accountId); + if (!bucket) return 0; + this.bytes -= bucket.overhead + bucket.costs.reduce((a, b) => a + b, 0); + this.count -= bucket.samples.length; + this.accounts.delete(accountId); + return 1; + } + + reconcile(ids: ReadonlySet): number { + let removed = 0; + for (const id of this.accounts.keys()) if (!ids.has(id)) removed += this.clear(id); + return removed; + } + + serialize(now = Date.now()): { version: 1; accounts: Record } { + this.prune(now); + return { version: 1, accounts: Object.fromEntries([...this.accounts].map(([id, bucket]) => [id, + { identity: bucket.identity, samples: structuredClone(bucket.samples) }])) }; + } + + hydrate(value: unknown, now = Date.now()): void { + this.clear(); + if (!record(value) || value.version !== 1 || !record(value.accounts) || byteSize(value) > QUOTA_HISTORY_LIMITS.bytes) return; + const entries = Object.entries(value.accounts); + if (entries.length > QUOTA_HISTORY_LIMITS.accounts) return; + let count = 0; + for (const [id, envelope] of entries) { + if (!isValidCodexAccountId(id) || !record(envelope) || typeof envelope.identity !== "string" + || !identityPattern.test(envelope.identity) || !Array.isArray(envelope.samples) + || envelope.samples.length > QUOTA_HISTORY_LIMITS.perAccount) return; + count += envelope.samples.length; + if (count > QUOTA_HISTORY_LIMITS.samples) return; + } + for (const [accountId, raw] of entries) { + const envelope = raw as Envelope; + for (const sample of envelope.samples) { + const parsed = parseSample(sample, now); + if (parsed) this.append({ accountId, historyIdentity: envelope.identity, credentialGeneration: parsed.credentialGeneration }, parsed, now); + } + } + } + + private dropFirst(id: string): void { + const bucket = this.accounts.get(id)!; + this.bytes -= bucket.costs.shift()!; + bucket.samples.shift(); this.count--; + if (!bucket.samples.length) { this.bytes -= bucket.overhead; this.accounts.delete(id); } + } + + private prune(now: number): void { + for (const [id, bucket] of this.accounts) { + while (bucket.samples.length && bucket.samples[0].observedAt < now - QUOTA_HISTORY_LIMITS.ageMs) this.dropFirst(id); + } + while (this.accounts.size > QUOTA_HISTORY_LIMITS.accounts || this.count > QUOTA_HISTORY_LIMITS.samples || this.bytes > QUOTA_HISTORY_LIMITS.bytes) { + const first = [...this.accounts].sort(([a, x], [b, y]) => x.samples[0].observedAt - y.samples[0].observedAt || a.localeCompare(b))[0]; + if (!first) break; + this.dropFirst(first[0]); + } + } +} diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 1a6260636c..f7bff7dc0e 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { closeSync, constants as fsConstants, existsSync, fstatSync, openSync, readSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper"; @@ -6,10 +6,12 @@ import { isThirtyDayOnlyCodexPlan } from "./plan"; import { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaWriter } from "./main-account-cache"; -import type { StoredAccountQuota, WhamUsageResponse, WhamUsageWindow } from "./quota-types"; +import { CodexQuotaHistory, QUOTA_HISTORY_LIMITS, type QuotaHistoryWindow } from "./quota-history"; +import { isPoolQuotaWriterLive, poolQuotaHistoryIdentity } from "./account-store"; +import type { PoolQuotaWriter, StoredAccountQuota, WhamUsageResponse, WhamUsageWindow } from "./quota-types"; export type { StoredAccountQuota, WhamUsageResponse } from "./quota-types"; -/** Disk snapshot under OPENCODEX_HOME — quota and policy identity only, never credential tags. */ +/** Disk snapshot: quota, private non-secret publication UUIDs and policy identity; never token-derived fingerprints. */ const QUOTA_CACHE_FILENAME = "codex-quota-cache.json"; /** Keep last-known bars across restarts; WHAM still refreshes on TTL in live/prime paths. */ const QUOTA_DISK_MAX_AGE_MS = 6 * 60 * 60_000; @@ -19,6 +21,7 @@ type QuotaDiskFile = { version: 1; quotas: Record; mainPolicyQuota?: MainPolicyQuota; + history?: ReturnType; }; type MainPolicyQuota = { identityKey: string; quota: StoredAccountQuota }; @@ -61,6 +64,7 @@ export function resetAtToMs(resetAt: number): number { } const accountQuota = new Map(); +const quotaHistory = new CodexQuotaHistory(); let lastReconciledGeneration = 0; let liveAccountIds = new Set(); @@ -268,6 +272,7 @@ export function setAccountQuotaFromParsed( writerGeneration = captureConfigGeneration(), mainWriter?: MainQuotaWriter, policyQuota: Omit | null = quota, + historyEvidence?: QuotaObservationEvidence, ): void { if (!quota) return; if (!mayCommitAccountQuota(accountId, writerGeneration)) return; @@ -276,6 +281,11 @@ export function setAccountQuotaFromParsed( hydrateAccountQuotasFromDisk(); const legacyExisting = accountQuota.get(accountId); const updatedAt = Date.now(); + if (historyEvidence && historyEvidence.writer.accountId === accountId && isPoolQuotaWriterLive(historyEvidence.writer)) { + quotaHistory.append(historyEvidence.writer, { observedAt: historyEvidence.observedAt, source: historyEvidence.source, + credentialGeneration: historyEvidence.writer.credentialGeneration, windows: historyWindows(historyEvidence.raw), + }, updatedAt); + } // Legacy rotation keeps its existing carry behavior, but never inherits policy-only // evidence that outlived its disk TTL. Policy has a separate, identity-checked base. const next = mergeAccountQuota(quota, legacyExisting, updatedAt); @@ -446,6 +456,7 @@ const SPARK_MODEL_MARKER = "codex-spark"; * must write the SAME label so a header refresh replaces the WHAM reading instead of doubling it. */ const SPARK_SHORT_WINDOW_LABEL = "GPT-5.3-Codex-Spark 5h"; +const SPARK_WEEKLY_WINDOW_LABEL = "GPT-5.3-Codex-Spark Weekly"; /** True when the routed model belongs to the Spark family, which carries its own rate limit. */ function isCodexSparkModel(modelId: string | undefined): boolean { @@ -540,7 +551,7 @@ export function applyAccountQuotaFromUpstreamHeaders( headers: Headers, writerGeneration = captureConfigGeneration(), mainWriter?: MainQuotaWriter, - options?: { modelId?: string }, + options?: { modelId?: string; poolWriter?: PoolQuotaWriter }, ): void { const quota = parseUpstreamQuotaHeaders(headers, options); if (!quota) return; @@ -564,7 +575,10 @@ export function applyAccountQuotaFromUpstreamHeaders( legacyQuota = { ...quota, customWindows: merged }; } } - setAccountQuotaFromParsed(accountId, legacyQuota, writerGeneration, mainWriter, policyQuota); + const validHistory = !["x-codex-primary-used-percent", "x-codex-secondary-used-percent", "x-codex-tertiary-used-percent"] + .some(name => isInvalidPolicyUsagePercent(headers.get(name))); + setAccountQuotaFromParsed(accountId, legacyQuota, writerGeneration, mainWriter, policyQuota, + options?.poolWriter && validHistory ? { writer: options.poolWriter, observedAt: Date.now(), source: "response-header", raw: quota } : undefined); } export function updateAccountQuota( @@ -658,9 +672,10 @@ function hydrateAccountQuotasFromDisk(): void { try { const path = join(getConfigDir(), QUOTA_CACHE_FILENAME); if (!existsSync(path)) return; - const raw = readFileSync(path, "utf8"); + const raw = readQuotaCacheBounded(path); const parsed = JSON.parse(raw) as QuotaDiskFile; if (!parsed || parsed.version !== 1 || !parsed.quotas || typeof parsed.quotas !== "object") return; + quotaHistory.hydrate(parsed.history); // Policy evidence deliberately outlives the legacy six-hour rotation-cache TTL. mainPolicyQuota = readMainPolicyQuota(parsed.mainPolicyQuota); const now = Date.now(); @@ -687,6 +702,7 @@ function schedulePersistAccountQuotas(): void { version: 1, quotas, ...(mainPolicyQuota ? { mainPolicyQuota } : {}), + history: quotaHistory.serialize(), }; atomicWriteFile(join(getConfigDir(), QUOTA_CACHE_FILENAME), `${JSON.stringify(body)}\n`); } catch { @@ -735,6 +751,8 @@ function forgetCodexQuotaBaseline(accountId?: string): void { } export function clearAccountQuota(accountId?: string): void { + if (accountId) hydrateAccountQuotasFromDisk(); + quotaHistory.clear(accountId); if (accountId) { hydrateAccountQuotasFromDisk(); accountQuota.delete(accountId); @@ -762,7 +780,7 @@ export function clearAccountQuota(accountId?: string): void { export function reconcileCodexQuotaAccounts(context: GenerationContext): number { if (context.generation <= lastReconciledGeneration) return 0; hydrateAccountQuotasFromDisk(); - let removed = 0; + let removed = quotaHistory.reconcile(context.codexAccountIds); for (const accountId of accountQuota.keys()) { if (context.codexAccountIds.has(accountId)) continue; accountQuota.delete(accountId); @@ -888,7 +906,7 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit = []; for (const [label, window] of [ [SPARK_SHORT_WINDOW_LABEL, sparkShort], - ["GPT-5.3-Codex-Spark Weekly", sparkWeekly], + [SPARK_WEEKLY_WINDOW_LABEL, sparkWeekly], ] as const) { const percent = normalizeUsagePercent(window?.used_percent); if (percent === undefined) continue; @@ -902,3 +920,70 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit; +} + +/** Reject raw invalid readings before the compatibility parser clamps them into valid-looking bars. */ +export function isValidWhamHistoryObservation(data: WhamUsageResponse): boolean { + const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window]; + for (const limit of Array.isArray(data.additional_rate_limits) ? data.additional_rate_limits : []) { + if (limit && typeof limit === "object") windows.push(limit.rate_limit?.primary_window, limit.rate_limit?.secondary_window); + } + return !windows.some(window => isInvalidPolicyUsagePercent(window?.used_percent)); +} + +function historyWindows(quota: Omit): QuotaHistoryWindow[] { + const windows: QuotaHistoryWindow[] = []; + for (const window of ["short", "weekly", "monthly"] as const) { + const percent = quota[`${window}Percent`]; + const reset = quota[`${window}ResetAt`]; + if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) continue; + windows.push({ family: "account", window, usedPercent: percent, + ...(typeof reset === "number" && Number.isFinite(reset) && reset >= 0 ? { resetAtMs: resetAtToMs(reset) } : {}), + ...(window === "short" && quota.shortWindowSeconds ? { windowSeconds: quota.shortWindowSeconds } : {}), + ...(window === "monthly" && quota.monthlyIsPrimaryWindow ? { monthlyIsPrimaryWindow: true } : {}), + }); + } + for (const [label, window] of [[SPARK_SHORT_WINDOW_LABEL, "short"], [SPARK_WEEKLY_WINDOW_LABEL, "weekly"]] as const) { + const raw = quota.customWindows?.find(row => row.label === label); + if (!raw || !Number.isFinite(raw.percent) || raw.percent < 0 || raw.percent > 100) continue; + windows.push({ family: "spark", window, usedPercent: raw.percent, + ...(typeof raw.resetAt === "number" && Number.isFinite(raw.resetAt) && raw.resetAt >= 0 ? { resetAtMs: resetAtToMs(raw.resetAt) } : {}), + }); + } + return windows; +} + +/** A cache read is bounded even if a file grows between stat and read. */ +function readQuotaCacheBounded(path: string): string { + const limit = 4 * 1024 * 1024; + const flags = fsConstants.O_RDONLY | (process.platform === "win32" ? 0 : fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW); + const fd = openSync(path, flags); + try { + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > limit) throw new Error("quota cache exceeds bounds"); + const chunks: Buffer[] = []; + let total = 0; + while (total <= limit) { + const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, limit + 1 - total)); + const size = readSync(fd, chunk, 0, chunk.length, null); + if (!size) return Buffer.concat(chunks, total).toString("utf8"); + chunks.push(chunk.subarray(0, size)); total += size; + } + throw new Error("quota cache exceeds bounds"); + } finally { closeSync(fd); } +} + +/** Cached pool observations only. An unavailable identity never authorizes publication or deletion. */ +export function getAccountQuotaHistory(accountId: string, limit: number = QUOTA_HISTORY_LIMITS.perAccount) { + hydrateAccountQuotasFromDisk(); + const result = quotaHistory.read(accountId, poolQuotaHistoryIdentity(accountId), Date.now(), limit); + return { observations: result.samples.map(({ credentialGeneration: _generation, ...sample }) => sample), + truncated: result.truncated, retention: { maxObservations: QUOTA_HISTORY_LIMITS.perAccount, maxAgeDays: 30 } }; +} diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 9fb71e662c..ab698f05c2 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -91,6 +91,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/codex-auth/active", module: "codex/auth-api", mutates: false }, { method: "GET", path: "/api/codex-auth/login-status", module: "codex/auth-api", mutates: false }, { method: "GET", path: "/api/codex-auth/quota", module: "codex/auth-api", mutates: false }, + { method: "GET", path: "/api/codex-auth/quota/history", module: "codex/auth-api", mutates: false }, { method: "GET", path: "/api/codex-auth/reset-credits", module: "codex/auth-api", mutates: false }, { method: "PATCH", path: "/api/codex-auth/pool-strategy", module: "codex/auth-api", mutates: true }, { method: "POST", path: "/api/codex-auth/accounts", module: "codex/auth-api", mutates: true }, diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 21958143db..28fd4a564f 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -1,3 +1,4 @@ +import { capturePoolQuotaWriter } from "../../codex/account-store"; import type { Server } from "bun"; import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge"; import { @@ -356,6 +357,7 @@ async function refreshPoolCompactContext(args: { accessToken: refreshed.accessToken, chatgptAccountId: refreshed.chatgptAccountId, generation: refreshed.generation, + poolQuotaWriter: capturePoolQuotaWriter(authCtx.accountId, refreshed), }; const refreshedProvider = applyCodexAuthContextToProvider( stripCodexRuntimeProviderFields(provider), @@ -1037,7 +1039,7 @@ export async function handleResponsesCompact( upstream.headers, authCtx.writerGeneration, authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined, - { modelId: route.modelId }, + { modelId: route.modelId, poolWriter: authCtx.kind === "pool" ? authCtx.poolQuotaWriter : undefined }, ); } recordCompactPoolOutcome(authCtx, upstream.status, { @@ -1078,6 +1080,12 @@ export async function handleResponsesCompact( } } } + // Capture the final serving account as well as an earlier rejected account, once per response. + if (outcomeCtx.kind === "pool") { + const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/quota"); + applyAccountQuotaFromUpstreamHeaders(outcomeCtx.accountId, upstream.headers, outcomeCtx.writerGeneration, + undefined, { modelId: route.modelId, poolWriter: outcomeCtx.poolQuotaWriter }); + } const retryAfter = upstream.headers.get("retry-after"); const resetAt = [ upstream.headers.get("x-codex-primary-reset-at"), diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e141b55ef0..10cc5f9bd3 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1,3 +1,4 @@ +import { capturePoolQuotaWriter } from "../../codex/account-store"; import type { Server } from "bun"; import { randomUUID } from "node:crypto"; import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge"; @@ -1041,7 +1042,7 @@ function codexWsQuotaObserver(authCtx: CodexAuthContext, provider: OcxProviderCo const mainWriter = authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined; return headers => { if (credentialGeneration !== undefined && !isCodexAccountGenerationLive(accountId, credentialGeneration)) return; - applyCapturedCodexQuota(accountId, headers, writerGeneration, mainWriter, { modelId }); + applyCapturedCodexQuota(accountId, headers, writerGeneration, mainWriter, { modelId, poolWriter: authCtx.kind === "pool" ? authCtx.poolQuotaWriter : undefined }); }; } @@ -1446,7 +1447,7 @@ async function retryCodexPoolOnAlternateAccount( firstResponse.headers, firstAuthCtx.writerGeneration, firstAuthCtx.kind === "main-pool" ? firstAuthCtx.mainQuotaWriter : undefined, - { modelId: route.modelId }, + { modelId: route.modelId, poolWriter: firstAuthCtx.kind === "pool" ? firstAuthCtx.poolQuotaWriter : undefined }, ); } const deferFirstOutcome = shouldDeferCodexResetDerivedCooldown( @@ -2420,6 +2421,7 @@ async function refreshPoolForwardAuth(args: { accessToken: refreshed.accessToken, chatgptAccountId: refreshed.chatgptAccountId, generation: refreshed.generation, + poolQuotaWriter: capturePoolQuotaWriter(authCtx.accountId, refreshed), }; const provider = applyCodexAuthContextToProvider( stripCodexRuntimeProviderFields(route.provider), @@ -5915,7 +5917,7 @@ async function handleResponsesInner( if (!isCodexWsQuotaObservedResponse(upstreamResponse)) { applyAccountQuotaFromUpstreamHeaders(authCtx.accountId, upstreamResponse.headers, authCtx.writerGeneration, authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined, - { modelId: route.modelId }); + { modelId: route.modelId, poolWriter: authCtx.kind === "pool" ? authCtx.poolQuotaWriter : undefined }); } if (terminalBodyWillRecord) { options.setTerminalOutcomeRecorder?.((status, httpStatusOverride) => { diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index a4dc21adbf..658daca743 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -66,3 +66,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/catalog.md b/structure/catalog.md index 0d2be61254..6ba73c03d1 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -280,3 +280,5 @@ Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#c privately to final dispatch; preliminary route selection does not inject Go-only headers. Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. + +Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 2914823958..aa95b9f74a 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -91,3 +91,5 @@ The explicit sync coordinator also accepts Cline CLI as a separate file integrat `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](../data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. + +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/codex-home.md b/structure/codex-home.md index 8085223426..d9c51d351d 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -238,3 +238,5 @@ The legacy external writer is now refused for affected rows in any store whose s Native restore preflight also checks manifest-owned targets whose rows already returned to `openai`, including interrupted restores. Preimage capture distinguishes absent files from unreadable artifacts and aborts before mutation when a complete snapshot cannot be read. Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. + +Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/config.md b/structure/config.md index de957f0c7f..6e5f280401 100644 --- a/structure/config.md +++ b/structure/config.md @@ -207,3 +207,5 @@ The Cline client keeps connection settings and models in a separate native file `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. + +Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 25646c7de4..b91e84e95c 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -79,3 +79,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 2d17c11875..4e8122a954 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -128,3 +128,5 @@ changes prompt roles, not conversation identity, and cannot guarantee upstream c Instruction notice extraction scans fence ranges once and walks original lines backwards with a decreasing cursor. It accepts exactly one ASCII space inside the token notice, preserves unmatched prefix bytes, and does not repeatedly scan or copy shrinking prompt prefixes. + +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 42a70082db..3d186568fa 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -545,3 +545,5 @@ integration IO adapter. Its snapshot fingerprint cannot be checked against provi [the integration contract](clients/integrations.md#cline-paired-files) defines recovery. The existing dashboard file-client maps include Cline CLI and reuse its committed color mark. The export panel labels its download as a settings/catalog bundle; all locales explain that Undo restores both original files. + +Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 6e86dfbf43..9d1c835637 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -316,3 +316,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Private pool credential metadata follows the [quota-history publication identity contract](../providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. The integrations guide documents Cline CLI as a two-file, loopback-only integration. Hosted CI validates its source-backed fixtures; the packaged dashboard exposes it through the existing client list. + +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 39dc9a82da..62b33fd55f 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -142,3 +142,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/overview.md b/structure/overview.md index d5d1a2207a..297f30106a 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -108,3 +108,5 @@ The management quota DTO keeps Combo editing aligned with scoped inference evide see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). Cline CLI is a managed file integration: its provider settings and catalog share one recoverable journal operation. The [paired-file contract](clients/integrations.md#cline-paired-files) defines its stop/restart requirement. + +Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 10f3016a73..b3137f178d 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -408,3 +408,11 @@ successful main usage refresh clears the runtime mark. `src/codex/account-store.ts` assigns each explicit pool credential publication a private random `quotaHistoryIdentity`. Same-account token refresh preserves it, including each alias record's own identity; replacement or deletion retires it. A refresh CAS with a changed upstream account identity rotates the tag and does not propagate that changed identity to old aliases. Credential-only projections omit this metadata. `capturePoolQuotaWriter` captures the exact dispatched access/account pair and generation. Legacy identity initialization rechecks under the credential mutation lock, persists metadata without advancing credential generation or mutation epoch, and fails to no optional evidence on read/lock/write errors. Append admission uses the captured generation and tag; history retention compares the tag across ordinary refresh. Native main is excluded from this pool proof. These interfaces supply the bounded observation layer; the identity alone is neither a quota sample nor proof of capacity. + +## Bounded pool quota observations + +`src/codex/quota-history.ts` retains at most 200 raw observations per stored pool account for 30 days, bounded globally to 64 identities, 4096 observations and 2 MiB. `src/codex/quota.ts` persists these alongside the latest quota cache; the file reader caps allocation at 4 MiB and rejects nonregular/oversized input. Invalid history envelopes are discarded without blocking inference. Atomic cache replacement is best-effort single-writer persistence, not cross-process merging. + +WHAM and response-header producers pass the exact captured pool writer, including refreshed replay and compact outcomes. Admission rechecks credential generation and publication UUID. Same-account refresh preserves prior history; replacement/removal invalidates it. Raw invalid percentages discard the entire trusted observation before display clamping; carried windows, reset credits alone, native main and staged-login probes never become durable pool history. + +`GET /api/codex-auth/quota/history` and `ocx account history openai ` read only cached, identity-checked observations. The optional limit is 1–200. Public results omit the internal publication UUID and credential generation. These observations are inputs for capacity estimation; percentages alone do not establish absolute token capacity. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 5b149ac6a2..87242b2580 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -65,3 +65,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Pool quota producers and account commands follow the [bounded raw-observation contract](openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/runtime.md b/structure/runtime.md index c94e65d7e5..93ffb9993f 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -227,3 +227,5 @@ Cline CLI joins the existing export/client integration registries. Explicit CLI `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. + +Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/subagents.md b/structure/subagents.md index c93aa8be21..29b258ceb3 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -216,3 +216,5 @@ Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#c privately to final dispatch; preliminary route selection does not inject Go-only headers. Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. + +Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index b2fc3b3fae..b79a70a2b5 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -70,3 +70,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2321d78dd6..1e7cb6e71e 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -523,3 +523,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 68093843ea..48200dd371 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -199,3 +199,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 214270e4be..46d2329b02 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -586,6 +586,22 @@ afterEach(() => { }); describe("ocx account CLI (issue #180 matrix)", () => { + test("history reads one cached endpoint and rejects invalid arguments before I/O", async () => { + let calls = 0; + const deps: AccountDeps = { baseUrl: "http://127.0.0.1:10100", fetchImpl: (async input => { + calls++; + expect(String(input)).toBe("http://127.0.0.1:10100/api/codex-auth/quota/history?accountId=pool-a&limit=2"); + return Response.json({ accountId: "pool-a", observations: [], retention: { maxObservations: 200, maxAgeDays: 30 }, truncated: false }); + }) as typeof fetch }; + const result = await run(["history", "openai", "pool-a", "--limit", "2", "--json"], deps); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout).observations).toEqual([]); + for (const args of [["anthropic", "pool-a"], ["openai", "__main__"], ["openai", "pool-a", "--limit", "201"], ["openai", "pool-a", "--unknown"]]) { + expect((await run(["history", ...args], deps)).code).toBe(1); + } + expect(calls).toBe(1); + }); + test.each([100, 12])("pending validation stays visible at %s percent usage without exposing raw health details", async weeklyPercent => { codexAccounts = [{ id: "pending", email: "p***@example.test", quota: { weeklyPercent }, health: { status: "warning", reason: "validation_pending", message: RAW_SENTINEL } }]; diff --git a/tests/codex-integration/codex-quota-history.test.ts b/tests/codex-integration/codex-quota-history.test.ts new file mode 100644 index 0000000000..b63309fc81 --- /dev/null +++ b/tests/codex-integration/codex-quota-history.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; +import { CodexQuotaHistory, QUOTA_HISTORY_LIMITS, type QuotaHistorySample } from "../../src/codex/quota-history"; +import type { PoolQuotaWriter } from "../../src/codex/quota-types"; + +const now = 1_800_000_000_000; +const identity = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const replacement = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +const writer: PoolQuotaWriter = { accountId: "pool-a", credentialGeneration: 1, historyIdentity: identity }; +function sample(at = now, usedPercent = 10): QuotaHistorySample { + return { observedAt: at, source: "wham", credentialGeneration: 1, + windows: [{ family: "account", window: "weekly", usedPercent, resetAtMs: now + 100_000 }] }; +} + +describe("bounded quota observation history", () => { + test("keeps the newest observations by time and returns independent copies", () => { + const history = new CodexQuotaHistory(); + for (let index = 200; index >= 0; index--) history.append(writer, sample(now - index), now); + const result = history.read(writer.accountId, identity, now); + expect(result.samples).toHaveLength(200); + expect(result.samples[0].observedAt).toBe(now - 199); + expect(result.samples[199].observedAt).toBe(now); + result.samples[0].windows[0].usedPercent = 99; + expect(history.read(writer.accountId, identity, now).samples[0].windows[0].usedPercent).toBe(10); + expect(history.read(writer.accountId, identity, now, 1)).toMatchObject({ truncated: true, samples: [sample(now)] }); + }); + + test("refresh retains history but replacement and roster removal retire it", () => { + const history = new CodexQuotaHistory(); + history.append(writer, sample(), now); + history.append({ ...writer, credentialGeneration: 2 }, { ...sample(now + 1), credentialGeneration: 2 }, now + 1); + expect(history.read(writer.accountId, undefined, now + 1).samples).toEqual([]); + expect(history.read(writer.accountId, identity, now + 1).samples).toHaveLength(2); + expect(history.read(writer.accountId, replacement, now + 1).samples).toEqual([]); + history.append({ ...writer, historyIdentity: replacement }, sample(now + 2), now + 2); + expect(history.reconcile(new Set())).toBe(1); + expect(history.serialize(now + 2).accounts).toEqual({}); + }); + + test("rejects invalid observations and never admits native-main identity", () => { + const history = new CodexQuotaHistory(); + for (const used of [-1, 101, Number.NaN, Infinity]) history.append(writer, sample(now, used), now); + history.append(writer, sample(now + 1), now); + history.append({ ...writer, accountId: "__main__" }, sample(), now); + history.append(writer, { ...sample(), windows: [] }, now); + history.append(writer, { ...sample(), windows: [sample().windows[0], sample().windows[0]] }, now); + expect(history.serialize(now).accounts).toEqual({}); + }); + + test("disk hydration rejects overflow rather than losing a newer 65th account", () => { + const history = new CodexQuotaHistory(); + const accounts = Object.fromEntries(Array.from({ length: 65 }, (_, i) => [`pool-${i}`, { identity, samples: [sample(now - 65 + i)] }])); + history.hydrate({ version: 1, accounts }, now); + expect(history.serialize(now).accounts).toEqual({}); + history.hydrate({ version: 1, accounts: { "pool-a": { identity, samples: Array.from({ length: 201 }, () => sample()) } } }, now); + expect(history.serialize(now).accounts).toEqual({}); + history.hydrate({ version: 1, accounts: {}, extra: "x".repeat(QUOTA_HISTORY_LIMITS.bytes) }, now); + expect(history.serialize(now).accounts).toEqual({}); + }); + + test("valid unordered disk rows are sorted and arbitrary payload fields are discarded", () => { + const history = new CodexQuotaHistory(); + history.hydrate({ version: 1, accounts: { "pool-a": { identity, secret: "private-token", samples: [ + { ...sample(now), secret: "private-token" }, sample(now - 2), sample(now - 1), + ] } } }, now); + expect(history.read("pool-a", identity, now).samples.map(row => row.observedAt)).toEqual([now - 2, now - 1, now]); + expect(JSON.stringify(history.serialize(now))).not.toContain("private-token"); + expect(history.read("pool-a", identity, now + QUOTA_HISTORY_LIMITS.ageMs + 1).samples).toEqual([]); + }); + + test("global retention evicts oldest samples and stays below the serialized byte bound", () => { + const history = new CodexQuotaHistory(); + for (let account = 0; account < 65; account++) { + for (let i = 0; i < 100; i++) history.append({ ...writer, accountId: `pool-${account}` }, sample(now - 6500 + account * 100 + i), now); + } + const disk = history.serialize(now); + expect(Object.keys(disk.accounts).length).toBeLessThanOrEqual(QUOTA_HISTORY_LIMITS.accounts); + expect(Object.values(disk.accounts).reduce((n, row) => n + row.samples.length, 0)).toBeLessThanOrEqual(QUOTA_HISTORY_LIMITS.samples); + expect(new TextEncoder().encode(JSON.stringify(disk)).byteLength).toBeLessThanOrEqual(QUOTA_HISTORY_LIMITS.bytes); + expect(disk.accounts["pool-64"].samples.at(-1)?.observedAt).toBe(now - 1); + }); +}); diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index 72d596e75f..903d50f79b 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -1,3 +1,5 @@ +import { capturePoolQuotaWriter, saveCodexAccountCredential, saveCodexAccountCredentialIfGeneration } from "../../src/codex/account-store"; +import { getAccountQuotaHistory, isValidWhamHistoryObservation } from "../../src/codex/quota"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -400,3 +402,51 @@ describe("main policy quota durability and lifecycle", () => { expect(matchesMainQuotaCredential("fixture-bearer-a", "fixture-main-a")).toBe(false); }); }); + + +test("pool history records fresh windows only and preserves identity across token refresh", () => { + const credential = { accessToken: "history-token", refreshToken: "history-refresh", chatgptAccountId: "history-account", expiresAt: Date.now() + 3600_000 }; + const generation = saveCodexAccountCredential("history-pool", credential); + const writer = capturePoolQuotaWriter("history-pool", { ...credential, generation })!; + const raw = { weeklyPercent: 10, weeklyResetAt: Date.now() / 1000 + 1000 }; + setAccountQuotaFromParsed("history-pool", raw, undefined, undefined, raw, { writer, observedAt: Date.now(), source: "wham", raw }); + applyAccountQuotaFromUpstreamHeaders("history-pool", new Headers({ + "x-codex-primary-used-percent": "20", "x-codex-primary-window-minutes": "300", "x-codex-primary-reset-at": String(Date.now() / 1000 + 300), + }), undefined, undefined, { poolWriter: writer }); + let rows = getAccountQuotaHistory("history-pool").observations; + expect(rows).toHaveLength(2); + expect(rows[1].windows.map(window => window.window)).toEqual(["short"]); + expect(getAccountQuota("history-pool")?.weeklyPercent).toBe(10); + setAccountQuotaFromParsed("history-pool", { resetCredits: 2 }); + expect(getAccountQuotaHistory("history-pool").observations).toHaveLength(2); + const refreshed = { ...credential, accessToken: "history-new-token" }; + expect(saveCodexAccountCredentialIfGeneration("history-pool", generation, refreshed)).toBe(true); + applyAccountQuotaFromUpstreamHeaders("history-pool", new Headers({ "x-codex-primary-used-percent": "30" }), undefined, undefined, { poolWriter: writer }); + expect(getAccountQuotaHistory("history-pool").observations).toHaveLength(2); + const refreshedWriter = capturePoolQuotaWriter("history-pool", { ...refreshed, generation: generation + 1 })!; + applyAccountQuotaFromUpstreamHeaders("history-pool", new Headers({ "x-codex-primary-used-percent": "-20" }), undefined, undefined, { poolWriter: refreshedWriter }); + rows = getAccountQuotaHistory("history-pool").observations; + expect(rows).toHaveLength(2); + expect(isValidWhamHistoryObservation({ rate_limit: { primary_window: { used_percent: 101 } } })).toBe(false); + expect(isValidWhamHistoryObservation({ additional_rate_limits: [{ rate_limit: { primary_window: { used_percent: -1 } } }] })).toBe(false); + const body = flushPersistence(); + expect(JSON.parse(body).history.accounts["history-pool"].samples).toHaveLength(2); + expect(body).not.toContain("history-token"); + expect(body).not.toContain("history-refresh"); + clearAccountQuota(); + writeSnapshot(JSON.parse(body)); + expect(getAccountQuotaHistory("history-pool").observations).toHaveLength(2); + saveCodexAccountCredential("history-pool", refreshed); + expect(getAccountQuotaHistory("history-pool").observations).toEqual([]); +}); + +test("native main observations and oversized cache never become pool history", () => { + const raw = { weeklyPercent: 20 }; + setAccountQuotaFromParsed(MAIN, raw, undefined, writerFor()); + expect(getAccountQuotaHistory(MAIN).observations).toEqual([]); + const persisted = JSON.parse(flushPersistence()); + expect(persisted.history.accounts).not.toHaveProperty(MAIN); + clearAccountQuota(); + writeFileSync(join(testDir, "codex-quota-cache.json"), " ".repeat(4 * 1024 * 1024 + 1)); + expect(getAccountQuotaHistory("history-pool").observations).toEqual([]); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f116d70a11..6aff5af109 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -314,6 +314,7 @@ "codex-prompt-text-probe.test.ts": "codex-integration", "codex-quota-auto-refresh-main-admission.test.ts": "codex-integration", "codex-quota-auto-refresh.test.ts": "codex-integration", + "codex-quota-history.test.ts": "codex-integration", "codex-quota-parser-parity.test.ts": "codex-integration", "codex-quota-prime.test.ts": "codex-integration", "codex-quota-rejection.test.ts": "codex-integration", diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index f703a1899e..336ebca564 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -1,3 +1,4 @@ +import { getAccountQuotaHistory } from "../../src/codex/quota"; import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; import { sessionLaneIdFromRequest } from "../../src/server/request-log-conversation"; /** @@ -1229,6 +1230,30 @@ describe("compact alternate-account attempt (#913)", () => { }); }); + test.each([false, true])("compact final quota history follows the serving account with alternate=%s", async alternate => { + await withPoolEnv("ocx-compact-history-", async config => { + let calls = 0; + globalThis.fetch = (async () => { + calls++; + const rejected = alternate && calls === 1; + return Response.json(rejected ? { error: { message: "pool exhausted" } } : completedPayload("history compact"), { + status: rejected ? 429 : 200, + headers: { "x-codex-primary-used-percent": rejected ? "100" : "25", "x-codex-primary-window-minutes": "10080" }, + }); + }) as typeof fetch; + const response = await handleResponsesCompact(compactionRequest(baseCompactionBody({})), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + const first = getAccountQuotaHistory("pool-a").observations; + expect(first).toHaveLength(1); + expect(first[0].windows[0].usedPercent).toBe(alternate ? 100 : 25); + const second = getAccountQuotaHistory("pool-b").observations; + expect(second).toHaveLength(alternate ? 1 : 0); + if (alternate) expect(second[0].windows[0].usedPercent).toBe(25); + expect(calls).toBe(alternate ? 2 : 1); + }); + }); + test("canonical trailing slashes are pinned before native compact sends pool credentials", async () => { await withPoolEnv("ocx-compact-canonical-url-", async config => { config.providers.openai!.baseUrl = "https://chatgpt.com/backend-api/codex///"; diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index feec9a8151..ae3a9100b2 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -667,6 +667,30 @@ describe("unified pool-settings contract (#695 wp5c)", () => { if (dir) removeTreeWithRetry(dir); }); + test("quota history is a protected bounded cached read for stored pool accounts", async () => { + const config = loadConfig(); + config.codexAccounts = [{ id: "history-row", email: "history@example.test", isMain: false }]; + saveConfig(config); + const server = startServer(0); + try { + const endpoint = "/api/codex-auth/quota/history"; + const denied = await globalThis.fetch(new URL(`${endpoint}?accountId=history-row`, server.url)); + expect(denied.status).toBe(401); + await denied.text(); + for (const query of ["", "?accountId=__main__", "?accountId=history-row&accountId=history-row", "?accountId=history-row&limit=201", "?accountId=history-row&refresh=1"]) { + const response = await fetch(new URL(endpoint + query, server.url)); + expect(response.status).toBe(400); + await response.text(); + } + const unknown = await fetch(new URL(`${endpoint}?accountId=missing`, server.url)); + expect(unknown.status).toBe(404); + await unknown.text(); + const response = await fetch(new URL(`${endpoint}?accountId=history-row&limit=1`, server.url)); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ accountId: "history-row", observations: [], retention: { maxObservations: 200, maxAgeDays: 30 }, truncated: false }); + } finally { await server.stop(true); } + }); + test("every kind answers with the same keys and declares what it supports", async () => { const server = startServer(0); try { From 0d98205fcd1985607d8ae257ebacc4df86ac6e63 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 21:41:31 +0900 Subject: [PATCH 6/8] fix(codex): harden quota history dates and producer regression evidence --- .../260912_accounts/051_history_delivery.md | 2 ++ src/cli/account-history.ts | 8 ++++++- tests/cli/cli-account.test.ts | 10 +++++++++ .../codex-integration/codex-auth-api.test.ts | 4 ++++ .../codex-quota-auto-refresh.test.ts | 3 +++ .../codex-quota-history.test.ts | 21 ++++++++++++++++++- .../main-quota-provenance.test.ts | 8 ++++++- .../responses/responses-account-label.test.ts | 6 +++++- .../responses-compaction-routing.test.ts | 13 ++++++++++++ .../account-pool-management-api.test.ts | 14 +++++++++++++ 10 files changed, 85 insertions(+), 4 deletions(-) diff --git a/devlog/_plan/260912_accounts/051_history_delivery.md b/devlog/_plan/260912_accounts/051_history_delivery.md index 3ae2698d79..0e38a408c8 100644 --- a/devlog/_plan/260912_accounts/051_history_delivery.md +++ b/devlog/_plan/260912_accounts/051_history_delivery.md @@ -5,3 +5,5 @@ Extends publication identity foundation #4375 with a pure bounded history leaf, Regression sources cover chronological retention, limits/corrupt disk, private-field stripping, generation/identity changes, raw-versus-carried windows, cached API auth/validation, CLI argument rejection and compact serving-account attribution. Independent source review identified missing compact final-response capture; it was added with ordinary/alternate regressions. CLI skill surface regenerated by its source-only generator, not a product build or suite. Local suites/build/typecheck/install NOT RUN. This child targets the existing history-identity branch at19cbe826d8. The pending plan-only commit was rebased onto the parent-updated branch; foundation product bytes were unchanged. Host goal remains blocked; actual FSMB(tun) remains untouched under explicit user instruction. These are authorized source implementation and independent reviews, not a claimed new persisted PABCD cycle. Complete hosted verification belongs to the eventual cumulative history/capacity tip; no merge or issue closure. + +Review corrections: human CLI formats out-of-range dates as unknown; byte-limit fixtures now carry valid populated data and exercise append-byte eviction before row limits; authenticated API returns a populated sanitized history; WHAM refresh/replay, HTTP/WS and real warmup producer fixtures assert history including stale WS replacement rejection. Local suites remain NOT RUN. diff --git a/src/cli/account-history.ts b/src/cli/account-history.ts index fb813cbdbf..c7662d3ad4 100644 --- a/src/cli/account-history.ts +++ b/src/cli/account-history.ts @@ -1,6 +1,12 @@ import { isValidCodexAccountId } from "../codex/account-id"; import { apiError, apiJson, proxyUnreachable, resolveBaseUrl, type AccountDeps } from "./account-api"; +function historyDate(value: unknown): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "unknown"; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date.toISOString() : "unknown"; +} + /** Read cached pool observations without refreshing credentials or spending quota. */ export async function cmdAccountHistory(args: string[], deps: AccountDeps): Promise { const [provider, accountId, ...flags] = args; @@ -32,7 +38,7 @@ export async function cmdAccountHistory(args: string[], deps: AccountDeps): Prom if (!observation || typeof observation !== "object" || !Array.isArray(observation.windows) || !Number.isFinite(observation.observedAt)) return apiError({}, "Invalid quota history response", 502); for (const window of observation.windows) { - console.log(`${new Date(observation.observedAt).toISOString()}\t${observation.source}\t${window.family}/${window.window}\t${window.usedPercent}%\t${typeof window.resetAtMs === "number" ? new Date(window.resetAtMs).toISOString() : "unknown"}`); + console.log(`${historyDate(observation.observedAt)}\t${observation.source}\t${window.family}/${window.window}\t${window.usedPercent}%\t${historyDate(window.resetAtMs)}`); } } return 0; diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 46d2329b02..a41187e27f 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -586,6 +586,16 @@ afterEach(() => { }); describe("ocx account CLI (issue #180 matrix)", () => { + test("human quota history renders populated rows and safely handles oversized reset dates", async () => { + const result = await run(["history", "openai", "pool-a"], { baseUrl: "http://127.0.0.1:10100", fetchImpl: (async () => Response.json({ + observations: [{ observedAt: 1_800_000_000_000, source: "wham", windows: [ + { family: "account", window: "weekly", usedPercent: 20, resetAtMs: 1e20 }, + ] }], + })) as typeof fetch }); + expect(result.code).toBe(0); + expect(result.stdout).toContain("2027-01-15T08:00:00.000Z\twham\taccount/weekly\t20%\tunknown"); + }); + test("history reads one cached endpoint and rejects invalid arguments before I/O", async () => { let calls = 0; const deps: AccountDeps = { baseUrl: "http://127.0.0.1:10100", fetchImpl: (async input => { diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 89813bb058..51a05d9170 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -1,3 +1,4 @@ +import { getAccountQuotaHistory } from "../../src/codex/quota"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import type { ServerWebSocket } from "bun"; import { Database } from "bun:sqlite"; @@ -1796,6 +1797,8 @@ describe("codex-auth API", () => { const data = await resp!.json() as { accounts: { id: string; quota: unknown }[] }; const pool = data.accounts.find(a => a.id === "pool-refresh"); expect(pool?.quota).toMatchObject({ weeklyPercent: 6, weeklyResetAt: 1782628379 }); + expect(getAccountQuotaHistory("pool-refresh").observations).toHaveLength(1); + expect(getAccountQuotaHistory("pool-refresh").observations[0]).toMatchObject({ source: "wham", windows: [{ family: "account", window: "weekly", usedPercent: 6, resetAtMs: 1782628379000 }] }); expect(calls).toBe(1); } finally { globalThis.fetch = originalFetch; @@ -6097,6 +6100,7 @@ describe("manual reset cooldown recovery (#3973)", () => { expect((await consume(config))?.status).toBe(200); expect(getCodexQuotaHealthSnapshot("manual-a", "shared")).toBeNull(); expect(readCodexAccountRecord("manual-a")!.generation).toBe(generation + 1); + expect(getAccountQuotaHistory("manual-a").observations.some(row => row.source === "wham")).toBe(true); expect(urls).toEqual([CONSUME, USAGE, "https://auth.openai.com/oauth/token", USAGE]); }); diff --git a/tests/codex-integration/codex-quota-auto-refresh.test.ts b/tests/codex-integration/codex-quota-auto-refresh.test.ts index 605247db16..375317d9a1 100644 --- a/tests/codex-integration/codex-quota-auto-refresh.test.ts +++ b/tests/codex-integration/codex-quota-auto-refresh.test.ts @@ -12,6 +12,7 @@ import { import { clearAccountQuota, getAccountQuota, + getAccountQuotaHistory, setAccountQuotaFromParsed, type StoredAccountQuota, } from "../../src/codex/quota"; @@ -173,6 +174,8 @@ describe("Codex quota window auto refresh", () => { expect(getAccountQuota("pool-a")).toMatchObject({ shortPercent: 0, shortResetAt: RESET_SECONDS + 18_000 }); resetCodexQuotaAutoRefreshForTests(); await runCodexQuotaAutoRefresh(loadConfig(), NOW + 18_000_000, deps); + expect(getAccountQuotaHistory("pool-a").observations).toHaveLength(2); + expect(getAccountQuotaHistory("pool-a").observations.every(row => row.source === "response-header" && row.windows[0]?.usedPercent === 0)).toBe(true); expect(calls).toBe(2); expect(loadConfig().codexQuotaAutoRefresh?.["pool-a"]?.lastFiveHourResetAt).toBe(NOW + 18_000_000); }); diff --git a/tests/codex-integration/codex-quota-history.test.ts b/tests/codex-integration/codex-quota-history.test.ts index b63309fc81..feb1ea8cc1 100644 --- a/tests/codex-integration/codex-quota-history.test.ts +++ b/tests/codex-integration/codex-quota-history.test.ts @@ -53,7 +53,7 @@ describe("bounded quota observation history", () => { expect(history.serialize(now).accounts).toEqual({}); history.hydrate({ version: 1, accounts: { "pool-a": { identity, samples: Array.from({ length: 201 }, () => sample()) } } }, now); expect(history.serialize(now).accounts).toEqual({}); - history.hydrate({ version: 1, accounts: {}, extra: "x".repeat(QUOTA_HISTORY_LIMITS.bytes) }, now); + history.hydrate({ version: 1, accounts: { "pool-a": { identity, samples: [sample()] } }, extra: "x".repeat(QUOTA_HISTORY_LIMITS.bytes) }, now); expect(history.serialize(now).accounts).toEqual({}); }); @@ -79,3 +79,22 @@ describe("bounded quota observation history", () => { expect(disk.accounts["pool-64"].samples.at(-1)?.observedAt).toBe(now - 1); }); }); + + +test("append byte budget evicts samples before any row or account count limit", () => { + const history = new CodexQuotaHistory(); + const windows = ([ + ["account", "short"], ["account", "weekly"], ["account", "monthly"], ["spark", "short"], ["spark", "weekly"], + ] as const).map(([family, window]) => ({ family, window, usedPercent: 12.345678901234567, + resetAtMs: 1_800_000_123_456.789, windowSeconds: 123_456_789.12345678, + ...(window === "monthly" ? { monthlyIsPrimaryWindow: true } : {}), + })); + for (let account = 0; account < 32; account++) for (let index = 0; index < 128; index++) { + history.append({ ...writer, accountId: `long-account-${account}` }, { ...sample(now - 4096 + account * 128 + index), windows }, now); + } + const persisted = history.serialize(now); + const retained = Object.values(persisted.accounts).reduce((count, row) => count + row.samples.length, 0); + expect(retained).toBeGreaterThan(0); + expect(retained).toBeLessThan(4096); + expect(new TextEncoder().encode(JSON.stringify(persisted)).byteLength).toBeLessThanOrEqual(QUOTA_HISTORY_LIMITS.bytes); +}); diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index 903d50f79b..a1e8b85418 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -447,6 +447,12 @@ test("native main observations and oversized cache never become pool history", ( const persisted = JSON.parse(flushPersistence()); expect(persisted.history.accounts).not.toHaveProperty(MAIN); clearAccountQuota(); - writeFileSync(join(testDir, "codex-quota-cache.json"), " ".repeat(4 * 1024 * 1024 + 1)); + const credential = { accessToken: "large-cache-access", refreshToken: "large-cache-refresh", chatgptAccountId: "large-cache-account", expiresAt: Date.now() + 3600_000 }; + const generation = saveCodexAccountCredential("history-pool", credential); + const writer = capturePoolQuotaWriter("history-pool", { ...credential, generation })!; + writeFileSync(join(testDir, "codex-quota-cache.json"), JSON.stringify({ version: 1, quotas: {}, history: { version: 1, accounts: { + "history-pool": { identity: writer.historyIdentity, samples: [{ observedAt: Date.now(), source: "wham", credentialGeneration: generation, + windows: [{ family: "account", window: "weekly", usedPercent: 20 }] }] }, + } }, padding: "x".repeat(4 * 1024 * 1024) })); expect(getAccountQuotaHistory("history-pool").observations).toEqual([]); }); diff --git a/tests/responses/responses-account-label.test.ts b/tests/responses/responses-account-label.test.ts index e96c4556d9..1980c4a5e4 100644 --- a/tests/responses/responses-account-label.test.ts +++ b/tests/responses/responses-account-label.test.ts @@ -16,7 +16,7 @@ import { handleResponses } from "../../src/server/responses"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { CodexWsMetadata } from "../../src/server/responses/codex-ws-metadata"; -import { applyAccountQuotaFromUpstreamHeaders } from "../../src/codex/quota"; +import { applyAccountQuotaFromUpstreamHeaders, getAccountQuotaHistory } from "../../src/codex/quota"; const originalFetch = globalThis.fetch; @@ -183,6 +183,8 @@ describe("Responses account usage attribution", () => { await response.text(); expect(getAccountQuota(accountId)?.weeklyPercent).toBe(20); expect(getAccountQuota("untouched-account")?.weeklyPercent).toBe(7); + expect(getAccountQuotaHistory(accountId).observations.map(row => row.windows[0].usedPercent)) + .toEqual(accountId === MAIN_CODEX_ACCOUNT_ID ? [] : [10, 20]); } }); } finally { @@ -235,6 +237,7 @@ describe("Responses account usage attribution", () => { codexWsRuntimeIdentity: "1.4.0", }); expect(getAccountQuota("pool-ws-replaced")?.weeklyPercent).toBe(10); + expect(getAccountQuotaHistory("pool-ws-replaced").observations.map(row => row.windows[0].usedPercent)).toEqual([10]); savePoolCredential("pool-ws-replaced"); clearAccountQuota("pool-ws-replaced"); @@ -242,6 +245,7 @@ describe("Responses account usage attribution", () => { await response.text(); expect(getAccountQuota("pool-ws-replaced")).toBeNull(); + expect(getAccountQuotaHistory("pool-ws-replaced").observations).toEqual([]); }); } finally { releaseFinalQuota(); diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index 336ebca564..d7b14cc1ce 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -1230,6 +1230,19 @@ describe("compact alternate-account attempt (#913)", () => { }); }); + test("ordinary pooled HTTP responses publish their captured quota history writer", async () => { + await withPoolEnv("ocx-http-history-", async config => { + globalThis.fetch = (async () => Response.json(completedPayload("ordinary history"), { + headers: { "x-codex-primary-used-percent": "31", "x-codex-primary-window-minutes": "10080" }, + })) as typeof fetch; + const response = await handleResponses(compactionRequest({ model: "gpt-5.5", input: [{ role: "user", content: "hello" }], stream: false }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + expect(getAccountQuotaHistory("pool-a").observations).toHaveLength(1); + expect(getAccountQuotaHistory("pool-a").observations[0].windows[0].usedPercent).toBe(31); + }); + }); + test.each([false, true])("compact final quota history follows the serving account with alternate=%s", async alternate => { await withPoolEnv("ocx-compact-history-", async config => { let calls = 0; diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index ae3a9100b2..63a1feefc3 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -688,6 +688,20 @@ describe("unified pool-settings contract (#695 wp5c)", () => { const response = await fetch(new URL(`${endpoint}?accountId=history-row&limit=1`, server.url)); expect(response.status).toBe(200); expect(await response.json()).toEqual({ accountId: "history-row", observations: [], retention: { maxObservations: 200, maxAgeDays: 30 }, truncated: false }); + const { saveCodexAccountCredential, capturePoolQuotaWriter } = await import("../../src/codex/account-store"); + const { setAccountQuotaFromParsed } = await import("../../src/codex/quota"); + const credential = { accessToken: "history-secret-access", refreshToken: "history-secret-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "private-history-account" }; + const generation = saveCodexAccountCredential("history-row", credential); + const writer = capturePoolQuotaWriter("history-row", { ...credential, generation })!; + const raw = { weeklyPercent: 21 }; + setAccountQuotaFromParsed("history-row", raw, undefined, undefined, raw, { writer, observedAt: Date.now(), source: "wham", raw }); + const populated = await fetch(new URL(`${endpoint}?accountId=history-row`, server.url)); + const body = await populated.json() as { observations: Array<{ source: string; windows: Array<{ usedPercent: number }> }> }; + expect(body.observations).toHaveLength(1); + expect(body.observations[0]).toMatchObject({ source: "wham", windows: [{ family: "account", window: "weekly", usedPercent: 21 }] }); + const serialized = JSON.stringify(body); + for (const privateValue of [credential.accessToken, credential.refreshToken, writer.historyIdentity, "credentialGeneration"]) expect(serialized).not.toContain(privateValue); + } finally { await server.stop(true); } }); From 584ba3f346b2c34531a8f70051bbc2b1897aaeae Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 21:52:17 +0900 Subject: [PATCH 7/8] feat(codex): estimate effective capacity from observed quota intervals --- devlog/_plan/260912_accounts/060_capacity.md | 16 +++- .../260912_accounts/061_capacity_delivery.md | 5 + .../ko/reference/cli/providers-accounts.md | 4 +- .../docs/reference/cli/providers-accounts.md | 4 +- scripts/test-layout/layout.json | 1 + src/cli/account-history.ts | 10 ++ src/codex/auth-api.ts | 34 ++++++- src/codex/quota-capacity.ts | 93 +++++++++++++++++++ src/usage/log.ts | 1 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/codex-home.md | 2 + structure/config.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/providers/openai-tiers.md | 6 ++ structure/runtime.md | 2 + structure/subagents.md | 2 + .../codex-integration/codex-auth-api.test.ts | 37 ++++++++ .../codex-quota-capacity.test.ts | 60 ++++++++++++ tests/fixtures/test-layout-expected.json | 1 + .../account-pool-management-api.test.ts | 2 +- tests/usage/usage-log.test.ts | 8 ++ 23 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 devlog/_plan/260912_accounts/061_capacity_delivery.md create mode 100644 src/codex/quota-capacity.ts create mode 100644 tests/codex-integration/codex-quota-capacity.test.ts diff --git a/devlog/_plan/260912_accounts/060_capacity.md b/devlog/_plan/260912_accounts/060_capacity.md index f4c5acb023..d222802811 100644 --- a/devlog/_plan/260912_accounts/060_capacity.md +++ b/devlog/_plan/260912_accounts/060_capacity.md @@ -2,14 +2,14 @@ Cycle capacity depends on history. Source: `src/usage/log.ts` already persists accountLogLabel, timestamp, reported/estimated usage and per-attempt attribution; `src/codex/account-label.ts` owns safe labels. Use those existing records instead of storing credentials or duplicating request attribution. -NEW `src/codex/quota-capacity.ts`: a pure estimator receives copied raw history and account-attributed reported usage observations. For each short/weekly/monthly window, pair adjacent fresh percentage observations only when reset identity matches, time increases and percentage delta is positive. Sum reported token usage in that interval, count per-attempt records once, exclude estimated/local/unattributed usage and reset/refund crossings. Estimate tokens per full window as observedTokens * 100 / percentageDelta; aggregate defensible intervals with median and report sampleCount plus observed-token lower-bound caveat. No valid interval returns null, never zero or a fabricated capacity. Bounded scan is invoked on management request, never routing; estimation is informational and does not overrule live quota. +NEW `src/codex/quota-capacity.ts`: a pure estimator receives copied raw history and account-attributed reported usage observations. For each short/weekly/monthly window, pair adjacent fresh percentage observations only when reset identity matches, time increases and percentage delta is positive. Sum reported token usage in that interval, count per-attempt records once, exclude estimated/local/unattributed usage and reset/refund crossings. Estimate tokens per full window as observedTokens * 100 / percentageDelta; aggregate defensible intervals with median and report sampleCount plus an explicit low-confidence inference caveat. No valid interval returns null, never zero or a fabricated capacity. Bounded scan is invoked on management request, never routing; estimation is informational and does not overrule live quota. ```ts export type CodexCapacityEstimate = { window: "short" | "weekly" | "monthly"; estimatedTokens: number; sampleCount: number; - confidence: "observed-lower-bound"; + confidence: "low"; }; ``` @@ -18,3 +18,15 @@ MODIFY history read API/CLI projection to attach per-window estimates with sampl A2 accepted: use readUsageSnapshotForManagement; if truncatedPrefixBytes>0, entriesTruncated, entriesDropped>0, missing revision, or invalid timing then return insufficient-evidence with no estimate. Treat each request as interval [timestamp, timestamp+durationMs] (request-log.ts:1039/1072); include only requests wholly contained in a quota-observation interval. Boundary-spanning requests contribute nothing. For included requests count reported physical attempts matching the exact pool label once; do not count both request total and attempts. Without attempts accept request-level reported usage only with matching label and no recovery ambiguity. Native main is excluded from token capacity because its historical label cannot establish identity after replacement. Current pool logLabel must be unique; legacy fallback labels/id reuse require insufficient evidence unless continuity is proven by history generation. Same-reset positive deltas only. Hand-worked boundary-spanning, truncation, missing identity and retry rows are mandatory regression fixtures. P future refinement from history sidecar: do not call estimate a mathematical lower bound. It is an observed effective token estimate under rounded/delayed quota and local coverage assumptions. Admit only single-send reported nonestimated attempts; present-but-empty attempt arrays cannot fall back to parent totals. Deduplicate requestId+ordinal and reject conflicting duplicates. Use interval (left,right] with whole request containment to avoid zero-duration double counting. Existing parser can skip malformed rows without a rejected counter: report retained-valid-ledger-only assumption explicitly or add rejected-row metadata before claiming complete coverage. Loglabel alone is not history identity; history publication UUID and current stable unique configured label must bind sample period. All source tests remain hosted-only. + +## Resumed capacity contract + +Depends on history PR4404/0d98205fcd. Add pure quota-capacity.ts estimator receiving public sanitized observations, validated usage rows and the current explicit unique random pool logLabel; no native-main/fallback labels. Per account short/weekly/monthly, pair adjacent raw observations only with same source/reset boundary, increasing localtime and percentage delta>=1. Count only whole requests within (left,right], single-send reported nonestimated nonlocal attempts matching that label. Presence of an empty attempts array never falls back to request totals. Deduplicate requestId; conflicting duplicates yield insufficient evidence. No inferred absolute attempt start. Exclude boundary-spanning requests and unknown/multisend usage; no valid pair yields insufficient-evidence. + +Use reported totalTokens or input+output exactly once, not reasoning/cache detail additions. Median effective tokens per100percentage over defensible intervals, sampleCount explicit. Output confidence low and assumptions array: rounded/delayed quota, only retained valid proxy ledger rows, label continuity assumed inside the observation interval, external usage not observed. This is an observed effective estimate, never a provider token limit or proven lower bound. The private credential publication UUID must match before/after async ledger read; current explicit logLabel and uniqueness must still match config. Any mismatch yields insufficient-evidence, not mixed identity. No estimate is used for scheduling. + +Extend existing history GET result with capacity:{status:estimated|insufficient-evidence,estimates:[{window,estimatedTokens,sampleCount,confidence:low}],reason?,assumptions}. Cached history remains visible on ledger read failure. Use readUsageSnapshotForManagement; reject truncatedPrefixBytes/entriesTruncated/entriesDropped, missingrevision and >10000 retainedrows before estimator scan. This deliberately does not attest missing/rejected historical ledger lines; assumptions state that limitation. CLI history humanoutput renders estimates and sample counts/caveat; JSON carries fullobject. No new config, timer, persistence, GUI surface or inference call. + +Tests handcomputed10→20% plus1000reportedtokens→10000estimate; mixed sources/reset/refund/0delta/rounding/timestampintervals, duplicate request IDs, absent-vs-empty attempts, multisend, local/estimated/unattributed tokens, nonfiniteoutput, truncatedledger andidentitychangedawait. Sample storage/read provides current publication evidence; retrospective label continuity is explicitly low-confidence inference, not independently verified identity. This clarification replaces earlier mathematically unprovable lower-bound wording without reducing raw-data/identity fences. Local suites/build/typecheck/install NOTRUN. Independent source design/review plus final cumulative tip hostedCI required; hostFSMblockedB remains unchanged. + +Implementation refinements: reject absent physicalattempts, deduplicate ordinals, countonlyaccountfamily/sharedmodelscope and matchingwindowduration/primaryprovenance withresetnotelapsed. Preserve locallyAnswered duringexistingusagenormalization so capacitycanexcludeit. CaptureUUIDbeforehistoryread and recheckbefore/afterasyncledgerread; usefullboundedhistory forestimationindependentofdisplaylimit. Labels re-read fromcurrentruntimeconfig. diff --git a/devlog/_plan/260912_accounts/061_capacity_delivery.md b/devlog/_plan/260912_accounts/061_capacity_delivery.md new file mode 100644 index 0000000000..7d8e8a89a1 --- /dev/null +++ b/devlog/_plan/260912_accounts/061_capacity_delivery.md @@ -0,0 +1,5 @@ +# Informational effective quota capacity + +This child of #4404 estimates observed reported tokens per100percentage from bounded raw observation intervals. It preserves private publication UUID checks and requires an explicit unique pool log label. The estimate is low-confidence with disclosed rounding, retained-valid-row, external-usage and label-continuity assumptions; it is not a provider limit or scheduling policy. + +Regression sources cover a hand-computed1000tokens/10points=10000, duplicates, single-send evidence, provenance/reset/interval/independent-model conditions, numeric overflow, bounded ledger rejection, populated API/CLI output and identity replacement during async usage read. Existing local-answer provenance now survives attempt normalization. No local suite/build/typecheck/install was run. Independent design source audit passed; implementation source review and final cumulative hostedCI remain pending. Actual hostgoal blocked/FSMB untouched; no persisted capacity PABCD cycle is claimed. diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 4f832d3dbb..bbed36cb4b 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -369,4 +369,6 @@ ocx models remove deepseek/deepseek-v4 --yes `ocx account history openai [--limit 1-200] [--json]`은 제공자에게 요청하지 않고 저장된 관측을 읽습니다. 관측 시각, WHAM·응답 헤더 출처, 한도 종류와 사용률을 구분해 표시합니다. 계정마다 최대 200개를 30일간 보관하며 전체 저장량에도 제한이 있습니다. -일반 토큰 갱신은 기록을 유지합니다. 재로그인·삭제·계정 교체는 이전 기록과 분리합니다. 네이티브 메인 계정과 로그인 저장 전 조회는 포함하지 않습니다. 기록이 없다는 것은 관측 부족이며 사용량 0을 뜻하지 않습니다. 이 명령은 토큰 용량을 추정하거나 쿼터를 소비하지 않습니다. +일반 토큰 갱신은 기록을 유지합니다. 재로그인·삭제·계정 교체는 이전 기록과 분리합니다. 네이티브 메인 계정과 로그인 저장 전 조회는 포함하지 않습니다. 기록이 없다는 것은 관측 부족이며 사용량 0을 뜻하지 않습니다. 이 명령은 쿼터를 소비하지 않습니다. 관측을 바탕으로 한 용량 추정에는 아래 한계가 적용됩니다. + +같은 초기화 구간의 관측과 계정별 사용 기록이 있으면 보고된 토큰 기준 용량 추정도 표시합니다. 표본 수와 낮은 신뢰도를 함께 표시하며, 쿼터 반올림·외부 사용량·로그 라벨 유지 여부 때문에 제공자의 실제 토큰 한도와 다를 수 있습니다. 기록이 없거나 잘렸으면 근거 부족으로 표시합니다. `--limit`은 표시할 기록 수만 제한하며 추정 입력은 전체 보관 범위입니다. diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index f39c1f3a57..30f289551d 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -586,4 +586,6 @@ all refuse the bad value rather than storing something the catalog writer would `ocx account history openai [--limit 1-200] [--json]` reads stored observations without contacting the provider. The output separates actual observation time, WHAM or response-header source, window family and usage percentage. At most 200 observations per account are retained for 30 days, with global storage bounds. -Ordinary token refresh preserves history. Reauthentication, removal or account replacement retires the old publication. Native main and probes performed before a login is published are not included. Missing history means insufficient observations, not zero usage. This command does not estimate token capacity or spend quota. +Ordinary token refresh preserves history. Reauthentication, removal or account replacement retires the old publication. Native main and probes performed before a login is published are not included. Missing history means insufficient observations, not zero usage. This command does not spend quota. Effective estimates, when supported by observations, carry the limitations below. + +The history output also includes effective reported-token estimates when same-window observations and attributable usage support them. Each estimate includes a sample count and low confidence. Quota rounding, external usage and assumed log-label continuity limit the inference; it is not your provider’s token allowance. Missing or truncated ledger evidence returns insufficient evidence. `--limit` controls displayed history, not the bounded estimate input. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 3691347aa2..f5370550fb 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -479,6 +479,7 @@ "codex-prompt-text-probe.test.ts": "codex-integration", "codex-quota-auto-refresh-main-admission.test.ts": "codex-integration", "codex-quota-auto-refresh.test.ts": "codex-integration", + "codex-quota-capacity.test.ts": "codex-integration", "codex-quota-history.test.ts": "codex-integration", "codex-quota-parser-parity.test.ts": "codex-integration", "codex-quota-prime.test.ts": "codex-integration", diff --git a/src/cli/account-history.ts b/src/cli/account-history.ts index c7662d3ad4..b33dd88102 100644 --- a/src/cli/account-history.ts +++ b/src/cli/account-history.ts @@ -41,5 +41,15 @@ export async function cmdAccountHistory(args: string[], deps: AccountDeps): Prom console.log(`${historyDate(observation.observedAt)}\t${observation.source}\t${window.family}/${window.window}\t${window.usedPercent}%\t${historyDate(window.resetAtMs)}`); } } + const capacity = result.json.capacity; + if (capacity && typeof capacity === "object" && "status" in capacity && capacity.status === "estimated" + && "estimates" in capacity && Array.isArray(capacity.estimates)) { + console.log("Effective capacity estimate (low confidence; not a provider token limit):"); + for (const estimate of capacity.estimates) { + if (estimate && Number.isFinite(estimate.estimatedTokens) && Number.isSafeInteger(estimate.sampleCount)) { + console.log(`${estimate.window}\t~${estimate.estimatedTokens} reported tokens / 100%\t${estimate.sampleCount} samples`); + } + } + } return 0; } diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 4c112d03b0..cc91579cd9 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -1,3 +1,7 @@ +import { CODEX_ACCOUNT_LOG_LABEL_RE } from "./account-label"; +import { poolQuotaHistoryIdentity } from "./account-store"; +import { estimateCodexQuotaCapacity, insufficientCodexCapacity, type CodexCapacityResult } from "./quota-capacity"; +import { readUsageSnapshotForManagement } from "../usage/log"; import { capturePoolQuotaWriter } from "./account-store"; import type { PoolQuotaWriter } from "./quota-types"; import { getAccountQuotaHistory, isValidWhamHistoryObservation } from "./quota"; @@ -44,6 +48,7 @@ import { } from "./account-priority"; import { claimDueCodexQuotaRecoveryProbes, + codexQuotaScopeForModel, claimManualResetCooldowns, settleManualResetCooldown, type ManualResetCooldownClaim, @@ -2543,8 +2548,35 @@ export async function handleCodexAuthAPI( || (rawLimit !== null && !/^(?:[1-9]|[1-9][0-9]|1[0-9]{2}|200)$/.test(rawLimit))) { return jsonResponse({ error: "A stored pool accountId and optional limit from 1 to 200 are required" }, 400); } + const runtimeConfig = getRuntimeConfig(config); + const account = configuredPoolAccount(runtimeConfig, accountId); + if (!account) return jsonResponse({ error: "Unknown pool account" }, 404); + const identity = poolQuotaHistoryIdentity(accountId); + const allHistory = getAccountQuotaHistory(accountId); + const limit = rawLimit === null ? 200 : Number(rawLimit); + const history = { ...allHistory, observations: allHistory.observations.slice(-limit), truncated: allHistory.observations.length > limit }; + const label = account.logLabel; + const labelStillUnique = () => { + const current = getRuntimeConfig(config); + return configuredPoolAccount(current, accountId)?.logLabel === label + && current.codexAccounts?.filter(row => codexAccountLogLabel(row) === label).length === 1; + }; + let capacity: CodexCapacityResult = insufficientCodexCapacity("identity_unavailable"); + if (identity && identity === poolQuotaHistoryIdentity(accountId) && label && CODEX_ACCOUNT_LOG_LABEL_RE.test(label) && labelStillUnique()) { + try { + const usage = await readUsageSnapshotForManagement(); + if (poolQuotaHistoryIdentity(accountId) !== identity || !labelStillUnique()) capacity = insufficientCodexCapacity("identity_changed"); + else if (!usage.revision) capacity = insufficientCodexCapacity("ledger_unavailable"); + else if (usage.truncatedPrefixBytes > 0 || usage.entriesTruncated || usage.entriesDropped > 0) capacity = insufficientCodexCapacity("ledger_truncated"); + else capacity = estimateCodexQuotaCapacity(allHistory.observations, usage.entries, label, + model => { const scope = codexQuotaScopeForModel(model); return scope !== "spark" && scope !== "reserve"; }); + } catch { capacity = insufficientCodexCapacity("ledger_unavailable"); } + } if (!configuredPoolAccount(getRuntimeConfig(config), accountId)) return jsonResponse({ error: "Unknown pool account" }, 404); - return jsonResponse({ accountId, ...getAccountQuotaHistory(accountId, rawLimit === null ? 200 : Number(rawLimit)) }); + if (identity !== poolQuotaHistoryIdentity(accountId) || (identity && label && !labelStillUnique())) { + return jsonResponse({ accountId, ...getAccountQuotaHistory(accountId, limit), capacity: insufficientCodexCapacity("identity_changed") }); + } + return jsonResponse({ accountId, ...history, capacity }); } if (url.pathname === "/api/codex-auth/quota" && req.method === "GET") { diff --git a/src/codex/quota-capacity.ts b/src/codex/quota-capacity.ts new file mode 100644 index 0000000000..9b83e614f5 --- /dev/null +++ b/src/codex/quota-capacity.ts @@ -0,0 +1,93 @@ +import type { QuotaHistorySample, QuotaHistoryWindow } from "./quota-history"; +import type { PersistedUsageAttempt, PersistedUsageEntry } from "../usage/log"; + +export const CAPACITY_ASSUMPTIONS = [ + "Quota percentages can be rounded or delayed.", + "Only retained valid proxy usage rows are observed; external usage is unknown.", + "Account log labels are assumed stable within each observation interval.", + "This low-confidence effective-token estimate is not a provider token limit or lower bound.", +] as const; +export type CapacityReason = "insufficient_intervals" | "ledger_unavailable" | "ledger_truncated" | "identity_unavailable" | "identity_changed" | "ambiguous_usage"; +export interface CodexCapacityResult { + status: "estimated" | "insufficient-evidence"; + estimates: Array<{ window: QuotaHistoryWindow["window"]; estimatedTokens: number; sampleCount: number; confidence: "low" }>; + reason?: CapacityReason; + assumptions: readonly string[]; +} +export function insufficientCodexCapacity(reason: CapacityReason): CodexCapacityResult { + return { status: "insufficient-evidence", estimates: [], reason, assumptions: [...CAPACITY_ASSUMPTIONS] }; +} +const nonnegative = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0; + +function reportedTokens(attempt: PersistedUsageAttempt): number | undefined { + if (attempt.sendCount !== 1 || attempt.usageStatus !== "reported" || attempt.locallyAnswered === true + || !attempt.usage || attempt.usage.estimated === true + || !nonnegative(attempt.usage.inputTokens) || !nonnegative(attempt.usage.outputTokens)) return undefined; + const total = attempt.usage.totalTokens ?? attempt.usage.inputTokens + attempt.usage.outputTokens; + return nonnegative(total) ? total : undefined; +} + +/** Informational inference over raw same-window observations, never an account-selection input. */ +export function estimateCodexQuotaCapacity( + observations: ReadonlyArray>, + entries: readonly PersistedUsageEntry[], + label: string, + sharedQuotaModel: (model: string) => boolean, +): CodexCapacityResult { + if (entries.length > 10_000) return insufficientCodexCapacity("ledger_truncated"); + const requests = new Map(); + for (const entry of entries) { + const previous = requests.get(entry.requestId); + if (previous && JSON.stringify(previous) !== JSON.stringify(entry)) return insufficientCodexCapacity("ambiguous_usage"); + requests.set(entry.requestId, entry); + } + const sorted = [...observations].sort((a, b) => a.observedAt - b.observedAt); + const estimates: CodexCapacityResult["estimates"] = []; + for (const windowName of ["short", "weekly", "monthly"] as const) { + const points = sorted.flatMap(row => { + const window = row.windows.find(candidate => candidate.family === "account" && candidate.window === windowName); + return window ? [{ ...window, at: row.observedAt, source: row.source }] : []; + }); + const samples: number[] = []; + for (let index = 1; index < points.length; index++) { + const left = points[index - 1], right = points[index]; + const delta = right.usedPercent - left.usedPercent; + if (left.source !== right.source || !nonnegative(left.resetAtMs) || left.resetAtMs !== right.resetAtMs + || left.resetAtMs <= right.at || left.windowSeconds !== right.windowSeconds + || left.monthlyIsPrimaryWindow !== right.monthlyIsPrimaryWindow + || right.at <= left.at || delta < 1 || delta > 100 || !Number.isFinite(delta)) continue; + let tokens = 0; + let valid = true; + for (const entry of requests.values()) { + if (!nonnegative(entry.timestamp) || !nonnegative(entry.durationMs)) continue; + const end = entry.timestamp + entry.durationMs; + if (!Number.isFinite(end) || entry.timestamp <= left.at || end > right.at) continue; + // Untimed or absent physical-attempt evidence cannot be reconstructed from parent totals. + if (!entry.attempts?.length) continue; + const attempts = new Map(); + for (const attempt of entry.attempts) { + const prior = attempts.get(attempt.ordinal); + if (prior && JSON.stringify(prior) !== JSON.stringify(attempt)) { valid = false; break; } + attempts.set(attempt.ordinal, attempt); + } + if (!valid) break; + for (const attempt of attempts.values()) { + if (attempt.accountLogLabel !== label || attempt.adapter !== "openai-responses" || !sharedQuotaModel(attempt.model)) continue; + const reported = reportedTokens(attempt); + if (reported === undefined) continue; + tokens += reported; + } + } + const inferred = tokens * 100 / delta; + if (valid && tokens > 0 && Number.isFinite(inferred) && inferred > 0) samples.push(inferred); + } + if (samples.length) { + samples.sort((a, b) => a - b); + const middle = Math.floor(samples.length / 2); + const median = samples.length % 2 ? samples[middle] : samples[middle - 1] / 2 + samples[middle] / 2; + estimates.push({ window: windowName, estimatedTokens: Math.round(median), sampleCount: samples.length, confidence: "low" }); + } + } + return estimates.length ? { status: "estimated", estimates, assumptions: [...CAPACITY_ASSUMPTIONS] } + : insufficientCodexCapacity("insufficient_intervals"); +} diff --git a/src/usage/log.ts b/src/usage/log.ts index 2944c22f9a..3320a0022f 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -455,6 +455,7 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null { durationMs: attempt.durationMs, // Absent by default; only the literal `true` marker survives the round trip. ...(attempt.streamAborted === true ? { streamAborted: true } : {}), + ...(attempt.locallyAnswered === true ? { locallyAnswered: true } : {}), ...(isNonNegativeFiniteNumber(attempt.firstOutputMs) ? { firstOutputMs: attempt.firstOutputMs } : {}), diff --git a/structure/catalog.md b/structure/catalog.md index 6ba73c03d1..4ddd86ff1c 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -282,3 +282,5 @@ privately to final dispatch; preliminary route selection does not inject Go-only Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. + +The account history response can include a [low-confidence effective capacity estimate](providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index aa95b9f74a..10a4d65a50 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -93,3 +93,5 @@ The explicit sync coordinator also accepts Cline CLI as a separate file integrat Config JSON preserves the boolean; only literal true activates the role-changing transform. Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. + +The account history response can include a [low-confidence effective capacity estimate](../providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. diff --git a/structure/codex-home.md b/structure/codex-home.md index d9c51d351d..5d43f8107e 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -240,3 +240,5 @@ Native restore preflight also checks manifest-owned targets whose rows already r Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. + +The account history response can include a [low-confidence effective capacity estimate](providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. diff --git a/structure/config.md b/structure/config.md index 6e5f280401..e0db5fd370 100644 --- a/structure/config.md +++ b/structure/config.md @@ -209,3 +209,5 @@ The Cline client keeps connection settings and models in a separate native file Config JSON preserves the boolean; only literal true activates the role-changing transform. Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. + +The account history response can include a [low-confidence effective capacity estimate](providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 3d186568fa..105cdcf3e5 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -547,3 +547,5 @@ integration IO adapter. Its snapshot fingerprint cannot be checked against provi The existing dashboard file-client maps include Cline CLI and reuse its committed color mark. The export panel labels its download as a settings/catalog bundle; all locales explain that Undo restores both original files. Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. + +The account history response can include a [low-confidence effective capacity estimate](providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 9d1c835637..0160f0ba6b 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -318,3 +318,5 @@ Private pool credential metadata follows the [quota-history publication identity The integrations guide documents Cline CLI as a two-file, loopback-only integration. Hosted CI validates its source-backed fixtures; the packaged dashboard exposes it through the existing client list. Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. + +The account history response can include a [low-confidence effective capacity estimate](../providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index b3137f178d..47f8f94fc9 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -416,3 +416,9 @@ successful main usage refresh clears the runtime mark. WHAM and response-header producers pass the exact captured pool writer, including refreshed replay and compact outcomes. Admission rechecks credential generation and publication UUID. Same-account refresh preserves prior history; replacement/removal invalidates it. Raw invalid percentages discard the entire trusted observation before display clamping; carried windows, reset credits alone, native main and staged-login probes never become durable pool history. `GET /api/codex-auth/quota/history` and `ocx account history openai ` read only cached, identity-checked observations. The optional limit is 1–200. Public results omit the internal publication UUID and credential generation. These observations are inputs for capacity estimation; percentages alone do not establish absolute token capacity. + +## Observed effective token capacity + +`src/codex/quota-capacity.ts` joins raw account-family observations with reported single-send usage attempts wholly contained within matching, unexpired reset intervals. Source, window duration and monthly-primary provenance must match; percentage delta must be at least one point. Duplicate request/attempt identities never multiply usage. Local, estimated, multi-send, independent-model and absent-attempt evidence does not supply a capacity sample. + +The history read API reports a median effective token estimate and interval sample count with low confidence and explicit coverage/rounding/label-continuity assumptions. It is not a provider token limit or mathematical lower bound and never affects account selection. Truncated, unavailable or excessive usage-ledger reads produce insufficient evidence while retaining history. Publication UUID and explicit unique account label are checked around the asynchronous read; identity changes discard the estimate and refresh the returned history. diff --git a/structure/runtime.md b/structure/runtime.md index 93ffb9993f..b77618e219 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -229,3 +229,5 @@ Cline CLI joins the existing export/client integration registries. Explicit CLI Config JSON preserves the boolean; only literal true activates the role-changing transform. Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. + +The account history response can include a [low-confidence effective capacity estimate](providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. diff --git a/structure/subagents.md b/structure/subagents.md index 29b258ceb3..a42cae401c 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -218,3 +218,5 @@ privately to final dispatch; preliminary route selection does not inject Go-only Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. + +The account history response can include a [low-confidence effective capacity estimate](providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 51a05d9170..7cefc7bd83 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -1,3 +1,4 @@ +import * as usageHistoryModule from "../../src/usage/log"; import { getAccountQuotaHistory } from "../../src/codex/quota"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import type { ServerWebSocket } from "bun"; @@ -1052,6 +1053,42 @@ describe("codex-auth API", () => { } }); + test("history capacity uses reported intervals and invalidates after identity changes during the ledger read", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "capacity-a", email: "capacity@example.test", plan: "plus" }); + config.codexAccounts![0].logLabel = "pabcdef"; + const { capturePoolQuotaWriter } = await import("../../src/codex/account-store"); + const record = readCodexAccountRecord("capacity-a")!; + const writer = capturePoolQuotaWriter("capacity-a", { ...record.credential!, generation: record.generation })!; + const now = Date.now(); + for (const [observedAt, weeklyPercent] of [[now - 2000, 10], [now, 20]]) { + const raw = { weeklyPercent, weeklyResetAt: now + 100_000 }; + setAccountQuotaFromParsed("capacity-a", raw, undefined, undefined, raw, { writer, observedAt, source: "wham", raw }); + } + usageHistoryModule.appendUsageEntry({ requestId: "capacity-request", timestamp: now - 1000, durationMs: 100, provider: "openai", model: "gpt-5.5", status: 200, usageStatus: "reported", attempts: [{ + ordinal: 1, provider: "openai", model: "gpt-5.5", adapter: "openai-responses", status: 200, durationMs: 100, sendCount: 1, + recoveryKinds: [], usageStatus: "reported", accountLogLabel: "pabcdef", usage: { inputTokens: 800, outputTokens: 200, totalTokens: 1000 }, + }] }); + const request = () => new Request("http://localhost/api/codex-auth/quota/history?accountId=capacity-a&limit=1"); + const req = request(); + const result = await handleCodexAuthAPI(req, new URL(req.url), config); + const body = await result!.json() as { observations: unknown[]; capacity: { status: string; estimates: unknown[] } }; + expect(body.observations).toHaveLength(1); + expect(body.capacity.estimates).toEqual([{ window: "weekly", estimatedTokens: 10000, sampleCount: 1, confidence: "low" }]); + const originalRead = usageHistoryModule.readUsageSnapshotForManagement; + const read = spyOn(usageHistoryModule, "readUsageSnapshotForManagement").mockImplementation(async () => { + const snapshot = await originalRead(); + saveCodexAccountCredential("capacity-a", record.credential!); + return snapshot; + }); + try { + const next = request(); + const response = await handleCodexAuthAPI(next, new URL(next.url), config); + expect(read).toHaveBeenCalledTimes(1); + expect(await response!.json()).toMatchObject({ observations: [], capacity: { status: "insufficient-evidence", reason: "identity_changed", estimates: [] } }); + } finally { read.mockRestore(); } + }); + test("GET /api/codex-auth/accounts returns array with main", async () => { const req = new Request("http://localhost/api/codex-auth/accounts", { method: "GET" }); const url = new URL(req.url); diff --git a/tests/codex-integration/codex-quota-capacity.test.ts b/tests/codex-integration/codex-quota-capacity.test.ts new file mode 100644 index 0000000000..0f10d2b4a7 --- /dev/null +++ b/tests/codex-integration/codex-quota-capacity.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { estimateCodexQuotaCapacity } from "../../src/codex/quota-capacity"; +import type { QuotaHistorySample } from "../../src/codex/quota-history"; +import type { PersistedUsageEntry, PersistedUsageAttempt } from "../../src/usage/log"; + +const label = "pabcdef"; +const shared = (model: string) => model !== "independent"; +const point = (at: number, percent: number): Omit => ({ + observedAt: at, source: "wham", windows: [{ family: "account", window: "weekly", usedPercent: percent, resetAtMs: 10_000 }], +}); +const attempt = (overrides: Partial = {}): PersistedUsageAttempt => ({ + ordinal: 1, provider: "openai", model: "gpt-test", adapter: "openai-responses", status: 200, durationMs: 10, + sendCount: 1, recoveryKinds: [], usageStatus: "reported", accountLogLabel: label, + usage: { inputTokens: 800, outputTokens: 200, totalTokens: 1000 }, ...overrides, +}); +const entry = (overrides: Partial = {}): PersistedUsageEntry => ({ + requestId: "r1", timestamp: 1100, durationMs: 100, provider: "openai", model: "gpt-test", status: 200, usageStatus: "reported", + attempts: [attempt()], ...overrides, +}); +const points = [point(1000, 10), point(2000, 20)]; + +describe("observed effective quota capacity", () => { + test("hand-calculated 1000 reported tokens over ten percentage points estimates 10000", () => { + const result = estimateCodexQuotaCapacity(points, [entry()], label, shared); + expect(result.status).toBe("estimated"); + expect(result.estimates).toEqual([{ window: "weekly", estimatedTokens: 10000, sampleCount: 1, confidence: "low" }]); + expect(result.assumptions.length).toBeGreaterThan(0); + }); + + test("duplicate rows and ordinals count once while conflicts refuse estimation", () => { + expect(estimateCodexQuotaCapacity(points, [entry(), entry()], label, shared).estimates[0].estimatedTokens).toBe(10000); + expect(estimateCodexQuotaCapacity(points, [entry({ attempts: [attempt(), attempt()] })], label, shared).estimates[0].estimatedTokens).toBe(10000); + expect(estimateCodexQuotaCapacity(points, [entry(), entry({ durationMs: 101 })], label, shared).status).toBe("insufficient-evidence"); + expect(estimateCodexQuotaCapacity(points, [entry({ attempts: [attempt(), attempt({ sendCount: 2 })] })], label, shared).status).toBe("insufficient-evidence"); + }); + + test.each([ + entry({ timestamp: 1000 }), entry({ timestamp: 1999, durationMs: 2 }), entry({ attempts: [] }), entry({ attempts: undefined }), + entry({ attempts: [attempt({ sendCount: 2 })] }), entry({ attempts: [attempt({ locallyAnswered: true })] }), + entry({ attempts: [attempt({ usage: { inputTokens: 1, outputTokens: 1, estimated: true } })] }), + entry({ attempts: [attempt({ usageStatus: "unreported" })] }), entry({ attempts: [attempt({ accountLogLabel: "p123456" })] }), + entry({ attempts: [attempt({ model: "independent" })] }), + ])("unknown or outside-interval usage supplies no sample", row => { + expect(estimateCodexQuotaCapacity(points, [row], label, shared).status).toBe("insufficient-evidence"); + }); + + test("window/provenance/reset changes, refunds and tiny deltas are not capacity intervals", () => { + for (const right of [point(2000, 9), point(2000, 10), point(2000, 10.1), { ...point(2000, 20), source: "response-header" as const }, + { ...point(2000, 20), windows: [{ ...point(2000, 20).windows[0], resetAtMs: undefined }] }, + { ...point(2000, 20), windows: [{ ...point(2000, 20).windows[0], resetAtMs: 20_000 }] }, + { ...point(2000, 20), windows: [{ ...point(2000, 20).windows[0], family: "spark" as const }] }, + ]) expect(estimateCodexQuotaCapacity([points[0], right], [entry()], label, shared).status).toBe("insufficient-evidence"); + }); + + test("overflow and bounded scan cannot produce a finite-looking false result", () => { + const oversized = entry({ attempts: [attempt({ usage: { inputTokens: Number.MAX_VALUE, outputTokens: Number.MAX_VALUE } })] }); + expect(estimateCodexQuotaCapacity(points, [oversized], label, shared).status).toBe("insufficient-evidence"); + expect(estimateCodexQuotaCapacity(points, Array.from({ length: 10001 }, () => entry()), label, shared).reason).toBe("ledger_truncated"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6aff5af109..dd034aff57 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -314,6 +314,7 @@ "codex-prompt-text-probe.test.ts": "codex-integration", "codex-quota-auto-refresh-main-admission.test.ts": "codex-integration", "codex-quota-auto-refresh.test.ts": "codex-integration", + "codex-quota-capacity.test.ts": "codex-integration", "codex-quota-history.test.ts": "codex-integration", "codex-quota-parser-parity.test.ts": "codex-integration", "codex-quota-prime.test.ts": "codex-integration", diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index 63a1feefc3..90805e538b 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -687,7 +687,7 @@ describe("unified pool-settings contract (#695 wp5c)", () => { await unknown.text(); const response = await fetch(new URL(`${endpoint}?accountId=history-row&limit=1`, server.url)); expect(response.status).toBe(200); - expect(await response.json()).toEqual({ accountId: "history-row", observations: [], retention: { maxObservations: 200, maxAgeDays: 30 }, truncated: false }); + expect(await response.json()).toEqual({ accountId: "history-row", observations: [], retention: { maxObservations: 200, maxAgeDays: 30 }, truncated: false, capacity: { status: "insufficient-evidence", reason: "identity_unavailable", estimates: [], assumptions: expect.any(Array) } }); const { saveCodexAccountCredential, capturePoolQuotaWriter } = await import("../../src/codex/account-store"); const { setAccountQuotaFromParsed } = await import("../../src/codex/quota"); const credential = { accessToken: "history-secret-access", refreshToken: "history-secret-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "private-history-account" }; diff --git a/tests/usage/usage-log.test.ts b/tests/usage/usage-log.test.ts index f39c778d2b..9cd97855a6 100644 --- a/tests/usage/usage-log.test.ts +++ b/tests/usage/usage-log.test.ts @@ -579,6 +579,14 @@ describe("usage log", () => { expect(valid.attempts?.[0]?.reasoningWireValue).toBe(false); }); + test("local-answer provenance survives attempt normalization for capacity exclusion", () => { + const value = normalizeUsageEntryForTest({ requestId: "local-capacity", timestamp: Date.now(), provider: "openai", model: "m", status: 200, durationMs: 1, usageStatus: "reported", attempts: [{ + ordinal: 1, provider: "openai", model: "m", adapter: "openai-responses", status: 200, durationMs: 1, sendCount: 1, + recoveryKinds: [], usageStatus: "reported", locallyAnswered: true, accountLogLabel: "pabcdef", usage: { inputTokens: 1, outputTokens: 1 }, + }] }); + expect(value.attempts?.[0]?.locallyAnswered).toBe(true); + }); + test("drops only malformed persisted attempts while preserving valid siblings", () => { const valid = (ordinal: number) => ({ ordinal, From 8845f63cc57b445089b6874467612a7256244139 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 21:55:18 +0900 Subject: [PATCH 8/8] fix(codex): exclude unknown capacity scope and report insufficient evidence --- .../_plan/260912_accounts/061_capacity_delivery.md | 2 ++ src/cli/account-history.ts | 5 +++++ src/codex/auth-api.ts | 2 +- src/codex/quota-capacity.ts | 9 +++++++-- tests/cli/cli-account.test.ts | 13 +++++++++++++ tests/codex-integration/codex-auth-api.test.ts | 7 +++++++ .../codex-integration/codex-quota-capacity.test.ts | 6 ++++++ 7 files changed, 41 insertions(+), 3 deletions(-) diff --git a/devlog/_plan/260912_accounts/061_capacity_delivery.md b/devlog/_plan/260912_accounts/061_capacity_delivery.md index 7d8e8a89a1..da434e3e12 100644 --- a/devlog/_plan/260912_accounts/061_capacity_delivery.md +++ b/devlog/_plan/260912_accounts/061_capacity_delivery.md @@ -3,3 +3,5 @@ This child of #4404 estimates observed reported tokens per100percentage from bounded raw observation intervals. It preserves private publication UUID checks and requires an explicit unique pool log label. The estimate is low-confidence with disclosed rounding, retained-valid-row, external-usage and label-continuity assumptions; it is not a provider limit or scheduling policy. Regression sources cover a hand-computed1000tokens/10points=10000, duplicates, single-send evidence, provenance/reset/interval/independent-model conditions, numeric overflow, bounded ledger rejection, populated API/CLI output and identity replacement during async usage read. Existing local-answer provenance now survives attempt normalization. No local suite/build/typecheck/install was run. Independent design source audit passed; implementation source review and final cumulative hostedCI remain pending. Actual hostgoal blocked/FSMB untouched; no persisted capacity PABCD cycle is claimed. + +Source review corrections: API accepts only explicit shared quota scope, excluding blank/undefined model identity through an actual populated API regression. CLI prints insufficient-evidence reasons through the closed reason parser, with estimated/insufficient human+JSON fixtures. A positive fraction that rounds to zero yields no estimate. Local suites remain NOTRUN. diff --git a/src/cli/account-history.ts b/src/cli/account-history.ts index b33dd88102..9534cf30fd 100644 --- a/src/cli/account-history.ts +++ b/src/cli/account-history.ts @@ -1,3 +1,4 @@ +import { parseCapacityReason } from "../codex/quota-capacity"; import { isValidCodexAccountId } from "../codex/account-id"; import { apiError, apiJson, proxyUnreachable, resolveBaseUrl, type AccountDeps } from "./account-api"; @@ -51,5 +52,9 @@ export async function cmdAccountHistory(args: string[], deps: AccountDeps): Prom } } } + if (capacity && typeof capacity === "object" && "status" in capacity && capacity.status === "insufficient-evidence") { + const reason = "reason" in capacity ? parseCapacityReason(capacity.reason) : undefined; + console.log(`Effective capacity: insufficient evidence${reason ? ` (${reason})` : ""}.`); + } return 0; } diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index cc91579cd9..c128e781d8 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -2569,7 +2569,7 @@ export async function handleCodexAuthAPI( else if (!usage.revision) capacity = insufficientCodexCapacity("ledger_unavailable"); else if (usage.truncatedPrefixBytes > 0 || usage.entriesTruncated || usage.entriesDropped > 0) capacity = insufficientCodexCapacity("ledger_truncated"); else capacity = estimateCodexQuotaCapacity(allHistory.observations, usage.entries, label, - model => { const scope = codexQuotaScopeForModel(model); return scope !== "spark" && scope !== "reserve"; }); + model => codexQuotaScopeForModel(model) === "shared"); } catch { capacity = insufficientCodexCapacity("ledger_unavailable"); } } if (!configuredPoolAccount(getRuntimeConfig(config), accountId)) return jsonResponse({ error: "Unknown pool account" }, 404); diff --git a/src/codex/quota-capacity.ts b/src/codex/quota-capacity.ts index 9b83e614f5..d22d53b511 100644 --- a/src/codex/quota-capacity.ts +++ b/src/codex/quota-capacity.ts @@ -7,7 +7,11 @@ export const CAPACITY_ASSUMPTIONS = [ "Account log labels are assumed stable within each observation interval.", "This low-confidence effective-token estimate is not a provider token limit or lower bound.", ] as const; -export type CapacityReason = "insufficient_intervals" | "ledger_unavailable" | "ledger_truncated" | "identity_unavailable" | "identity_changed" | "ambiguous_usage"; +export const CAPACITY_REASONS = ["insufficient_intervals", "ledger_unavailable", "ledger_truncated", "identity_unavailable", "identity_changed", "ambiguous_usage"] as const; +export type CapacityReason = typeof CAPACITY_REASONS[number]; +export function parseCapacityReason(value: unknown): CapacityReason | undefined { + return CAPACITY_REASONS.find(reason => reason === value); +} export interface CodexCapacityResult { status: "estimated" | "insufficient-evidence"; estimates: Array<{ window: QuotaHistoryWindow["window"]; estimatedTokens: number; sampleCount: number; confidence: "low" }>; @@ -85,7 +89,8 @@ export function estimateCodexQuotaCapacity( samples.sort((a, b) => a - b); const middle = Math.floor(samples.length / 2); const median = samples.length % 2 ? samples[middle] : samples[middle - 1] / 2 + samples[middle] / 2; - estimates.push({ window: windowName, estimatedTokens: Math.round(median), sampleCount: samples.length, confidence: "low" }); + const estimatedTokens = Math.round(median); + if (estimatedTokens > 0) estimates.push({ window: windowName, estimatedTokens, sampleCount: samples.length, confidence: "low" }); } } return estimates.length ? { status: "estimated", estimates, assumptions: [...CAPACITY_ASSUMPTIONS] } diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index a41187e27f..cb18aa5f7c 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -586,6 +586,19 @@ afterEach(() => { }); describe("ocx account CLI (issue #180 matrix)", () => { + test.each(["estimated", "insufficient-evidence"] as const)("human and JSON history preserve capacity status %s", async status => { + const capacity = status === "estimated" ? { status, estimates: [{ window: "weekly", estimatedTokens: 10000, sampleCount: 2, confidence: "low" }] } + : { status, reason: "ledger_truncated", estimates: [] }; + const deps: AccountDeps = { baseUrl: "http://127.0.0.1:10100", fetchImpl: (async () => Response.json({ observations: [{ + observedAt: 1_800_000_000_000, source: "wham", windows: [{ family: "account", window: "weekly", usedPercent: 20 }], + }], capacity })) as typeof fetch }; + const human = await run(["history", "openai", "pool-a"], deps); + expect(human.code).toBe(0); + expect(human.stdout).toContain(status === "estimated" ? "~10000 reported tokens / 100%\t2 samples" : "insufficient evidence (ledger_truncated)"); + const json = await run(["history", "openai", "pool-a", "--json"], deps); + expect(JSON.parse(json.stdout).capacity).toEqual(capacity); + }); + test("human quota history renders populated rows and safely handles oversized reset dates", async () => { const result = await run(["history", "openai", "pool-a"], { baseUrl: "http://127.0.0.1:10100", fetchImpl: (async () => Response.json({ observations: [{ observedAt: 1_800_000_000_000, source: "wham", windows: [ diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 7cefc7bd83..fe6eca6ed2 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -1075,6 +1075,13 @@ describe("codex-auth API", () => { const body = await result!.json() as { observations: unknown[]; capacity: { status: string; estimates: unknown[] } }; expect(body.observations).toHaveLength(1); expect(body.capacity.estimates).toEqual([{ window: "weekly", estimatedTokens: 10000, sampleCount: 1, confidence: "low" }]); + const stored = usageHistoryModule.readUsageEntries(); + expect(stored).toHaveLength(1); + stored[0].attempts![0].model = " "; + writeFileSync(usageHistoryModule.usageLogPath(), JSON.stringify(stored[0]) + "\n"); + const blankModel = request(); + const blankResult = await handleCodexAuthAPI(blankModel, new URL(blankModel.url), config); + expect((await blankResult!.json()).capacity).toMatchObject({ status: "insufficient-evidence", estimates: [] }); const originalRead = usageHistoryModule.readUsageSnapshotForManagement; const read = spyOn(usageHistoryModule, "readUsageSnapshotForManagement").mockImplementation(async () => { const snapshot = await originalRead(); diff --git a/tests/codex-integration/codex-quota-capacity.test.ts b/tests/codex-integration/codex-quota-capacity.test.ts index 0f10d2b4a7..3bca1e8391 100644 --- a/tests/codex-integration/codex-quota-capacity.test.ts +++ b/tests/codex-integration/codex-quota-capacity.test.ts @@ -58,3 +58,9 @@ describe("observed effective quota capacity", () => { expect(estimateCodexQuotaCapacity(points, Array.from({ length: 10001 }, () => entry()), label, shared).reason).toBe("ledger_truncated"); }); }); + + +test("a positive fractional inference never publishes zero capacity after rounding", () => { + const small = entry({ attempts: [attempt({ usage: { inputTokens: 0.1, outputTokens: 0 } })] }); + expect(estimateCodexQuotaCapacity([point(1000, 0), point(2000, 90)], [small], label, shared).status).toBe("insufficient-evidence"); +});