diff --git a/devlog/_plan/260926_main_hard_lock_any_window/010_plan.md b/devlog/_plan/260926_main_hard_lock_any_window/010_plan.md new file mode 100644 index 00000000000..5efa6360a1b --- /dev/null +++ b/devlog/_plan/260926_main_hard_lock_any_window/010_plan.md @@ -0,0 +1,51 @@ +# 010 — Main-account 98% hard lock: any governing window blocks + +## Problem +`getMainAccountHardLockStatus` (src/codex/main-account-hard-lock.ts) selects ONE window: 5h when any +5h field exists, else weekly, else monthly. A weekly reading of 98–100% with a 5h reading below 98 is +reported `ready`, so ocx keeps admitting main-account traffic until the weekly window is drained. + +## Required policy (user, 2026-09-26) +- 5h >= 98 blocks, regardless of weekly. +- weekly >= 98 blocks immediately, even when 5h < 98. +- Either alone is sufficient. + +## Diff-level plan +1. src/codex/main-account-hard-lock.ts + - Governing windows = [short] if present + [weekly] if present; monthly only when neither exists + (monthly-only accounts, unchanged; supplementary monthly still never governs). + - Classify each window: blocked (finite, 0..100, >= 98), ready (finite, 0..<98), unknown (missing/invalid). + - Any blocked -> blocked. resetAt = latest future reset among blocking windows; omitted when any + blocking window lacks a future reset (lock lasts until EVERY blocking window reads lower). + - Else any unknown -> unknown (admits, as today). Else ready. + - An invalid/unknown 5h reading can no longer mask a valid blocking weekly reading. +2. src/codex/quota.ts mergeAccountQuota: policy-mode guard symmetric to preserveKnownShort — an + observation with weeklyResetAt but no weeklyPercent keeps a retained blocking weekly tuple. +3. Tests (flip intended pins, add the user's cases): + - main-account-hard-lock-policy.test.ts: replace "5h wins", "expired 5h no fallback", "5h shape stays + unknown" with any-window cases: 5h97+w98 blocked, 5h98+w50 blocked, 5h97+w97 ready, release requires both, + 5h-unknown+w99 blocked, 5h-unknown+w20 unknown, resetAt = latest; reset test line 70 now needs weekly 0. + - main-quota-evidence-validation.test.ts: "short zero keeps priority over weekly99" -> weekly99 blocks; + "rejected short usage" -> blocked. + - main-quota-window-observation.test.ts: weekly99 samples -> blocked, metadata-only -> unknown. + - weekly reset-only policy observation keeps weekly block (new). +4. Copy: gui/src/i18n/*.ts codexAuth.mainHardLockDesc (10 locales), docs-site en+ko providers-accounts, + structure/providers/openai-tiers.md paragraph. + +## Out of scope +Monthly as an extra OR term for weekly/5h accounts; routing score; recovery probe cadence. + +## Verification +Focused: the three hard-lock/quota test files + main-account-hard-lock-*.test.ts + account-usability/auth tests; +bun run typecheck; bun run structure:check; bun run test:changed; lint for gui i18n via typecheck. + + +## Audit fold (DeepSeek, NEAR-PASS) +- Folded: the governing window list is never empty — with no 5h/weekly the monthly slot is classified + even if absent, so a reading-less record stays unknown. +- Folded: the weekly reset-only guard excludes monthlyIsPrimaryWindow observations so monthly-primary + replacement still releases a weekly block. +- Folded: main-quota-window-observation weekly99 cases (incl. metadata-only, and final ready at 323/375) + flip to blocked because weekly99 is retained; test expectations follow the rule, not the old pins. +- Rebutted/residual: weekly evidence has no freshness clock. Unchanged from today's weekly-only accounts; + the per-minute blocked recovery sweep fetches WHAM, which carries the secondary window. 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 54d4130fda2..42d13cae195 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 @@ -88,9 +88,10 @@ ocx login anthropic Reserve입니다. 차단 중에는 그 메인 계정의 Reserve를 활성화할 수 없으므로, 메인 계정을 끝까지 쓰고 Reserve로 넘어가려면 스위치를 끄세요. -**5h 창이 있으면 5h 사용률**, 없으면 주간 사용률을 봅니다. 월간 전용 계정은 월간을 -기준으로 합니다. 여러 창에서 가장 높은 값을 고르는 방식은 아닙니다. 새 사용률이 **0%**로 -리셋되면 자동으로 차단을 풀고, 스위치는 켜 둡니다. 이후 다시 98%가 되면 차단합니다. +**5h 창과 주간 창은 각각 따로 차단합니다.** 둘 중 하나만 98%에 닿아도, 다른 창에 여유가 +있어도 바로 차단합니다. 월간 전용 계정은 월간을 기준으로 합니다. 차단한 창이 모두 새 사용률 +98% 미만(0% 리셋 포함)을 보고하면 자동으로 차단을 풀고, 스위치는 켜 둡니다. 이후 다시 98%가 +되면 차단합니다. 5h 수치를 읽지 못해도 주간 차단은 그대로 유지됩니다. 값이 빠진 응답을 0%로 보지 않으며, 이미 확인한 차단 수치를 누락된 응답만으로 지우지도 않습니다. 예정된 리셋 시간이 지났다는 이유만으로 풀지는 않습니다. 차단 중에는 기존 1분 주기 점검에서 실제 사용량을 다시 확인하며, 조회 실패나 잘못된 수치는 차단을 풀지 않습니다. 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 ec6a5018fce..7acc0fe0955 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -150,10 +150,11 @@ account usable ([#5694](https://github.com/lidge-jun/opencodex/issues/5694)). Th Reserve: while the block is in force, Reserve on that main account cannot activate. To let the main account run to exhaustion and hand over to Reserve, turn the switch off. -The policy uses the **5h window when present**, otherwise the weekly window. Monthly-only -accounts use their monthly window. It does not take the highest percentage across windows. -A fresh **0%** observation automatically releases the block while the switch stays on; the next -98% observation blocks again. Unknown usage does not fabricate a zero, and a missing reading does +The **5h window and the weekly window each block on their own**: either one reaching 98% blocks +immediately, even while the other still has headroom. Monthly-only accounts use their monthly +window. The block releases automatically, with the switch still on, once every blocking window +reports a fresh reading below 98% (a 0% reset counts); the next 98% observation blocks again. +An unreadable 5h reading cannot hide a weekly block. Unknown usage does not fabricate a zero, and a missing reading does not erase an already measured blocking tuple. A predicted reset time alone does not unlock it. While blocked, the existing once-per-minute background cycle checks fresh owned usage; failed or invalid readings retain the block. Other pause, reauthentication, and upstream limits remain independent. diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index cbaee338cee..469faafba85 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1616,7 +1616,7 @@ export const de: Record = { "codexAuth.refreshQuota": "Kontingente aktualisieren", "codexAuth.ultraFastTitle": "Ultra-Fast-Diensttarif", "codexAuth.mainHardLockTitle": "Hauptkonto bei 98 % sperren", - "codexAuth.mainHardLockDesc": "Verwendet das 5-Stunden-Fenster, falls vorhanden, sonst das Wochenfenster (bei rein monatlichen Konten das Monatsfenster). Ein neuer Wert von 0 % hebt die Sperre automatisch auf; der Schutz bleibt aktiv. Standardmäßig aktiviert.", + "codexAuth.mainHardLockDesc": "Sperrt, sobald das 5-Stunden-Fenster oder das Wochenfenster 98 % erreicht (bei rein monatlichen Konten das Monatsfenster). Die Sperre hebt sich automatisch auf, wenn alle sperrenden Fenster unter 98 % liegen; der Schutz bleibt aktiv. Standardmäßig aktiviert.", "codexAuth.mainHardLockConfirmTitle": "98-%-Schutz für das Hauptkonto aktivieren?", "codexAuth.mainHardLockConfirmBody": "Während der Sperre ist auch Luna Reserve für das Hauptkonto nicht verfügbar. Ohne vollständigen Verbrauch des normalen Kontingents wird Reserve möglicherweise nicht aktiviert. Zusätzliche Konten und andere Anbieter bleiben nutzbar. Laufende Anfragen, nicht zugeordnete Schlüsselbund-Zugangsdaten und Anfragen außerhalb dieses Proxys sind nicht geschützt.", "codexAuth.mainHardLockConfirm": "Schutz aktivieren", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index b4f20397e88..17179b65dd3 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2204,7 +2204,7 @@ export const en = { "codexAuth.refreshQuota": "Refresh quotas", "codexAuth.ultraFastTitle": "Ultra Fast service tier", "codexAuth.mainHardLockTitle": "Block main account at 98%", - "codexAuth.mainHardLockDesc": "Uses 5h usage when available, otherwise weekly (monthly for monthly-only accounts). A fresh 0% reading unlocks automatically; protection stays on. On by default.", + "codexAuth.mainHardLockDesc": "Blocks when either the 5h or the weekly window reaches 98% (monthly for monthly-only accounts). Unlocks automatically once every blocking window reads below 98%; protection stays on. On by default.", "codexAuth.mainHardLockConfirmTitle": "Enable the main account 98% lock?", "codexAuth.mainHardLockConfirmBody": "While blocked, the main account cannot use Luna Reserve. Keeping normal usage below exhaustion may prevent Reserve activation. Added accounts and other providers remain available. Running requests, unmatched keyring credentials, and traffic outside this proxy are not protected.", "codexAuth.mainHardLockConfirm": "Enable protection", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 2e16093f9ff..7841b025b20 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2136,7 +2136,7 @@ export const fr: Record = { "codexAuth.refreshQuota": "Actualiser les quotas", "codexAuth.ultraFastTitle": "Niveau de service Ultra Fast", "codexAuth.mainHardLockTitle": "Bloquer le compte principal à 98 %", - "codexAuth.mainHardLockDesc": "Utilise la fenêtre de 5 h si elle existe, sinon la semaine (le mois pour les comptes mensuels uniquement). Une nouvelle mesure à 0 % lève le blocage automatiquement ; la protection reste active. Activé par défaut.", + "codexAuth.mainHardLockDesc": "Bloque dès que la fenêtre de 5 h ou la fenêtre hebdomadaire atteint 98 % (le mois pour les comptes mensuels uniquement). Le blocage se lève automatiquement quand toutes les fenêtres bloquantes repassent sous 98 % ; la protection reste active. Activé par défaut.", "codexAuth.mainHardLockConfirmTitle": "Activer la protection à 98 % du compte principal ?", "codexAuth.mainHardLockConfirmBody": "Pendant le blocage, Luna Reserve est également indisponible sur le compte principal. Ne pas épuiser le quota normal peut empêcher l’activation de Reserve. Les comptes ajoutés et les autres fournisseurs restent utilisables. Les requêtes en cours, les identifiants du trousseau non reconnus et le trafic hors de ce proxy ne sont pas protégés.", "codexAuth.mainHardLockConfirm": "Activer la protection", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 9c254e8fdc1..919f8d866fb 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2059,7 +2059,7 @@ export const ja: Record = { "codexAuth.refreshQuota": "クォータを更新", "codexAuth.ultraFastTitle": "Ultra Fast サービスティア", "codexAuth.mainHardLockTitle": "メインアカウントを98%で停止", - "codexAuth.mainHardLockDesc": "5時間枠があればその使用率、なければ週間使用率を使います(月間のみのアカウントは月間)。0%にリセットされると自動解除し、設定は有効のままです。既定でオンです。", + "codexAuth.mainHardLockDesc": "5時間枠と週間枠のどちらかが98%に達すると停止します(月間のみのアカウントは月間)。停止の原因になった枠がすべて98%未満になると自動解除し、設定は有効のままです。既定でオンです。", "codexAuth.mainHardLockConfirmTitle": "メインアカウントの98%保護を有効にしますか?", "codexAuth.mainHardLockConfirmBody": "停止中はメインアカウントのLuna Reserveも使えません。通常枠を使い切らない場合、Reserveが有効にならないことがあります。追加アカウントや他のプロバイダーは引き続き使えます。実行中のリクエスト、照合できないキーチェーン認証情報、このプロキシ外の通信は対象外です。", "codexAuth.mainHardLockConfirm": "保護を有効にする", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index eb19feae0aa..ef743d783e2 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1652,7 +1652,7 @@ export const ko: Record = { "codexAuth.refreshQuota": "할당량 새로고침", "codexAuth.ultraFastTitle": "Ultra Fast 서비스 티어", "codexAuth.mainHardLockTitle": "메인 계정 98% 차단", - "codexAuth.mainHardLockDesc": "5h 창이 있으면 5h, 없으면 주간 사용률을 기준으로 합니다. 월간 전용 계정은 월간을 봅니다. 0%로 리셋되면 자동으로 풀리고 설정은 유지됩니다. 기본값으로 켜져 있습니다.", + "codexAuth.mainHardLockDesc": "5h 창이나 주간 창 중 하나라도 98%에 닿으면 차단합니다. 월간 전용 계정은 월간을 봅니다. 차단한 창이 모두 98% 아래로 내려오면 자동으로 풀리고 설정은 유지됩니다. 기본값으로 켜져 있습니다.", "codexAuth.mainHardLockConfirmTitle": "메인 계정 98% 차단을 켤까요?", "codexAuth.mainHardLockConfirmBody": "차단 중에는 메인 계정의 Luna Reserve도 사용할 수 없습니다. 일반 사용량이 소진되지 않으면 Reserve가 활성화되지 않을 수 있습니다. 추가 계정과 다른 공급자는 계속 사용할 수 있습니다. 진행 중 요청, 식별되지 않은 키링 계정, 프록시 밖 요청에는 적용되지 않습니다.", "codexAuth.mainHardLockConfirm": "확인하고 켜기", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index f7d46a78b3b..03172b334f6 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2131,7 +2131,7 @@ export const ru: Record = { "codexAuth.refreshQuota": "Обновить квоты", "codexAuth.ultraFastTitle": "Уровень обслуживания Ultra Fast", "codexAuth.mainHardLockTitle": "Блокировать основной аккаунт при 98%", - "codexAuth.mainHardLockDesc": "Используется окно 5 ч, если оно есть, иначе недельное (месячное для аккаунтов только с месячным лимитом). Новое значение 0% автоматически снимает блокировку; защита остаётся включённой. Включено по умолчанию.", + "codexAuth.mainHardLockDesc": "Блокирует, когда окно 5 ч или недельное окно достигает 98% (месячное для аккаунтов только с месячным лимитом). Блокировка снимается автоматически, когда все блокирующие окна опускаются ниже 98%; защита остаётся включённой. Включено по умолчанию.", "codexAuth.mainHardLockConfirmTitle": "Включить защиту основного аккаунта при 98%?", "codexAuth.mainHardLockConfirmBody": "Во время блокировки Luna Reserve основного аккаунта тоже недоступна. Если обычная квота не исчерпана, Reserve может не активироваться. Дополнительные аккаунты и другие провайдеры остаются доступны. Текущие запросы, несопоставленные данные связки ключей и запросы вне этого прокси не защищены.", "codexAuth.mainHardLockConfirm": "Включить защиту", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index df709bf029e..6378fe98fcb 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2161,7 +2161,7 @@ export const tr: Record = { "codexAuth.refreshQuota": "Kotaları yenile", "codexAuth.ultraFastTitle": "Ultra Fast hizmet katmanı", "codexAuth.mainHardLockTitle": "Ana hesabı %98’de durdur", - "codexAuth.mainHardLockDesc": "Varsa 5 saatlik, yoksa haftalık kullanım esas alınır (yalnızca aylık hesaplarda aylık kullanım). Yeni %0 ölçümü engeli otomatik kaldırır; koruma açık kalır. Varsayılan olarak açık.", + "codexAuth.mainHardLockDesc": "5 saatlik veya haftalık pencereden biri %98'e ulaştığında engeller (yalnızca aylık hesaplarda aylık kullanım). Engelleyen tüm pencereler %98'in altına indiğinde engel otomatik kalkar; koruma açık kalır. Varsayılan olarak açık.", "codexAuth.mainHardLockConfirmTitle": "Ana hesap için %98 koruması açılsın mı?", "codexAuth.mainHardLockConfirmBody": "Engel sürerken ana hesabın Luna Reserve erişimi de kullanılamaz. Normal kotanın tükenmemesi Reserve’in etkinleşmesini önleyebilir. Ek hesaplar ve diğer sağlayıcılar kullanılmaya devam eder. Çalışan istekler, eşleştirilemeyen anahtarlık kimlik bilgileri ve bu proxy dışındaki trafik korunmaz.", "codexAuth.mainHardLockConfirm": "Korumayı aç", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 659cd6e2b4c..ca8329a179a 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2159,7 +2159,7 @@ export const vi: Record = { "codexAuth.refreshQuota": "Làm mới quota", "codexAuth.ultraFastTitle": "Dịch vụ cấp độ Ultra Fast", "codexAuth.mainHardLockTitle": "Khóa tài khoản chính ở mức 98%", - "codexAuth.mainHardLockDesc": "Sử dụng mức 5h nếu có, nếu không thì dùng hàng tuần (hoặc hàng tháng đối với tài khoản chỉ có hàng tháng). Một kết quả đọc 0% mới sẽ tự động mở khóa; bảo vệ vẫn tiếp tục bật. Mặc định bật.", + "codexAuth.mainHardLockDesc": "Chặn khi mức 5h hoặc mức hàng tuần chạm 98% (hàng tháng đối với tài khoản chỉ có hàng tháng). Tự động mở khóa khi mọi mức đang chặn đều xuống dưới 98%; bảo vệ vẫn tiếp tục bật. Mặc định bật.", "codexAuth.mainHardLockConfirmTitle": "Bật khóa 98% tài khoản chính?", "codexAuth.mainHardLockConfirmBody": "Khi bị khóa, tài khoản chính không thể sử dụng Luna Reserve. Giữ lượng dùng dưới mức cạn kiệt có thể ngăn việc kích hoạt Reserve. Các tài khoản được thêm vào và provider khác vẫn khả dụng. Các request đang chạy, credential chưa khớp, và lưu lượng ngoài proxy này không được bảo vệ.", "codexAuth.mainHardLockConfirm": "Bật bảo vệ", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index a9f11a71e42..c77089ba6b2 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1590,7 +1590,7 @@ export const zhTW: Record = { "codexAuth.refreshQuota": "重新整理額度", "codexAuth.ultraFastTitle": "Ultra Fast 服務層級", "codexAuth.mainHardLockTitle": "主帳戶用量達 98% 時阻擋請求", - "codexAuth.mainHardLockDesc": "有 5 小時額度時以該額度為準,否則使用週額度(僅有月額度的帳戶使用月額度)。新用量重設為 0% 後會自動解除阻擋,保護設定仍保持開啟。預設開啟。", + "codexAuth.mainHardLockDesc": "5 小時額度或週額度任一達到 98% 即阻擋(僅有月額度的帳戶使用月額度)。所有觸發阻擋的額度都降到 98% 以下後自動解除,保護設定仍保持開啟。預設開啟。", "codexAuth.mainHardLockConfirmTitle": "開啟主帳戶 98% 保護?", "codexAuth.mainHardLockConfirmBody": "阻擋期間,主帳戶也無法使用 Luna Reserve。一般額度未用盡時,Reserve 可能不會啟用。新增帳戶與其他供應商仍可使用。進行中的請求、無法比對的鑰匙圈憑證,以及此代理之外的流量不受此保護。", "codexAuth.mainHardLockConfirm": "開啟保護", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 4c9579b2de3..a191df139d2 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1633,7 +1633,7 @@ export const zh: Record = { "codexAuth.refreshQuota": "刷新额度", "codexAuth.ultraFastTitle": "Ultra Fast 服务层级", "codexAuth.mainHardLockTitle": "主账户用量达 98% 时阻止请求", - "codexAuth.mainHardLockDesc": "有 5 小时额度时以该额度为准,否则使用周额度(仅有月额度的账户使用月额度)。新用量重置为 0% 后会自动解除阻止,保护设置仍保持开启。默认开启。", + "codexAuth.mainHardLockDesc": "5 小时额度或周额度任一达到 98% 即阻止(仅有月额度的账户使用月额度)。所有触发阻止的额度都降到 98% 以下后自动解除,保护设置仍保持开启。默认开启。", "codexAuth.mainHardLockConfirmTitle": "开启主账户 98% 保护?", "codexAuth.mainHardLockConfirmBody": "阻止期间,主账户也无法使用 Luna Reserve。普通额度未耗尽时,Reserve 可能不会激活。附加账户和其他提供商仍可使用。正在进行的请求、无法匹配的钥匙串凭据以及此代理之外的流量不受此保护。", "codexAuth.mainHardLockConfirm": "开启保护", diff --git a/src/codex/main-account-hard-lock.ts b/src/codex/main-account-hard-lock.ts index ced8a2c0529..700cffb2111 100644 --- a/src/codex/main-account-hard-lock.ts +++ b/src/codex/main-account-hard-lock.ts @@ -31,6 +31,30 @@ function resetTimestamp(value: number | undefined): number | undefined { return value < 10_000_000_000 ? value * 1000 : value; } +type WindowReading = { percent: number | undefined; resetAt: number | undefined }; + +/** + * The windows that govern the lock. The 5h and weekly windows each govern on their own, so either + * one at 98% blocks even while the other has headroom. Monthly governs only a monthly-only account: + * supplementary monthly data on a 5h/weekly account never becomes a lock. The list is never empty, + * so a record with no readings at all classifies as unknown rather than vacuously ready. + */ +function governingWindows(quota: NonNullable>): WindowReading[] { + const hasShort = quota.shortPercent !== undefined || quota.shortResetAt !== undefined + || quota.shortWindowSeconds !== undefined; + const hasWeekly = quota.weeklyPercent !== undefined || quota.weeklyResetAt !== undefined; + const windows: WindowReading[] = []; + if (hasShort) windows.push({ percent: quota.shortPercent, resetAt: quota.shortResetAt }); + if (hasWeekly) windows.push({ percent: quota.weeklyPercent, resetAt: quota.weeklyResetAt }); + if (windows.length === 0) windows.push({ percent: quota.monthlyPercent, resetAt: quota.monthlyResetAt }); + return windows; +} + +function validPercent(percent: number | undefined): percent is number { + // The routing score's unknown sentinel is 101. It is never a raw quota observation. + return typeof percent === "number" && Number.isFinite(percent) && percent >= 0 && percent <= 100; +} + /** Observed admission policy, not a reservation of the account's remaining quota. */ export function getMainAccountHardLockStatus( config: PolicyConfig, @@ -39,26 +63,19 @@ export function getMainAccountHardLockStatus( if (!isMainAccountHardLockEnabled(config)) return { enabled: false, state: "off" }; const quota = getMainPolicyQuota(); if (!quota) return { enabled: true, state: "unknown" }; - // Account window priority is deliberate: a 5h account uses that window, even if - // its weekly bar is higher. An unknown/expired selected window does not change the choice. - const hasShort = quota.shortPercent !== undefined || quota.shortResetAt !== undefined - || quota.shortWindowSeconds !== undefined; - const hasWeekly = quota.weeklyPercent !== undefined || quota.weeklyResetAt !== undefined; - const [percent, rawReset] = hasShort - ? [quota.shortPercent, quota.shortResetAt] - : hasWeekly ? [quota.weeklyPercent, quota.weeklyResetAt] : [quota.monthlyPercent, quota.monthlyResetAt]; - const resetAt = resetTimestamp(rawReset); - // The routing score's unknown sentinel is 101. It is never a raw quota observation. - if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) { - return { enabled: true, state: "unknown" }; + const windows = governingWindows(quota); + const blocking = windows.filter(w => validPercent(w.percent) && w.percent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT); + if (blocking.length > 0) { + // The lock holds until every blocking window reads lower, so the earliest possible unlock is + // the latest blocking reset. One blocking window without a future reset makes it unknowable. + // A predicted reset is not evidence of recovery either way: only a fresh lower reading releases. + const resets = blocking.map(w => resetTimestamp(w.resetAt)); + const resetAt = resets.every(r => r !== undefined && r > now) ? Math.max(...(resets as number[])) : undefined; + return { enabled: true, state: "blocked", ...(resetAt !== undefined ? { resetAt } : {}) }; } - if (percent < MAIN_ACCOUNT_HARD_LOCK_PERCENT) return { enabled: true, state: "ready" }; - return { - enabled: true, - state: "blocked", - // A predicted reset is not evidence of recovery. Only a fresh lower reading releases. - ...(resetAt !== undefined && resetAt > now ? { resetAt } : {}), - }; + // Unknown admits, so an unreadable window never hides a blocking one and never blocks alone. + if (windows.some(w => !validPercent(w.percent))) return { enabled: true, state: "unknown" }; + return { enabled: true, state: "ready" }; } export function isMainAccountHardLocked(config: PolicyConfig, now = Date.now()): boolean { diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 7d43a9728a3..6f27fd32a2b 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -354,7 +354,17 @@ function mergeAccountQuota( return stampCodexQuotaUsageObservation(next, quota, existing); } - if (snapshotHasWeekly(quota)) { + const existingWeeklyPercent = existing?.weeklyPercent; + // A reset-only weekly observation is not a lower reading. Policy mode keeps a blocking weekly + // tuple whole, exactly like preserveKnownShort; a governing monthly-primary observation still + // replaces it, so that release path does not depend on this guard. + const preserveKnownWeekly = policyEvidence + && quota.weeklyPercent === undefined + && quota.monthlyIsPrimaryWindow !== true + && finitePercent(existingWeeklyPercent) + && existingWeeklyPercent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT + && existingWeeklyPercent <= 100; + if (snapshotHasWeekly(quota) && !preserveKnownWeekly) { if (quota.weeklyPercent !== undefined) next.weeklyPercent = quota.weeklyPercent; if (quota.weeklyResetAt !== undefined) next.weeklyResetAt = quota.weeklyResetAt; } else if (snapshotHasMonthly(quota) diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 47b40376d25..733cd1aec8c 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -289,8 +289,12 @@ Regression coverage lives in `tests/codex-integration/codex-quota-parser-parity. `tests/usage/quota-reset-observation.test.ts`, and `tests/usage/quota-reset-seen-store.test.ts`. `codexMainAccountHardLock` is a local admission policy that is **on by default** since #5694, at -`MAIN_ACCOUNT_HARD_LOCK_PERCENT` = 98% of the 5h/short window when present, otherwise the weekly -window (monthly for monthly-only accounts). It does not take the maximum across those windows. +`MAIN_ACCOUNT_HARD_LOCK_PERCENT` = 98%. The 5h/short window and the weekly window each govern on +their own: either one at 98% blocks, and an unknown or invalid reading in one never hides a block +in the other (unknown still admits). Monthly governs only a monthly-only account. A block holds +until every blocking window reads lower, so its reported `resetAt` is the latest blocking reset, +omitted when any blocking window has none. In the policy snapshot a reset-only weekly observation +keeps a blocking weekly tuple, mirroring the short-window rule; monthly-primary evidence still replaces it. It blocks newly admitted identity-matched main-account requests. Pool alternatives remain eligible; explicit main selection and stored Direct substitution do not override it. It neither pauses the account nor clears upstream cooldown/reauth state, and management quota refresh remains available. diff --git a/tests/codex-integration/main-account-hard-lock-policy.test.ts b/tests/codex-integration/main-account-hard-lock-policy.test.ts index a178c1b768b..142d8be30d9 100644 --- a/tests/codex-integration/main-account-hard-lock-policy.test.ts +++ b/tests/codex-integration/main-account-hard-lock-policy.test.ts @@ -67,6 +67,9 @@ describe("identity-bound main-account hard-lock policy", () => { expect(getMainAccountHardLockStatus(enabled, now + 60_000).state).toBe("blocked"); expect(getMainAccountHardLockStatus(enabled, now + 120_000)).toEqual({ enabled: true, state: "blocked" }); observe({ shortPercent: 0 }); + // Weekly99 still blocks on its own after the 5h window reads 0. + expect(getMainAccountHardLockStatus(enabled, now + 120_000).state).toBe("blocked"); + observe({ weeklyPercent: 0 }); expect(getMainAccountHardLockStatus(enabled, now + 120_000)).toEqual({ enabled: true, state: "ready" }); }); @@ -120,25 +123,51 @@ describe("identity-bound main-account hard-lock policy", () => { expect(getMainAccountHardLockStatus(enabled, now).state).toBe("blocked"); }); - test("5h usage wins over a higher weekly window", () => { - observe({ shortPercent: 97, shortWindowSeconds: 18_000, weeklyPercent: 100 }); - expect(getMainAccountHardLockStatus(enabled, now).state).toBe("ready"); - observe({ shortPercent: 99, shortWindowSeconds: 18_000, weeklyPercent: 20 }); - expect(isMainAccountHardLocked(enabled, now)).toBe(true); + test.each([ + { shortPercent: 98, weeklyPercent: 50, state: "blocked" }, + { shortPercent: 99, weeklyPercent: 20, state: "blocked" }, + { shortPercent: 97, weeklyPercent: 98, state: "blocked" }, + { shortPercent: 20, weeklyPercent: 100, state: "blocked" }, + { shortPercent: 97, weeklyPercent: 97.99, state: "ready" }, + ])("5h $shortPercent / weekly $weeklyPercent is $state: either window at 98 blocks alone", ({ state, ...usage }) => { + observe({ ...usage, shortWindowSeconds: 18_000 }); + expect(getMainAccountHardLockStatus(enabled, now).state).toBe(state); }); - test("an expired 5h window does not fall back to the high weekly bar", () => { + test("the lock holds until every blocking window reads lower", () => { observe({ shortPercent: 99, shortWindowSeconds: 18_000, shortResetAt: now / 1000, weeklyPercent: 100 }); expect(getMainAccountHardLockStatus(enabled, now)).toEqual({ enabled: true, state: "blocked" }); observe({ shortPercent: 0 }); + expect(getMainAccountHardLockStatus(enabled, now).state).toBe("blocked"); + observe({ weeklyPercent: 97 }); expect(getMainAccountHardLockStatus(enabled, now).state).toBe("ready"); + observe({ weeklyPercent: 98 }); + expect(getMainAccountHardLockStatus(enabled, now).state).toBe("blocked"); }); - test("a known 5h shape with no percentage stays unknown instead of selecting weekly", () => { + test("a blocked status reports the latest reset among the blocking windows only", () => { + observe({ shortPercent: 99, shortResetAt: now + 60_000, weeklyPercent: 98, weeklyResetAt: now + 600_000 }); + expect(getMainAccountHardLockStatus(enabled, now)).toEqual({ enabled: true, state: "blocked", resetAt: now + 600_000 }); + observe({ shortPercent: 99, shortResetAt: now + 60_000, weeklyPercent: 40, weeklyResetAt: now + 600_000 }); + expect(getMainAccountHardLockStatus(enabled, now)).toEqual({ enabled: true, state: "blocked", resetAt: now + 60_000 }); + }); + + test("an unknown 5h reading neither hides a weekly block nor blocks alone", () => { observe({ shortWindowSeconds: 18_000, weeklyPercent: 100 }); + expect(getMainAccountHardLockStatus(enabled, now).state).toBe("blocked"); + observe({ weeklyPercent: 20 }); expect(getMainAccountHardLockStatus(enabled, now).state).toBe("unknown"); }); + test("a reset-only weekly observation cannot release a retained weekly block", () => { + observe({ shortPercent: 10, shortWindowSeconds: 18_000, weeklyPercent: 99, weeklyResetAt: now + 60_000 }); + observe({ weeklyResetAt: now + 120_000 }); + expect(getMainPolicyQuota()).toMatchObject({ weeklyPercent: 99, weeklyResetAt: now + 60_000 }); + expect(isMainAccountHardLocked(enabled, now)).toBe(true); + observe({ monthlyPercent: 5, monthlyIsPrimaryWindow: true }); + expect(getMainPolicyQuota()?.weeklyPercent).toBeUndefined(); + }); + test("weekly-only accounts do not use a higher monthly bar", () => { observe({ weeklyPercent: 97, monthlyPercent: 100 }); expect(getMainAccountHardLockStatus(enabled, now).state).toBe("ready"); diff --git a/tests/codex-integration/main-quota-evidence-validation.test.ts b/tests/codex-integration/main-quota-evidence-validation.test.ts index 6b523b6aab1..fb87c15c3b9 100644 --- a/tests/codex-integration/main-quota-evidence-validation.test.ts +++ b/tests/codex-integration/main-quota-evidence-validation.test.ts @@ -352,10 +352,10 @@ describe("cold persisted policy percentage ranges", () => { }); } - test("valid short zero keeps priority over weekly99 after hydration", () => { + test("valid short zero cannot release a weekly99 block after hydration", () => { const disk = writeColdPolicy({ weeklyPercent: 99, shortPercent: 0 }); expect(getMainPolicyQuota()).toEqual(disk); - expect(getMainAccountHardLockStatus(cfg).state).toBe("ready"); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); }); test("rejected short usage retains independently valid unknown-window metadata", () => { @@ -363,7 +363,8 @@ describe("cold persisted policy percentage ranges", () => { shortWindowSeconds: 18_000, shortResetAt: 2_000_000_000, shortObservedAt: 1_700_000_000_000, resetCredits: 150 }); expect(getMainPolicyQuota()).toEqual({ updatedAt: disk.updatedAt, weeklyPercent: 99, shortWindowSeconds: 18_000, shortResetAt: 2_000_000_000, shortObservedAt: 1_700_000_000_000, resetCredits: 150 }); - expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "unknown" }); + // The rejected 5h reading is unknown, but it cannot mask the valid weekly99 block. + expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "blocked" }); }); test.each([0, 150, 2_000_000_000])("metadata and credits retain nonnegative %s independently of usage ranges", value => { diff --git a/tests/codex-integration/main-quota-window-observation.test.ts b/tests/codex-integration/main-quota-window-observation.test.ts index 2383eff0721..5b5226a08ce 100644 --- a/tests/codex-integration/main-quota-window-observation.test.ts +++ b/tests/codex-integration/main-quota-window-observation.test.ts @@ -255,7 +255,7 @@ describe("declared short-window producer evidence", () => { { name: "metadata-only short window", usage: undefined, weekly: false }, ]; for (const sample of cases) { - test(`owned WHAM fetch preserves ${sample.name} as unknown short-window policy`, async () => { + test(`owned WHAM fetch preserves ${sample.name} as an unknown short window while weekly99 blocks`, async () => { resetLifecycleDrainStateForTests(); const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "" }; setIcaclsRunnerForTests(() => aclOk); @@ -300,7 +300,8 @@ describe("declared short-window producer evidence", () => { expect(getMainPolicyQuota()).not.toHaveProperty("shortPercent"); expect(getMainPolicyQuota()).not.toHaveProperty("shortObservedAt"); expect(matchesMainQuotaCredential(accessToken, "fixture-main-a")).toBe(true); - expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true })).toEqual({ enabled: true, state: "unknown" }); + // The unknown 5h reading cannot hide the retained weekly99: either window blocks alone. + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true })).toEqual({ enabled: true, state: "blocked" }); // Unknown metadata replaces the legacy tuple but must retain trusted policy short99. const firstShortObservedAt = observationTime; await fetchMainAccountInfo(true); @@ -320,7 +321,9 @@ describe("declared short-window producer evidence", () => { expect(calls).toBe(5); expect(getMainPolicyQuota()).toMatchObject({ shortPercent: 0, shortResetAt: 4_000_000_000, shortObservedAt: observationTime }); expect(getAccountQuota(MAIN)?.shortObservedAt).toBe(observationTime); - expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000 - 1).state).toBe("ready"); + // A fresh 5h 0% releases only the 5h window; the retained weekly99 still blocks. + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000 - 1).state).toBe("blocked"); } finally { fetchSpy.mockRestore(); resetLifecycleDrainStateForTests(); @@ -334,7 +337,7 @@ describe("declared short-window producer evidence", () => { } }); - test(`headers preserve ${sample.name} instead of falling back to weekly99`, () => { + test(`headers preserve ${sample.name} as an unknown short window while weekly99 blocks`, () => { const writer = writerFor(); setAccountQuotaFromParsed(MAIN, { weeklyPercent: 99 }, undefined, writer); expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true }).state).toBe("blocked"); @@ -349,7 +352,7 @@ describe("declared short-window producer evidence", () => { }); expect(getMainPolicyQuota()).not.toHaveProperty("shortPercent"); expect(getMainPolicyQuota()).not.toHaveProperty("shortObservedAt"); - expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true })).toEqual({ enabled: true, state: "unknown" }); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true })).toEqual({ enabled: true, state: "blocked" }); const firstShortObservedAt = observationTime; applyAccountQuotaFromUpstreamHeaders(MAIN, new Headers({ "x-codex-primary-used-percent": "99", "x-codex-primary-window-minutes": "300", @@ -372,7 +375,8 @@ describe("declared short-window producer evidence", () => { expect(getMainPolicyQuota()).toMatchObject({ shortPercent: 0, shortWindowSeconds: 3_600, shortResetAt: 4_000_000_000, shortObservedAt: observationTime }); expect(getAccountQuota(MAIN)?.shortObservedAt).toBe(observationTime); - expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000 - 1).state).toBe("ready"); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000 - 1).state).toBe("blocked"); }); } });