diff --git a/devlog/_plan/261001_quota_send_lock_split/000_plan.md b/devlog/_plan/261001_quota_send_lock_split/000_plan.md new file mode 100644 index 00000000000..4ecc1dcdaee --- /dev/null +++ b/devlog/_plan/261001_quota_send_lock_split/000_plan.md @@ -0,0 +1,94 @@ +# Quota send-lock contributor split: roadmap + +Status: roadmap locked by the docs-only cycle; implementation runs one PABCD cycle per PR. +Date: 2026-10-01. Baseline: `origin/dev` `64294638a69e25ca0c7a4e2102e2349973161f71`. + +## Problem + +The Codex desktop composer disables Send when the signed-in ChatGPT quota reports +exhausted, even for models opencodex routes to independently funded providers +([#6196](https://github.com/lidge-jun/opencodex/issues/6196), duplicate +[#4878](https://github.com/lidge-jun/opencodex/issues/4878)). Two contributor PRs +address it: + +- [#5947](https://github.com/lidge-jun/opencodex/pull/5947) (lcxhh521, head + `61947c04d9`, +7317 / 54 files) mixes three mechanisms: (a) a local-CA TLS + intercept of chatgpt.com traffic (send-unblock), (b) a PAC fallback entry so the + intercept survives opencodex stopping, (c) an app-server shim: a `CODEX_CLI_PATH` + launcher that execs the bundled `codex app-server` and filters only its stdout, + clearing the plain-quota gate in `account/rateLimits/read` and + `account/rateLimits/updated`. +- [#5879](https://github.com/lidge-jun/opencodex/pull/5879) (MateuszJuszczyk, head + `9a5b6d9d72`) toggles `codexDesktopAuthless` automatically on quota exhaustion. + +## Evidence + +- #6196 (TooSpace, 2026-09-27..29): with (a)+(b) deployed, the loopback listener saw + **zero established connections over ~20 h**; the bundled app-server owns the + chatgpt.com sockets, so the intercept never sees the gate reads. +- #6196 (TooSpace, 2026-10-01 10:47 Asia/Taipei): Plus account, 5-hour window 100%, + weekly 51%, Desktop 26.928.21956 / bundled CLI 0.159.2: launching through the shim + launcher re-enabled Send before the window reset; `dynamic_app_tools_peer_rejected` + did not recur. Ingwannu's note: the run had intercept + shim + restart together, + so it does not isolate the shim as sole cause. +- `devlog/_plan/260928_macos_quota_gate/000_design.md` (maintainer design) prefers + upstream provider-aware admission and rejects CA installation and quota rewriting. + The split PRs below are explicitly experimental/opt-in candidates the maintainers + may close against that decision. + +## Existing defence and its gaps + +`codexMainAccountHardLock` (default on since #5694, `src/codex/main-account-hard-lock.ts`) +stops opencodex from sending main-account traffic at 98% on any governing window. +Gaps: usage outside opencodex, observation lag (a long turn crosses 98%), the small +Plus 5-hour window, and `unknown` state admitting. + +## PR map (dependency order) + +| Doc | Work-phase | Branch | Base | Credit | +| --- | --- | --- | --- | --- | +| 010 | wp1 hard-lock hardening | `codex/main-hard-lock-window-thresholds` | `dev` | none (maintainer code) | +| 020 | wp2 app-server shim | `codex/chatgpt-app-server-shim` | `dev` | Co-authored-by: lcxhh521 | +| 030 | wp3 local-CA send-unblock intercept | `codex/chatgpt-send-unblock-intercept` | wp2 branch (stacked) | Co-authored-by: lcxhh521 | +| 040 | wp4 PAC fallback | `codex/chatgpt-pac-fallback` | wp3 branch (stacked) | Co-authored-by: lcxhh521 | +| 050 | wp5 #5879 disposition | none (rationale only) | - | - | +| 060 | wp6 closeout | - | - | - | + +Stacking reason: wp2, wp3 and wp4 all extend the same new `ocx chatgpt` command and +`chatgptDesktop` config block. Opening them independently against `dev` would +make the second one to land conflict on `src/cli/chatgpt-command.ts`, the config +type/validator, the CLI registry, the guide and `structure/clients/chatgpt-desktop.md`. +The PAC fallback has no function without the TLS listener (its CONNECT entry only +splices into that listener), so wp4 cannot target `dev` alone. The shim has no +dependency on either and is the piece with field evidence, so it sits at the bottom. +Stack lifecycle: + +- Parent merged (squash): rebase the child with + `git rebase --onto origin/dev `, retarget it to `dev`, and + wait for fresh CI on the new head. Cascade the same rebase to every descendant + (wp4 onto the new wp3 head) and wait for fresh CI on each. Land bottom-up only. +- Parent closed: its children carry its commits and dependencies, so they close with + it unless a maintainer asks for a reconstruction on a different base. The children + are themselves "may close" candidates, so this is the expected path if the shim is + rejected. + +wp1 is independent of all three. + +## Common constraints + +- New branches from latest `origin/dev`; `codex/` prefix; no git config, no GIT_* env. +- Respect `tests/fixtures/file-size-baseline.json`; unlisted files stay below 2000 lines. +- New test files: register in `scripts/test-layout/layout.json` `explicit` and + `tests/fixtures/test-layout-expected.json`. +- `src/router.ts`, `src/server/lifecycle.ts`, `src/server/responses/core.ts` must not + reach new optional modules (core-lab boundary). +- GUI strings in all ten locale catalogs; GUI PRs need a screenshot on `pr-assets`. +- Verification per PR: `bun run typecheck`, focused tests, `bun run test:changed`, + `bun run structure:check`, `bun run privacy:scan`, `bun run skill:surface:check` when + the CLI changes, the core-lab boundary test, and the full `bun run test` (or the + documented resource exception recorded in the PR), then required CI green on the + exact pushed head. +- wp2-wp4 touch process exec and credential interception: PR descriptions request + explicit security review per MAINTAINERS.md. +- Merge/close decisions belong to maintainers; this unit opens PRs only. + diff --git a/devlog/_plan/261001_quota_send_lock_split/010_hard_lock_hardening.md b/devlog/_plan/261001_quota_send_lock_split/010_hard_lock_hardening.md new file mode 100644 index 00000000000..0136468734c --- /dev/null +++ b/devlog/_plan/261001_quota_send_lock_split/010_hard_lock_hardening.md @@ -0,0 +1,141 @@ +# 010 — wp1: window-aware hard-lock thresholds and outside-usage warning + +Branch `codex/main-hard-lock-window-thresholds` from `origin/dev`. Maintainer code. + +## Behaviour + +1. The 5-hour (short) window locks at a lower percentage than the weekly/monthly + (long) windows. Defaults: short **90**, long **98** (unchanged). Both configurable. +2. When the main account's usage rises while opencodex has not used the main + account, the dashboard and `ocx status` show a warning: usage is being consumed + outside opencodex, so the lock may not prevent exhaustion. + +## Config (NEW key) + +`src/types/config.ts` next to `codexMainAccountHardLock`: + +```ts +/** Per-window lock thresholds in percent (#6196). short: 5h window, default 90; long: weekly/monthly, default 98. */ +codexMainAccountHardLockThresholds?: { short?: number; long?: number }; +``` + +`src/config/schema/config-schema.ts`: object of two optional integers in +`[MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT, 100]`, `.catch(undefined)` per field so a +malformed disk value degrades to defaults (same pattern as the boolean at :188). + +`PUT /api/settings` (`src/server/management/config-routes.ts`): accept the object, +reject non-integer/out-of-range values and `short > long` with 400 before any write, +include it in the rollback snapshot; GET returns the effective thresholds in the +existing hard-lock projection. + +## Constants (MODIFY `src/codex/quota-types.ts`) + +```ts +export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 98; // long default, unchanged export +export const MAIN_ACCOUNT_HARD_LOCK_SHORT_PERCENT = 90; // NEW short default +export const MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT = 80; // NEW lowest configurable value +``` + +## Lock (MODIFY `src/codex/main-account-hard-lock.ts`) + +- NEW `resolveMainAccountHardLockThresholds(config): { short: number; long: number }` + — defaults above, clamps invalid in-memory values back to defaults, forces + `short <= long`. +- `WindowReading` gains `kind: "short" | "long"`; `governingWindows()` tags the 5h + reading short, weekly and monthly-only long. +- `getMainAccountHardLockStatus()` compares each window with its own threshold and + returns `thresholds` plus `window: "short" | "long"` of the first blocking reading. +- `PolicyConfig` adds `codexMainAccountHardLockThresholds`. + +## Evidence retention (MODIFY `src/codex/quota.ts`) + +The three policy-evidence retention guards (`assignCarriedShort` ~:263, +`preserveKnownWeekly` ~:374, `preserveKnownShort` ~:407) compare against 98. With a +configurable threshold a reading between the threshold and 98 could be dropped by an +elapsed reset or partial update and silently unlock. Change the comparison to +`MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT` so any reading that can block under some +allowed configuration is retained until a fresh lower reading arrives. Retaining a +non-blocking reading only keeps a stale percentage visible; it never blocks. + +## Error copy (MODIFY `src/codex/auth-context.ts` ~:517) + +`CodexMainAccountHardLockError` message stops naming 98: "Codex main account is +blocked by the main-account quota policy (5h ≥ X%, weekly ≥ Y%)." The constructor +takes the thresholds from the status. Update tests that assert the old string. + +## Outside-usage detector (NEW `src/codex/main-account-external-usage.ts`) + +Process-local, no persistence, no timers. It keeps its own baseline of fresh readings +and never reads the merged policy snapshot, which can contain carried windows. + +```ts +export const EXTERNAL_USAGE_QUIET_MS = 30 * 60_000; +export const EXTERNAL_USAGE_TTL_MS = 6 * 60 * 60_000; +type FreshWindow = { kind: "short" | "long"; percent: number; resetAtMs: number }; +export function noteMainAccountActivity(now = Date.now()): void; +/** Only windows with a validated percent AND a known reset in this observation. */ +export function observeMainAccountUsage(identityKey: string, windows: FreshWindow[], now = Date.now()): void; +export function forgetMainAccountUsage(): void; +export function getMainAccountExternalUsageWarning(identityKey: string | undefined, now = Date.now()): + { window: "short" | "long"; fromPercent: number; toPercent: number; observedAt: number } | undefined; +export function resetMainAccountExternalUsageForTests(): void; +``` + +Rule: the module holds `{ identityKey, perWindow: { percent, resetAtMs, observedAt } }`. +A new `identityKey` replaces the baseline and drops any warning. For a fresh window +with the same kind and the same normalized `resetAtMs` (both known; a missing reset +never proves the same episode), a rise of at least 1 point with no main-account +activity noted since `baseline.observedAt - EXTERNAL_USAGE_QUIET_MS` records a warning. +A changed reset rebaselines that window and clears its warning. Warnings expire after +`EXTERNAL_USAGE_TTL_MS` or once the recorded `resetAtMs` passes, whichever is first; the getter returns nothing for a different identity. +Over-counting activity only suppresses warnings. A single turn longer than the quiet +margin can still cause a false positive, so the copy says "possible usage outside +opencodex". + +Hooks: + +- `src/codex/auth-context.ts` `assertMainAccountPolicy()` (:703) runs on every + main-credential attach (main-pool, substituted main, caller bearer matching observed + main) and on some admission checks before refusal. Call `noteMainAccountActivity()` + there before the `!config` early return; the extra calls only suppress warnings. +- `src/codex/quota.ts` `setAccountQuotaFromParsed()` (~:318): when `isMain`, + `mainWriter` is present and `policyQuota` (this call's validated observation) is + non-null and carries usage, build `FreshWindow`s from `policyQuota` only (short: + `shortPercent` + `shortResetAt`; long: `weeklyPercent` + `weeklyResetAt`, or monthly + when `monthlyIsPrimaryWindow`), normalize with `resetAtToMs`, and call + `observeMainAccountUsage(mainWriter.identityKey, windows)`. +- `clearAccountQuota()` (main or all) calls `forgetMainAccountUsage()`. +- Readers pass `getObservedMainQuotaIdentityKey()` to the getter. + +## Surfaces + +- `src/codex/auth-api/account-list.ts` (~:374): the main-account hard-lock DTO gains + `thresholds` and optional `externalUsage`. +- GUI `gui/src/components/MainAccountHardLockSetting.tsx` and + `codex-account-pool-main-card.tsx`: copy uses the effective thresholds; amber notice + when `externalUsage` is present. Locale keys in all ten catalogs; replace fixed-98 + strings (`en.ts:2312-2325`). +- `ocx status`: `src/oauth/health.ts` `fetchCodexHealthFromLiveProxy()` already reads + `/api/codex-auth/accounts`; project the main hard-lock state, thresholds and + external-usage warning, and print one line in `src/cli/status-oauth.ts` (human) and + include it in JSON. Keep `src/cli/index.ts` (1979 lines) untouched. + +## Tests (NEW sibling files; register in both layout files) + +- `tests/codex-integration/main-account-hard-lock-thresholds.test.ts`: short 90 blocks + at 90 with weekly 30; weekly 95 admits by default and blocks with long 95; + configured short/long; invalid config falls back; retention of a 92% short reading + across an elapsed reset. +- `tests/codex-integration/main-account-external-usage.test.ts`: rise without + activity warns; activity inside the quiet margin suppresses; reset change clears; + identity change clears; expiry. +- `tests/config/settings-main-account-hard-lock.test.ts` (MODIFY, 119 lines): PUT + validation and rollback for thresholds. +- GUI test for the warning notice next to the existing hard-lock setting tests. + +## Docs + +`docs-site/src/content/docs/reference/cli/providers-accounts.md` (+ Korean), +`docs-site/src/content/docs/reference/configuration/server.md` key row, +`structure/providers/openai-accounts.md`, `structure/config.md`. + diff --git a/devlog/_plan/261001_quota_send_lock_split/020_app_server_shim.md b/devlog/_plan/261001_quota_send_lock_split/020_app_server_shim.md new file mode 100644 index 00000000000..f1e99800dba --- /dev/null +++ b/devlog/_plan/261001_quota_send_lock_split/020_app_server_shim.md @@ -0,0 +1,94 @@ +# 020 — wp2: app-server shim (experimental, macOS, default off) + +Branch `codex/chatgpt-app-server-shim` from `origin/dev`. Code from #5947 with +`Co-authored-by: lcxhh521 ` (use the author's +commit email from `git log pr/5947` if public). + +## Scope + +Only mechanism (c). No listener, CA, PAC, launch watcher, or `unblockSend`. + +## Files + +NEW `src/chatgpt/app-server-shim/`: + +- `gate-rewrite.ts` — pure copy of #5947 `rewrite.ts` `isRecord`, `PLAIN_QUOTA_REACHED_TYPE`, + `hasNonQuotaBlock`, `unlockRateLimitGate` (pr/5947 rewrite.ts:40-47, 157-246). + No conversation/endpoint/SSE code. +- `app-server-rewrite.ts` — #5947 file, import from `./gate-rewrite`. +- `filter.ts` — #5947 `app-server-shim.ts` line filter, without its `import.meta.main` + entry. NEW `runChatgptAppServerFilter({ selfTest })`: with `selfTest` it rewrites a + built-in fixture line, checks the result and exits 0; otherwise it filters stdin to + stdout. If the rewrite machinery throws outside the per-line guard, it switches to + raw byte passthrough for the rest of the stream. +- Entry point: hidden `ocx internal chatgpt-app-server-filter [--self-test]` in + `src/cli/internal-command.ts` (hidden commands stay out of the registry and skill + surface by design). This works for source installs and compiled standalone builds, + because the launcher re-enters the current CLI through `process.execPath` + + `selfLaunchArgv()` (`src/lib/self-launch-argv.ts`) instead of pointing Bun at a `.ts` + file that a compiled build does not ship. +- `launcher.ts` — `buildChatgptShimLauncher(argv, real)`, `writeChatgptShimLauncher()`, + `chatgptShimLauncherPath()` (adapted from #5947 runtime.ts:123-161). Launcher body: + +```bash +#!/bin/bash +# opencodex (experimental): ChatGPT app-server stdout passes through the quota-gate filter. +REAL='' +FILTER=('' [''] internal chatgpt-app-server-filter) +if [ "$(uname -s)" = "Darwin" ] && [ -x "${FILTER[0]}" ] \ + && "${FILTER[@]}" --self-test >/dev/null 2>&1; then + exec "$REAL" "$@" > >(exec "${FILTER[@]}") +fi +exec "$REAL" "$@" +``` + + Guarantee, stated exactly: a failed precondition or a failed self-test runs the + original binary with untouched stdout. A filter that passes the self-test and then + dies mid-session closes the pipe. Expected (not yet validated against the bundled + app-server): the server gets SIGPIPE or a write error and Desktop respawns it through + the same launcher. The filter's passthrough mode limits this to an exit/crash case. + +NEW `src/cli/chatgpt-command.ts` — `ocx chatgpt launch | restore | status` (macOS only; +other platforms print "macOS only" and exit 1). `launch` refuses unless +`chatgptDesktop.appServerShim === true`, writes the launcher, quits the app if +running, and relaunches with `open -a ChatGPT --env CODEX_CLI_PATH=`. +`restore` relaunches without the variable and deletes the launcher. `status` prints +`app-server shim (experimental): on|off`, launcher presence and whether a running +app carries the variable. All help text says "experimental". + +MODIFY `src/cli/dispatch.ts`, `src/cli/help.ts`, `src/cli/registry.ts` +plus `src/cli/capabilities.ts` and the regenerated skill surface (`bun run skill:surface`). + +Config: `src/types/config.ts` `chatgptDesktop?: { appServerShim?: boolean }`; +`leaf-validators.ts` strict object, `config-schema.ts` registration with +`.catch(undefined)`; `src/config/diagnostics.ts` warns when set on non-macOS. + +## Tests + +NEW `tests/chatgpt-unblock/app-server-shim.test.ts`: gate rewrite cases from #5947 +`unblock-app-server-shim.test.ts` (re-pointed to the new modules), self-test exit code, +passthrough after an internal rewrite failure. +NEW `tests/chatgpt-unblock/app-server-shim-launcher.test.ts`: launcher text for source +argv and compiled argv (`selfLaunchArgv` with `isStandaloneExecutable`); executing the +generated script on a stub `REAL` with (a) missing runtime, (b) failing self-test → +stub sees untouched stdout, (c) passing self-test → stub output filtered, (d) passing +self-test then filter exits → stub terminates instead of hanging (mock behaviour only). +NEW `tests/chatgpt-unblock/chatgpt-desktop-config.test.ts`: absent/false/malformed → off, +strict write rejection. CLI registry/capabilities parity via existing tests after +`bun run skill:surface`. Register new files in both layout files. + +## Docs + +`docs-site/src/content/docs/guides/chatgpt-desktop.md` (English, marked experimental, +security notes: generated executable, `CODEX_CLI_PATH`, quota gate fields rewritten +while displayed usage stays honest), sidebar entry in `astro.config.mjs`; +`structure/clients/chatgpt-desktop.md` + `structure/manifest.json` ownership of +`src/chatgpt/`; `structure/INDEX.md` via `bun run structure:index`. + +## PR description must state + +The 260928 maintainer design rejects rewriting quota gate data; this PR is offered +as an opt-in experiment because it is the only path with field evidence on a real +exhausted Plus account (#6196, 2026-10-01), and that run also had the intercept +enabled. + diff --git a/devlog/_plan/261001_quota_send_lock_split/030_send_unblock_intercept.md b/devlog/_plan/261001_quota_send_lock_split/030_send_unblock_intercept.md new file mode 100644 index 00000000000..a614ccc979b --- /dev/null +++ b/devlog/_plan/261001_quota_send_lock_split/030_send_unblock_intercept.md @@ -0,0 +1,41 @@ +# 030 — wp3: local-CA send-unblock intercept (candidate) + +Branch `codex/chatgpt-send-unblock-intercept`, stacked on wp2. Code from #5947, +`Co-authored-by: lcxhh521`. A candidate maintainers may close. + +## Files (from pr/5947 at 61947c04d9) + +Whole files: `src/chatgpt/desktop-unblock/{listener,ca-trust,ws-frame,ws-relay,ws-upstream,launch-watcher,runtime,rewrite}.ts`, +`src/server/index/chatgpt-unblock-lifecycle.ts`, `src/server/index/optional-listeners.ts` hunk, +`src/lib/socks5-handshake.ts` + `src/lib/socks5-fetch.ts` (shared SOCKS5 refactor), +`tests/lab/core-lab-boundary.test.ts` hunk. + +Removed while extracting: + +- PAC: runtime.ts:32,49-50,84-121,166-169,177-181,204-221; launch-watcher PAC branches + (102,114-116,146-165,177-179,190-203,239-242,271-277,472-475); CLI PAC status. +- Shim duplicates: runtime.ts:44-45,123-161,222-231 and watcher shim branches; the + shim lives in wp2. `rewrite.ts` imports the gate helpers from + `src/chatgpt/app-server-shim/gate-rewrite.ts` instead of redefining them. + +Config: `chatgptDesktop.unblockSend`, `chatgptDesktop.port` added to wp2's block. +CLI: `ocx chatgpt launch|restore|status|install-watcher` gain the intercept mode. + +Tests: `rewrite`, `unblock-ca-trust`, `unblock-listener`, `unblock-ws-frame`, +`unblock-ws-relay`, `unblock-runtime` (non-PAC cases), `unblock-launch-script` +(non-PAC, non-shim), `unblock-watcher-install`, `unblock-config-boundary`, +`tests/lib/socks5-handshake.test.ts`. Register in both layout files. + +Docs: guide sections for the intercept in English (+ locales from #5947 where they do +not describe PAC/shim), structure updates, transports inventory SOCKS5 row. + +## PR description must state + +- Security cost: issues a local CA and asks the user to run + `security add-trusted-cert` into the login keychain trust store; the loopback + listener terminates TLS for chatgpt.com and relays the account's credentials. +- #6196 evidence: on current Desktop builds the gate reads come from the bundled + app-server; the listener saw zero established connections in ~20 h. +- Conflicts with the 260928 maintainer design; offered only so the decision can be + made on a reviewable diff. + diff --git a/devlog/_plan/261001_quota_send_lock_split/040_pac_fallback.md b/devlog/_plan/261001_quota_send_lock_split/040_pac_fallback.md new file mode 100644 index 00000000000..97cda739ea6 --- /dev/null +++ b/devlog/_plan/261001_quota_send_lock_split/040_pac_fallback.md @@ -0,0 +1,19 @@ +# 040 — wp4: PAC fallback (candidate) + +Branch `codex/chatgpt-pac-fallback`, stacked on wp3. Code from #5947, +`Co-authored-by: lcxhh521`. A candidate maintainers may close. + +Adds `src/chatgpt/desktop-unblock/{pac,entry-proxy}.ts`, restores the PAC hunks +removed in 030 (runtime, launch-watcher, lifecycle logging, CLI status), config +`chatgptDesktop.pacFallback`, tests `unblock-pac`, `unblock-entry-proxy`, PAC cases of +`unblock-runtime`, `unblock-launch-script` (314-325, 373-426), `unblock-watcher-install` +(142), and the guide's PAC section (English 92-120 plus locales). + +End state: wp2+wp3+wp4 tree equals #5947's feature set with the shim relocated. + +## PR description must state + +Same security cost as 030 (requires the trusted local CA) plus: the PAC embeds the +system proxy/PAC configuration captured at start, and #6196 measured zero traffic on +both the TLS listener and the CONNECT entry. + diff --git a/devlog/_plan/261001_quota_send_lock_split/050_pr5879_disposition.md b/devlog/_plan/261001_quota_send_lock_split/050_pr5879_disposition.md new file mode 100644 index 00000000000..492342ae63b --- /dev/null +++ b/devlog/_plan/261001_quota_send_lock_split/050_pr5879_disposition.md @@ -0,0 +1,26 @@ +# 050 — wp5: #5879 auto desktop-authless disposition + +Decision: **no carried PR; rationale only.** #5879 is closed with credit after the +split PRs are up. + +Reasons (read-only review against `64294638`): + +1. Requirement conflict: #6196's reporter requires the signed-in account to stay in + the UI. Authless removes the account plane (usage, Fast, plugin/thread identity), + and the 260928 design rejects `requires_openai_auth = false` as the fix. +2. Disruption: every transition calls `performCodexRestart()`, restarting Desktop and + app servers from a background sweep without user consent. +3. Recovery proof is unsound on today's API: `refreshCodexQuotaForActivation()` + returns silently for unavailable leases/reauth/missing credentials and + `isCodexQuotaExhausted(null)` is false, so unknown state releases authless. +4. Persists before injecting, restarts even when injection fails, and the next sweep + sees the stored state and never retries; overwrites the manual + `codexDesktopAuthless` preference. +5. Overlap: wp1 lowers the short-window threshold so opencodex traffic leaves the + main account earlier, and wp2 addresses the client gate directly without leaving + the signed-in mode. + +A revised proposal would need authoritative recovery evidence, a separate automatic +state that never overwrites the manual preference, retry of failed application, and +deferred restarts. + diff --git a/devlog/_plan/261001_quota_send_lock_split/060_closeout.md b/devlog/_plan/261001_quota_send_lock_split/060_closeout.md new file mode 100644 index 00000000000..410a0bd4eb6 --- /dev/null +++ b/devlog/_plan/261001_quota_send_lock_split/060_closeout.md @@ -0,0 +1,12 @@ +# 060 — wp6: closeout + +After every split PR is open and its required CI is green on the exact head: + +1. Comment on #5947: links to wp2/wp3/wp4 PRs, which part went where, the + `Co-authored-by` trailer, thanks; then close. +2. Comment on #5879: link to 050 rationale (in the wp1 PR), credit, thanks; close. +3. Comment on #4878 linking #6196 and the split PRs; close as duplicate. +4. Report PR links, head SHAs, CI runs, closed PRs and residual risks. + +No merges, releases or promotions. + 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 12ddcad4aac..6a0a272b8d1 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 @@ -74,9 +74,9 @@ ocx login anthropic ## 계정과 키 풀 -### 메인 계정 98% 보호 +### 메인 계정 사용량 보호 -**Codex 설정 → 다중 인증 → 고급 설정**에서 Ultra Fast 옆의 **메인 계정 98% 차단**이 +**Codex 설정 → 다중 인증 → 고급 설정**에서 Ultra Fast 옆의 **메인 계정 차단**이 기본으로 켜져 있습니다. 끄면 바로 적용되고, 다시 켤 때는 영향 안내가 먼저 나오며 취소하면 설정은 바뀌지 않습니다. 고급 설정을 접어도 메인 계정 카드에 보호 상태와 사용량 확인 필요 여부, 현재 차단 여부가 표시됩니다. @@ -88,10 +88,23 @@ ocx login anthropic Reserve입니다. 차단 중에는 그 메인 계정의 Reserve를 활성화할 수 없으므로, 메인 계정을 끝까지 쓰고 Reserve로 넘어가려면 스위치를 끄세요. -**5h 창과 주간 창은 각각 따로 차단합니다.** 둘 중 하나만 98%에 닿아도, 다른 창에 여유가 +**5h 창과 주간 창은 각각 따로 차단합니다.** 5h 창이 90%, 주간 창이 98%에 닿으면 다른 창에 여유가 있어도 바로 차단합니다. 월간 전용 계정은 월간을 기준으로 합니다. 차단한 창이 모두 새 사용률 -98% 미만(0% 리셋 포함)을 보고하면 자동으로 차단을 풀고, 스위치는 켜 둡니다. 이후 다시 98%가 -되면 차단합니다. 5h 수치를 읽지 못해도 주간 차단은 그대로 유지됩니다. +각 기준 미만(0% 리셋 포함)을 보고하면 자동으로 차단을 풀고, 스위치는 켜 둡니다. 이후 다시 각 기준에 +닿으면 차단합니다. 5h 수치를 읽지 못해도 주간 차단은 그대로 유지됩니다. +`config.json`의 `codexMainAccountHardLockThresholds`에 `short`와 `long`을 설정할 수 있습니다. +기본값은 `{ "short": 90, "long": 98 }`입니다. 둘 다 80~100 범위의 정수여야 하고, +`short`는 `long`보다 클 수 없습니다. 생략한 값은 기본값을 사용합니다. 설정 API도 같은 객체를 +받으며 `mainAccountHardLock.thresholds`에 실제 적용값을 반환합니다. + +같은 리셋 구간에서 최신 사용량이 1%포인트 이상 늘었는데 이 프록시를 통한 메인 계정 활동이 +최근에 없었다면 대시보드와 `ocx status`에 **opencodex 밖에서 사용량이 늘었을 수 있음**을 표시합니다. +오래 실행한 턴도 같은 현상을 만들 수 있어 외부 사용의 확정 증거는 아닙니다. 외부 사용 때문에 +차단해도 한도를 소진할 수 있습니다. 알림은 프로세스 메모리에만 유지되며 계정이나 리셋 구간이 +바뀌면 지워지고, 6시간 경과 또는 관측한 리셋 시각 중 이른 시점에 만료됩니다. +`ocx status --json`은 실행 중인 관리 API에서 읽은 상태, 적용 기준, 차단 창 종류와 선택적 +`externalUsage` 알림을 `mainAccountHardLock`에 담습니다. + 값이 빠진 응답을 0%로 보지 않으며, 이미 확인한 차단 수치를 누락된 응답만으로 지우지도 않습니다. 예정된 리셋 시간이 지났다는 이유만으로 풀지는 않습니다. 차단 중에는 1분 주기 점검이 알려진 차단 창의 리셋 시각까지 기다린 뒤 계정의 실제 사용량을 확인합니다. 이후에도 차단 상태이거나 @@ -105,7 +118,7 @@ Pool 모드에서 사용량 조회의 `--refresh`는 캐시 유효기간을 무 1차 창이 명시적 `null`이고 2차 창에 유효한 주간 사용량이 있어도 같습니다. 어느 경우든 2차·3차 창은 명시적 `null`이거나 기간이 24시간 이상이고 유효한 사용량 수치가 있어야 합니다. 파서의 단기·장기 구분 기준을 따르므로 주간·월간뿐 아니라 하루짜리 창도 해당합니다. -현재 창에는 동일한 98% 기준을 적용합니다. 이 판단은 응답 한 건의 정보에 의존하며 연속 관측을 +현재 장기 창에는 설정된 장기 기준(기본 98%)을 적용합니다. 이 판단은 응답 한 건의 정보에 의존하며 연속 관측을 요구하지 않습니다. 창 필드가 하나라도 생략되었거나, 창의 기간을 모르거나, 응답 헤더만 일부 도착한 경우에는 이전 차단을 해제하지 않습니다. 모든 창이 `null`이고 크레딧만 있거나, 보조 월간 수치만 있는 응답도 차단을 풀지 않습니다. @@ -125,7 +138,7 @@ Direct 모드의 공급자 사용량 보고서에서도 공유 상태에 반영 계속 사용할 수 있습니다. 보호 기능이 켜져 있으면 소유권이 확인된 시작 과정에서 native 프로필 복구와 정리를 마친 뒤 -메인 인증정보의 메모리 내 식별 연결을 복원하므로, 저장된 98% 차단이 재시작 후에도 유지됩니다. +메인 인증정보의 메모리 내 식별 연결을 복원하므로, 저장된 정책 차단이 재시작 후에도 유지됩니다. 연결을 준비하는 동안 호출자 인증정보를 쓰는 Direct, 메인 계정 지정, 메인 fallback, 메인 pin 요청은 잠시 503을 받을 수 있고, 저장된 Pool 계정은 그동안에도 그대로 쓸 수 있습니다. 이 초기화를 위해 다른 서비스 소유이거나 소유권이 미확인인 홈의 인증정보를 읽지는 않습니다. @@ -161,7 +174,7 @@ Reserve가 활성화되지 않을 수 있습니다. 스위치를 끄면 원래 요청을 거절합니다. 다른 계정이나 일반 Luna로 몰래 바꾸지 않습니다. 일반 사용량 조회는 기존 허용을 취소할 수 있지만 새로 허용하지는 않습니다. -전체 쿨다운, 일시정지, 재인증, 98% 하드락은 여전히 적용됩니다. 소진된 메인 계정에서 Reserve를 +전체 쿨다운, 일시정지, 재인증, 메인 계정 하드락은 여전히 적용됩니다. 소진된 메인 계정에서 Reserve를 쓰려면 하드락을 꺼야 하지만, 껐다고 서버의 사용 권한이 생기지는 않습니다. 이 호환 경로는 대화와 대화 압축용입니다. 이미지 설명·웹 검색 보조 모델이나 독립 검색 릴레이에 Reserve를 지정하는 용도는 지원하지 않으므로, 그 기능에는 다른 모델을 선택하세요. 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 e6e3a548fa2..839bc7161b4 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -137,9 +137,9 @@ Remove the stored OAuth credential for a provider. ## Accounts and key pools -### Main-account 98% protection +### Main-account quota protection -In **Codex settings → Multi-auth → Advanced settings**, **Block main account at 98%** +In **Codex settings → Multi-auth → Advanced settings**, **Block main account** is on by default beside Ultra Fast. Switching it off applies immediately; turning it back on first shows the consequences, and cancelling does not change the setting. The main-account card shows monitoring, unknown usage, or a current policy block even when Advanced settings is closed. @@ -151,10 +151,23 @@ 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 **5h window and the weekly window each block on their own**: either one reaching 98% blocks +The **5h window and the weekly window each block on their own**: the 5h window reaching 90% or the weekly window 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. +reports a fresh reading below its threshold (a 0% reset counts); the next observation at the threshold blocks again. +Configure `codexMainAccountHardLockThresholds` in `config.json` with optional `short` and `long` +percentages, for example `{ "short": 90, "long": 98 }`. Both must be integers from 80 through 100, +and `short` must not exceed `long`; omitted fields use the defaults. The settings API accepts the +same object and returns the effective values in `mainAccountHardLock.thresholds`. + +The dashboard and `ocx status` show **possible usage outside opencodex** when fresh readings rise +by at least one point in the same reset episode without recent main-account activity through this +proxy. This is an advisory observation, not proof: a long-running turn can also cause it. The lock +may not prevent exhaustion from outside traffic. The process-local notice clears on identity or +reset changes and expires after six hours or at the observed reset, whichever comes first. +`ocx status --json` exposes the state, effective thresholds, blocking window kind, and optional +`externalUsage` warning under `mainAccountHardLock` when live management evidence is available. + 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 minute sweep waits for the latest known blocking reset, then checks owned usage. @@ -168,7 +181,7 @@ its primary window explicitly lasts **at least 24 hours** and reports valid usag explicitly `null` and a measured secondary supplies the weekly usage. In either case, secondary/tertiary windows must be explicitly `null` or explicitly last at least 24 hours and report valid usage. This follows the parser's short/long boundary, so a one-day window qualifies as well as weekly/monthly windows. The current window still uses the same -98% threshold. This relies on the single reported snapshot; repeated observations are not required. +configured long-window threshold (98% by default). This relies on the single reported snapshot; repeated observations are not required. Any omitted window field, an unknown duration, or partial response headers cannot clear a previous block. All-null responses carrying only credits and supplementary monthly-only readings also cannot establish recovery. The proxy checks the stored credential again before applying a delayed response. An unreadable file @@ -190,7 +203,7 @@ keyring credentials, and traffic outside the proxy can still spend quota. Added providers remain available. With protection on, an owned startup restores the main credential's in-memory identity -binding after native-profile recovery and cleanup, so a persisted 98% block survives a restart. +binding after native-profile recovery and cleanup, so a persisted policy block survives a restart. Caller-owned Direct, exact-main, main-fallback, and main-pin requests can briefly receive 503 while that binding is pending; healthy stored Pool accounts stay eligible throughout. No credential is read from a foreign or unconfirmed service home for this initialization. @@ -224,7 +237,7 @@ Each compatibility request checks a credential-bound server authorization, cache requires ordinary usage to be disallowed, the Luna Reserve banner, and exactly one allowed Reserve bucket. Missing, denied, stale or mismatched evidence refuses the request; it does not switch accounts or silently use ordinary Luna. Passive usage can revoke authorization but cannot create it. -Global cooldown, pause, reauthentication and the 98% hard lock still apply. Turn the hard lock off if +Global cooldown, pause, reauthentication and the main-account hard lock still apply. Turn the hard lock off if you want to use Reserve on an exhausted main account; doing so does not grant server entitlement. This compatibility path supports conversation requests and compaction, not Reserve as a vision or web-search helper or a standalone search-relay model. Choose another model for those helpers. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index e97fab4cdca..a919cf46fd9 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -38,6 +38,8 @@ runs helper features around provider requests. | `codexAccountNamespaces?` | `Record` | unset | Public selector to Codex account mapping. The main login maps to the config-only sentinel `@main`; added accounts map to their account id. Normally generated by enabling `codexAccountPickerEnabled` rather than hand-authored. An empty map means no account-qualified rows exist, including the Luna Reserve row. | | `codexClientCompaction?` | `boolean` | `false` | Opt into Codex client-side compaction on an authenticated loopback bind. Uses the dedicated `opencodex` provider identity with `requires_openai_auth = true`, preventing new routed compactions from storing OpenCodeX-owned `ocx1:` state. `codexDesktopAuthless` takes precedence when both are enabled and keeps `requires_openai_auth = false`. V2 sub-agent routing is unchanged. `ocx system settings --client-compaction on`. See [Codex integration](/guides/codex-integration/#client-side-compaction-opt-in). | | `codexProviderDisplayName?` | `string` | `"OpenCodex Proxy"` | Label Codex shows for the injected `opencodex` provider, written as its `name` field in `config.toml` and the reference profile. Presentation only: routing resolves through the provider id `opencodex`, so a rename never moves `model_provider = "opencodex"` or the `[model_providers.opencodex]` header and cannot orphan threads already tagged with that id. Codex refuses to load a provider with no name, so there is no way to omit the field — choose a neutral label instead. A blank, over-128-character, or control-character value is ignored and the default label is written. | +| `codexMainAccountHardLock?` | `boolean` | `true` | Block new identity-matched main-account requests at their per-window threshold. Only explicit `false` opts out. See [main-account protection](/reference/cli/providers-accounts/#main-account-quota-protection). | +| `codexMainAccountHardLockThresholds?` | `{ short?: number; long?: number }` | `{ short: 90, long: 98 }` | Independent 5h and weekly/monthly thresholds. Integers 80–100, with `short <= long`. Invalid persisted fields fall back to defaults; the settings API rejects invalid writes before mutation. | | `resetCreditAutoRedeem?` | `{ enabled?: boolean; leadTimeMinutes?: number }` | off | Opt-in: redeem the main Codex account's soonest-expiring reset credit `leadTimeMinutes` (1–60, default 10) before it expires. Every attempt re-reads the upstream credit list first and skips when the credit is gone (for example, redeemed by hand); the `redeem_request_id` is journaled in `$OPENCODEX_HOME/reset-credit-auto-redeem.json` before the call so a crash replays the same idempotent request instead of spending a second credit. Servers sharing this configuration directory coordinate reservations and settlements so one process does not replace another's request record. Logs carry a hashed account key only. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility. Original metadata is backed up and restored by `ocx stop` / `ocx restore`. | | `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | Redirect recognized Codex helper/shadow calls to a chosen model while preserving the request's configured reasoning effort. The default source prefixes are `gpt-6-luna` and `gpt-5.6-luna`; older clients through 0.144.x used `gpt-5.4-mini`, which `sourceModels` can restore. | diff --git a/gui/src/components/MainAccountHardLockSetting.tsx b/gui/src/components/MainAccountHardLockSetting.tsx index 7f5e8953313..e46079b9e8e 100644 --- a/gui/src/components/MainAccountHardLockSetting.tsx +++ b/gui/src/components/MainAccountHardLockSetting.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useId, useRef, useState } from "react"; import { createBoundedFetch } from "../bounded-fetch"; import { startVisibilityPoll } from "../visibility-poll"; import { useT } from "../i18n/shared"; -import type { MainAccountHardLockStatus } from "../hooks/useCodexAccountPool"; +import { hardLockThresholds, type MainAccountHardLockStatus } from "../hooks/useCodexAccountPool"; type Props = { apiBase: string; onSaved: () => Promise }; type Snapshot = { codexMainAccountHardLock: boolean; mainAccountHardLock: MainAccountHardLockStatus }; @@ -20,8 +20,9 @@ function readSnapshot(value: unknown): Snapshot { return payload as Snapshot; } -function HardLockConfirmation({ pending, onCancel, onConfirm }: { +function HardLockConfirmation({ pending, thresholds, onCancel, onConfirm }: { pending: boolean; + thresholds: { short: number; long: number }; onCancel: () => void; onConfirm: () => void; }) { @@ -49,7 +50,7 @@ function HardLockConfirmation({ pending, onCancel, onConfirm }: {
+ {snapshot?.mainAccountHardLock.externalUsage &&

{t("codexAuth.mainExternalUsageWarning")}

} {(saveError || loadError) &&

{t(saveError ? "codexAuth.mainHardLockSaveFailed" : "codexAuth.mainHardLockLoadFailed")}{" "}

} - {saved !== null && !refreshError &&

{t(saved ? "codexAuth.mainHardLockEnabled" : "codexAuth.mainHardLockDisabled")}

} + {saved !== null && !refreshError &&

{t(saved ? "codexAuth.mainHardLockEnabled" : "codexAuth.mainHardLockDisabled", thresholds)}

} {refreshError &&

{t("codexAuth.mainHardLockRefreshFailed")}{" "}

}
- {confirming && { void save(true); }} />} + {confirming && { void save(true); }} />} ); } diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index 9ec23fe5a79..798b48f67ba 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -13,6 +13,7 @@ import type { MainDeviceReauthState } from "./use-main-device-reauth"; import { LoginHint } from "./login-url-block"; import type { NoticeTone } from "../ui"; import { navigateHash } from "../hash-routing"; +import { hardLockThresholds } from "../hooks/useCodexAccountPool"; import { doctorCopyButtonLabel, formatOAuthHealthLabel, @@ -203,12 +204,13 @@ export function CodexAccountPoolMainCard({ {policy?.enabled && (

{t(hardLocked ? "codexAuth.mainHardLockBlocked" - : policy.state === "ready" ? "codexAuth.mainHardLockMonitoring" : "codexAuth.mainHardLockUnknown")}

+ : policy.state === "ready" ? "codexAuth.mainHardLockMonitoring" : "codexAuth.mainHardLockUnknown", hardLockThresholds(policy.thresholds))}

{onManageMainHardLock ? : }
)} + {policy?.externalUsage &&

{t("codexAuth.mainExternalUsageWarning")}

} {healthSummary && (
{healthSummary}
)} diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index 9765212944f..ce2ada6378e 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -25,12 +25,30 @@ import { */ export interface MainAccountHardLockStatus { + thresholds?: { short: number; long: number }; + window?: "short" | "long"; + externalUsage?: { window: "short" | "long"; fromPercent: number; toPercent: number; observedAt: number }; enabled: boolean; state: "off" | "unknown" | "ready" | "blocked"; /** Server timestamp in milliseconds; not a client-side unlock instruction. */ resetAt?: number; } +/** The server defaults, used whenever the management payload does not carry a usable pair. */ +export const DEFAULT_MAIN_HARD_LOCK_THRESHOLDS = { short: 90, long: 98 } as const; + +/** + * The thresholds as UI copy may show them. The management API is trusted for shape but not + * relied on: anything other than two finite percentages falls back to the server defaults, + * so translated strings never render "undefined" or a non-number. + */ +export function hardLockThresholds(value: unknown): { short: number; long: number } { + const pair = value && typeof value === "object" ? value as { short?: unknown; long?: unknown } : undefined; + const percent = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 100; + if (pair && percent(pair.short) && percent(pair.long)) return { short: pair.short, long: pair.long }; + return { ...DEFAULT_MAIN_HARD_LOCK_THRESHOLDS }; +} + export interface CodexCredits { hasCredits?: boolean; unlimited?: boolean; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 0ca7d4de119..11cbe22b5cf 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1750,19 +1750,20 @@ export const de: Record = { "codexAuth.creditsToggleFailed": "Codex-Credits-Einstellung konnte nicht geändert werden", "codexAuth.refreshQuota": "Kontingente aktualisieren", "codexAuth.ultraFastTitle": "Ultra-Fast-Diensttarif", - "codexAuth.mainHardLockTitle": "Hauptkonto bei 98 % sperren", - "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.mainHardLockTitle": "Hauptkonto sperren (5h {short}%, lang {long}%)", + "codexAuth.mainHardLockDesc": "Sperrt bei {short}% im 5h-Fenster oder {long}% im Wochenfenster (monatlich bei reinen Monatskonten). Entsperrt, sobald alle sperrenden Fenster unter ihrem Schwellenwert liegen. Standardmäßig aktiv.", + "codexAuth.mainHardLockConfirmTitle": "Hauptkontoschutz aktivieren (5h {short}%, lang {long}%)?", "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", - "codexAuth.mainHardLockEnabled": "98-%-Schutz ist aktiviert.", - "codexAuth.mainHardLockDisabled": "98-%-Schutz ist deaktiviert. Andere Kontolimits gelten weiterhin.", + "codexAuth.mainHardLockEnabled": "Schutz aktiv (5h {short}%, lang {long}%).", + "codexAuth.mainHardLockDisabled": "Hauptkontoschutz deaktiviert. Andere Kontolimits gelten weiterhin.", "codexAuth.mainHardLockLoadFailed": "Einstellung konnte nicht geladen werden. Erneut versuchen, um den aktuellen Zustand zu prüfen.", "codexAuth.mainHardLockSaveFailed": "Speicherung konnte nicht bestätigt werden. Einstellung vor einem neuen Versuch erneut laden.", "codexAuth.mainHardLockRefreshFailed": "Einstellung gespeichert, aber Kontostatus konnte nicht aktualisiert werden. Bitte erneut versuchen.", - "codexAuth.mainHardLockBlocked": "Durch 98-%-Schutz gesperrt", + "codexAuth.mainHardLockBlocked": "Durch Hauptkontoschutz gesperrt (5h {short}%, lang {long}%)", "codexAuth.mainHardLockUnknown": "Schutz aktiv · Nutzung unbekannt", "codexAuth.mainHardLockMonitoring": "Schutz aktiv · Überwachung", + "codexAuth.mainExternalUsageWarning": "Mögliche Nutzung außerhalb von opencodex. Die Sperre verhindert möglicherweise keine Erschöpfung.", "codexAuth.mainHardLockManage": "Schutzeinstellung anzeigen", "codexAuth.ultraFastDesc": "Verhindert, dass ein selbst konfigurierter ultrafast-Diensttarif beim Neuaufbau des Katalogs entfernt wird, und benennt ihn in den Anfrageprotokollen. Ultra Fast wird nicht in die Modellauswahl aufgenommen: Upstream kündigt nur Fast an, ein Eintrag würde also eine Geschwindigkeit anbieten, die die Leitung nicht liefern kann.", "codexAuth.ultraFastLoadFailed": "Die Ultra-Fast-Einstellung konnte nicht gelesen werden.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index f1cb2869728..0731c6aabd6 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2361,19 +2361,20 @@ export const en = { "codexAuth.creditsToggleFailed": "Could not change the Codex credits setting", "codexAuth.refreshQuota": "Refresh quotas", "codexAuth.ultraFastTitle": "Ultra Fast service tier", - "codexAuth.mainHardLockTitle": "Block main account at 98%", - "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.mainHardLockTitle": "Block main account (5h {short}%, long {long}%)", + "codexAuth.mainHardLockDesc": "Blocks at {short}% in the 5h window or {long}% in the weekly window (monthly for monthly-only accounts). Unlocks once every blocking window reads below its threshold; protection stays on. On by default.", + "codexAuth.mainHardLockConfirmTitle": "Enable main-account protection (5h {short}%, long {long}%)?", "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", - "codexAuth.mainHardLockEnabled": "98% protection is on.", - "codexAuth.mainHardLockDisabled": "98% protection is off. Other account limits still apply.", + "codexAuth.mainHardLockEnabled": "Protection is on (5h {short}%, long {long}%).", + "codexAuth.mainHardLockDisabled": "Main-account protection is off. Other account limits still apply.", "codexAuth.mainHardLockLoadFailed": "Could not load this setting. Retry to check its current state.", "codexAuth.mainHardLockSaveFailed": "Could not confirm the save. Reload the setting before trying again.", "codexAuth.mainHardLockRefreshFailed": "Setting saved, but account status could not be refreshed. Please retry.", - "codexAuth.mainHardLockBlocked": "Blocked by 98% protection", + "codexAuth.mainHardLockBlocked": "Blocked by main-account protection (5h {short}%, long {long}%)", "codexAuth.mainHardLockUnknown": "Protection on · usage unknown", "codexAuth.mainHardLockMonitoring": "Protection on · monitoring", + "codexAuth.mainExternalUsageWarning": "Possible usage outside opencodex. The lock may not prevent exhaustion.", "codexAuth.mainHardLockManage": "View protection setting", "codexAuth.ultraFastDesc": "Keeps an ultrafast service tier you configured yourself from being stripped when the catalog is regenerated, and names it in the request logs. It does not add Ultra Fast to the model picker: upstream advertises only Fast, so a picker row would offer a speed the wire cannot deliver.", "codexAuth.ultraFastLoadFailed": "Could not read the Ultra Fast setting.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index a11f2a549aa..238ae04362f 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2288,19 +2288,20 @@ export const fr: Record = { "codexAuth.creditsToggleFailed": "Impossible de modifier le réglage des crédits Codex", "codexAuth.refreshQuota": "Actualiser les quotas", "codexAuth.ultraFastTitle": "Niveau de service Ultra Fast", - "codexAuth.mainHardLockTitle": "Bloquer le compte principal à 98 %", - "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.mainHardLockTitle": "Bloquer le compte principal (5h {short}%, long {long}%)", + "codexAuth.mainHardLockDesc": "Bloque à {short}% sur 5h ou {long}% sur la semaine (mois pour les comptes mensuels). Débloque dès que chaque fenêtre bloquante passe sous son seuil. Protection active par défaut.", + "codexAuth.mainHardLockConfirmTitle": "Activer la protection du compte principal (5h {short}%, long {long}%) ?", "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", - "codexAuth.mainHardLockEnabled": "La protection à 98 % est active.", - "codexAuth.mainHardLockDisabled": "La protection à 98 % est désactivée. Les autres limites du compte restent applicables.", + "codexAuth.mainHardLockEnabled": "Protection active (5h {short}%, long {long}%).", + "codexAuth.mainHardLockDisabled": "Protection du compte principal désactivée. Les autres limites restent applicables.", "codexAuth.mainHardLockLoadFailed": "Impossible de charger ce réglage. Réessayez pour vérifier son état.", "codexAuth.mainHardLockSaveFailed": "Impossible de confirmer l’enregistrement. Rechargez le réglage avant de réessayer.", "codexAuth.mainHardLockRefreshFailed": "Réglage enregistré, mais l’état du compte n’a pas pu être actualisé. Réessayez.", - "codexAuth.mainHardLockBlocked": "Bloqué par la protection à 98 %", + "codexAuth.mainHardLockBlocked": "Bloqué par la protection du compte principal (5h {short}%, long {long}%)", "codexAuth.mainHardLockUnknown": "Protection active · utilisation inconnue", "codexAuth.mainHardLockMonitoring": "Protection active · surveillance", + "codexAuth.mainExternalUsageWarning": "Utilisation possible hors opencodex. Le blocage peut ne pas empêcher l’épuisement du quota.", "codexAuth.mainHardLockManage": "Voir le réglage de protection", "codexAuth.ultraFastDesc": "Empêche la suppression d’un niveau de service ultrafast que vous avez configuré vous-même lors de la régénération du catalogue, et le nomme dans les journaux de requêtes. Ultra Fast n’est pas ajouté au sélecteur de modèles : l’amont n’annonce que Fast, une entrée proposerait donc une vitesse que le transport ne peut pas fournir.", "codexAuth.ultraFastLoadFailed": "Impossible de lire le réglage Ultra Fast.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index db613b89e69..bd7704fdd24 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2183,19 +2183,20 @@ export const ja: Record = { "codexAuth.creditsToggleFailed": "Codex クレジットの設定を変更できませんでした", "codexAuth.refreshQuota": "クォータを更新", "codexAuth.ultraFastTitle": "Ultra Fast サービスティア", - "codexAuth.mainHardLockTitle": "メインアカウントを98%で停止", - "codexAuth.mainHardLockDesc": "5時間枠と週間枠のどちらかが98%に達すると停止します(月間のみのアカウントは月間)。停止の原因になった枠がすべて98%未満になると自動解除し、設定は有効のままです。既定でオンです。", - "codexAuth.mainHardLockConfirmTitle": "メインアカウントの98%保護を有効にしますか?", + "codexAuth.mainHardLockTitle": "メインアカウントを制限 (5h {short}%、長期 {long}%)", + "codexAuth.mainHardLockDesc": "5h枠が{short}%、週間枠が{long}%に達すると制限します。月間のみのアカウントは月間枠を使います。制限中の枠がすべて基準を下回ると自動解除し、保護は維持します。既定で有効です。", + "codexAuth.mainHardLockConfirmTitle": "メインアカウント保護を有効にしますか? (5h {short}%、長期 {long}%)", "codexAuth.mainHardLockConfirmBody": "停止中はメインアカウントのLuna Reserveも使えません。通常枠を使い切らない場合、Reserveが有効にならないことがあります。追加アカウントや他のプロバイダーは引き続き使えます。実行中のリクエスト、照合できないキーチェーン認証情報、このプロキシ外の通信は対象外です。", "codexAuth.mainHardLockConfirm": "保護を有効にする", - "codexAuth.mainHardLockEnabled": "98%保護を有効にしました。", - "codexAuth.mainHardLockDisabled": "98%保護を無効にしました。他のアカウント制限は引き続き適用されます。", + "codexAuth.mainHardLockEnabled": "保護が有効です (5h {short}%、長期 {long}%)。", + "codexAuth.mainHardLockDisabled": "メインアカウント保護が無効です。他の制限は引き続き適用されます。", "codexAuth.mainHardLockLoadFailed": "設定を読み込めませんでした。再試行して現在の状態を確認してください。", "codexAuth.mainHardLockSaveFailed": "保存を確認できませんでした。設定を再読み込みしてから再試行してください。", "codexAuth.mainHardLockRefreshFailed": "設定は保存されましたが、アカウント状態を更新できませんでした。再試行してください。", - "codexAuth.mainHardLockBlocked": "98%保護により停止中", + "codexAuth.mainHardLockBlocked": "メインアカウント保護で制限中 (5h {short}%、長期 {long}%)", "codexAuth.mainHardLockUnknown": "保護有効・使用率不明", "codexAuth.mainHardLockMonitoring": "保護有効・監視中", + "codexAuth.mainExternalUsageWarning": "opencodex以外で使用量が増えた可能性があります。制限しても上限に達する場合があります。", "codexAuth.mainHardLockManage": "保護設定を表示", "codexAuth.ultraFastDesc": "自分で設定した ultrafast サービスティアがカタログ再生成時に削除されないようにし、リクエストログにそのティア名を記録します。モデルピッカーに Ultra Fast は追加しません。アップストリームは Fast しか公開しておらず、ピッカーに項目を出すと実際には出せない速度を選ばせることになるためです。", "codexAuth.ultraFastLoadFailed": "Ultra Fast 設定を読み取れませんでした。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index aad942e36ce..236a2b2d168 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1794,19 +1794,20 @@ export const ko: Record = { "codexAuth.creditsToggleFailed": "Codex 크레딧 설정을 바꾸지 못했습니다", "codexAuth.refreshQuota": "할당량 새로고침", "codexAuth.ultraFastTitle": "Ultra Fast 서비스 티어", - "codexAuth.mainHardLockTitle": "메인 계정 98% 차단", - "codexAuth.mainHardLockDesc": "5h 창이나 주간 창 중 하나라도 98%에 닿으면 차단합니다. 월간 전용 계정은 월간을 봅니다. 차단한 창이 모두 98% 아래로 내려오면 자동으로 풀리고 설정은 유지됩니다. 기본값으로 켜져 있습니다.", - "codexAuth.mainHardLockConfirmTitle": "메인 계정 98% 차단을 켤까요?", + "codexAuth.mainHardLockTitle": "메인 계정 차단 (5h {short}%, 장기 {long}%)", + "codexAuth.mainHardLockDesc": "5h 창이 {short}%, 주간 창이 {long}%에 닿으면 차단합니다. 월간 전용 계정은 월간을 봅니다. 차단한 창이 모두 각 기준 아래로 내려오면 자동으로 풀리고 설정은 유지됩니다. 기본으로 켜져 있습니다.", + "codexAuth.mainHardLockConfirmTitle": "메인 계정 보호를 켤까요? (5h {short}%, 장기 {long}%)", "codexAuth.mainHardLockConfirmBody": "차단 중에는 메인 계정의 Luna Reserve도 사용할 수 없습니다. 일반 사용량이 소진되지 않으면 Reserve가 활성화되지 않을 수 있습니다. 추가 계정과 다른 공급자는 계속 사용할 수 있습니다. 진행 중 요청, 식별되지 않은 키링 계정, 프록시 밖 요청에는 적용되지 않습니다.", "codexAuth.mainHardLockConfirm": "확인하고 켜기", - "codexAuth.mainHardLockEnabled": "98% 보호 설정을 켰습니다.", - "codexAuth.mainHardLockDisabled": "98% 보호 설정을 껐습니다. 다른 계정 제한은 그대로 적용됩니다.", + "codexAuth.mainHardLockEnabled": "보호를 켰습니다 (5h {short}%, 장기 {long}%).", + "codexAuth.mainHardLockDisabled": "메인 계정 보호를 껐습니다. 다른 계정 제한은 그대로 적용됩니다.", "codexAuth.mainHardLockLoadFailed": "설정을 불러오지 못했습니다. 다시 시도해 현재 상태를 확인하세요.", "codexAuth.mainHardLockSaveFailed": "저장 여부를 확인하지 못했습니다. 설정을 다시 불러온 뒤 시도하세요.", "codexAuth.mainHardLockRefreshFailed": "설정은 저장됐지만 계정 상태를 다시 확인하지 못했습니다. 다시 시도하세요.", - "codexAuth.mainHardLockBlocked": "98% 보호로 차단 중", + "codexAuth.mainHardLockBlocked": "메인 계정 보호로 차단 중 (5h {short}%, 장기 {long}%)", "codexAuth.mainHardLockUnknown": "보호 켜짐 · 사용량 확인 필요", - "codexAuth.mainHardLockMonitoring": "98% 보호 켜짐", + "codexAuth.mainHardLockMonitoring": "보호 켜짐", + "codexAuth.mainExternalUsageWarning": "opencodex 밖에서 사용량이 늘었을 수 있습니다. 차단해도 한도를 소진할 수 있습니다.", "codexAuth.mainHardLockManage": "차단 설정 보기", "codexAuth.ultraFastDesc": "직접 설정한 ultrafast 서비스 티어가 카탈로그를 다시 만들 때 지워지지 않게 하고, 요청 로그에 그 티어 이름을 남깁니다. 모델 피커에 Ultra Fast를 추가하지는 않습니다. 업스트림은 Fast만 알리기 때문에, 피커에 칸을 만들면 실제로 낼 수 없는 속도를 고르게 하는 셈입니다.", "codexAuth.ultraFastLoadFailed": "Ultra Fast 설정을 읽지 못했습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index eb9c0c86bf7..b4042ab2cd6 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2274,19 +2274,20 @@ export const ru: Record = { "codexAuth.creditsToggleFailed": "Не удалось изменить настройку кредитов Codex", "codexAuth.refreshQuota": "Обновить квоты", "codexAuth.ultraFastTitle": "Уровень обслуживания Ultra Fast", - "codexAuth.mainHardLockTitle": "Блокировать основной аккаунт при 98%", - "codexAuth.mainHardLockDesc": "Блокирует, когда окно 5 ч или недельное окно достигает 98% (месячное для аккаунтов только с месячным лимитом). Блокировка снимается автоматически, когда все блокирующие окна опускаются ниже 98%; защита остаётся включённой. Включено по умолчанию.", - "codexAuth.mainHardLockConfirmTitle": "Включить защиту основного аккаунта при 98%?", + "codexAuth.mainHardLockTitle": "Блокировать основной аккаунт (5ч {short}%, долгий период {long}%)", + "codexAuth.mainHardLockDesc": "Блокировка при {short}% за 5ч или {long}% за неделю (месяц для месячных аккаунтов). Снимается, когда все блокирующие окна ниже своего порога. Защита включена по умолчанию.", + "codexAuth.mainHardLockConfirmTitle": "Включить защиту основного аккаунта (5ч {short}%, долгий период {long}%)?", "codexAuth.mainHardLockConfirmBody": "Во время блокировки Luna Reserve основного аккаунта тоже недоступна. Если обычная квота не исчерпана, Reserve может не активироваться. Дополнительные аккаунты и другие провайдеры остаются доступны. Текущие запросы, несопоставленные данные связки ключей и запросы вне этого прокси не защищены.", "codexAuth.mainHardLockConfirm": "Включить защиту", - "codexAuth.mainHardLockEnabled": "Защита при 98% включена.", - "codexAuth.mainHardLockDisabled": "Защита при 98% выключена. Остальные лимиты аккаунта сохраняются.", + "codexAuth.mainHardLockEnabled": "Защита включена (5ч {short}%, долгий период {long}%).", + "codexAuth.mainHardLockDisabled": "Защита основного аккаунта выключена. Другие ограничения действуют.", "codexAuth.mainHardLockLoadFailed": "Не удалось загрузить настройку. Повторите попытку, чтобы проверить её состояние.", "codexAuth.mainHardLockSaveFailed": "Не удалось подтвердить сохранение. Перезагрузите настройку перед повторной попыткой.", "codexAuth.mainHardLockRefreshFailed": "Настройка сохранена, но состояние аккаунта не обновилось. Повторите попытку.", - "codexAuth.mainHardLockBlocked": "Заблокирован защитой при 98%", + "codexAuth.mainHardLockBlocked": "Заблокировано защитой основного аккаунта (5ч {short}%, долгий период {long}%)", "codexAuth.mainHardLockUnknown": "Защита включена · расход неизвестен", "codexAuth.mainHardLockMonitoring": "Защита включена · наблюдение", + "codexAuth.mainExternalUsageWarning": "Возможно использование вне opencodex. Блокировка может не предотвратить исчерпание квоты.", "codexAuth.mainHardLockManage": "Открыть настройку защиты", "codexAuth.ultraFastDesc": "Не даёт удалить настроенный вами уровень ultrafast при перегенерации каталога и записывает его имя в журналы запросов. Ultra Fast не добавляется в выбор моделей: вышестоящий сервис объявляет только Fast, поэтому пункт в списке предлагал бы скорость, которую канал не может обеспечить.", "codexAuth.ultraFastLoadFailed": "Не удалось прочитать настройку Ultra Fast.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 0a548e6179a..b954a474aa5 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2312,19 +2312,20 @@ export const tr: Record = { "codexAuth.creditsToggleFailed": "Codex kredileri ayarı değiştirilemedi", "codexAuth.refreshQuota": "Kotaları yenile", "codexAuth.ultraFastTitle": "Ultra Fast hizmet katmanı", - "codexAuth.mainHardLockTitle": "Ana hesabı %98’de durdur", - "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.mainHardLockTitle": "Ana hesabı engelle (5sa {short}%, uzun {long}%)", + "codexAuth.mainHardLockDesc": "5 saatlik pencerede {short}%, haftalık pencerede {long}% kullanımda engeller (aylık hesaplarda aylık). Engelleyen tüm pencereler eşik altına inince açılır. Koruma varsayılan olarak açıktır.", + "codexAuth.mainHardLockConfirmTitle": "Ana hesap koruması açılsın mı (5sa {short}%, uzun {long}%)?", "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ç", - "codexAuth.mainHardLockEnabled": "%98 koruması açık.", - "codexAuth.mainHardLockDisabled": "%98 koruması kapalı. Diğer hesap sınırları geçerliliğini korur.", + "codexAuth.mainHardLockEnabled": "Koruma açık (5sa {short}%, uzun {long}%).", + "codexAuth.mainHardLockDisabled": "Ana hesap koruması kapalı. Diğer hesap sınırları geçerlidir.", "codexAuth.mainHardLockLoadFailed": "Ayar yüklenemedi. Güncel durumu kontrol etmek için yeniden deneyin.", "codexAuth.mainHardLockSaveFailed": "Kayıt doğrulanamadı. Yeniden denemeden önce ayarı tekrar yükleyin.", "codexAuth.mainHardLockRefreshFailed": "Ayar kaydedildi ancak hesap durumu yenilenemedi. Yeniden deneyin.", - "codexAuth.mainHardLockBlocked": "%98 koruması nedeniyle engellendi", + "codexAuth.mainHardLockBlocked": "Ana hesap korumasıyla engellendi (5sa {short}%, uzun {long}%)", "codexAuth.mainHardLockUnknown": "Koruma açık · kullanım bilinmiyor", "codexAuth.mainHardLockMonitoring": "Koruma açık · izleniyor", + "codexAuth.mainExternalUsageWarning": "opencodex dışında kullanım olabilir. Engel, kotanın tükenmesini önlemeyebilir.", "codexAuth.mainHardLockManage": "Koruma ayarını göster", "codexAuth.ultraFastDesc": "Kendi yapılandırdığınız ultrafast hizmet katmanının katalog yeniden oluşturulurken silinmesini önler ve istek günlüklerinde bu katmanın adını yazar. Ultra Fast’i model seçicisine eklemez: üst kaynak yalnızca Fast duyurur, bu yüzden bir satır eklemek hattın veremeyeceği bir hızı seçtirmek olurdu.", "codexAuth.ultraFastLoadFailed": "Ultra Fast ayarı okunamadı.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index b95a25a9001..0248bc02aed 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2316,19 +2316,20 @@ export const vi: Record = { "codexAuth.creditsToggleFailed": "Không thể thay đổi cài đặt tín dụng Codex", "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": "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.mainHardLockTitle": "Chặn tài khoản chính (5h {short}%, dài hạn {long}%)", + "codexAuth.mainHardLockDesc": "Chặn khi cửa sổ 5h đạt {short}% hoặc tuần đạt {long}% (tháng với tài khoản chỉ có hạn tháng). Tự mở khi mọi cửa sổ chặn xuống dưới ngưỡng. Bảo vệ bật mặc định.", + "codexAuth.mainHardLockConfirmTitle": "Bật bảo vệ tài khoản chính (5h {short}%, dài hạn {long}%)?", "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ệ", - "codexAuth.mainHardLockEnabled": "Bảo vệ 98% đang bật.", - "codexAuth.mainHardLockDisabled": "Bảo vệ 98% đang tắt. Các giới hạn tài khoản khác vẫn được áp dụng.", + "codexAuth.mainHardLockEnabled": "Bảo vệ đang bật (5h {short}%, dài hạn {long}%).", + "codexAuth.mainHardLockDisabled": "Bảo vệ tài khoản chính đang tắt. Các giới hạn khác vẫn áp dụng.", "codexAuth.mainHardLockLoadFailed": "Không thể tải cài đặt này. Hãy thử lại để kiểm tra trạng thái hiện tại của nó.", "codexAuth.mainHardLockSaveFailed": "Không thể xác nhận việc lưu. Tải lại cài đặt trước khi thử lại.", "codexAuth.mainHardLockRefreshFailed": "Cài đặt đã được lưu, nhưng không thể làm mới trạng thái tài khoản. Vui lòng thử lại.", - "codexAuth.mainHardLockBlocked": "Bị khóa bởi bảo vệ 98%", + "codexAuth.mainHardLockBlocked": "Bị chặn bởi bảo vệ tài khoản chính (5h {short}%, dài hạn {long}%)", "codexAuth.mainHardLockUnknown": "Bảo vệ đang bật · lượng sử dụng chưa rõ", "codexAuth.mainHardLockMonitoring": "Bảo vệ đang bật · đang theo dõi", + "codexAuth.mainExternalUsageWarning": "Có thể có sử dụng ngoài opencodex. Việc chặn có thể không ngăn hết hạn mức.", "codexAuth.mainHardLockManage": "Xem thiết lập bảo vệ", "codexAuth.ultraFastDesc": "Ngăn không cho dịch vụ cấp độ Ultra Fast mà bạn đã thiết lập bị loại bỏ khi catalog được tạo lại, và đưa tên nó vào các request log. Nó không thêm Ultra Fast vào bộ chọn model: upstream chỉ quảng cáo Fast, do đó mục chọn sẽ đưa ra tốc độ mà đường truyền không thể đáp ứng.", "codexAuth.ultraFastLoadFailed": "Không thể đọc thiết lập Ultra Fast.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 415f0c7d7ac..6752777be49 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1683,19 +1683,20 @@ export const zhTW: Record = { "codexAuth.creditsToggleFailed": "無法變更 Codex 額度設定", "codexAuth.refreshQuota": "重新整理額度", "codexAuth.ultraFastTitle": "Ultra Fast 服務層級", - "codexAuth.mainHardLockTitle": "主帳戶用量達 98% 時阻擋請求", - "codexAuth.mainHardLockDesc": "5 小時額度或週額度任一達到 98% 即阻擋(僅有月額度的帳戶使用月額度)。所有觸發阻擋的額度都降到 98% 以下後自動解除,保護設定仍保持開啟。預設開啟。", - "codexAuth.mainHardLockConfirmTitle": "開啟主帳戶 98% 保護?", + "codexAuth.mainHardLockTitle": "封鎖主帳戶 (5h {short}%,長期 {long}%)", + "codexAuth.mainHardLockDesc": "5h 視窗達到 {short}% 或週視窗達到 {long}% 時封鎖。僅月度帳戶使用月視窗。所有封鎖視窗低於各自門檻後自動解除,保護保持開啟。預設開啟。", + "codexAuth.mainHardLockConfirmTitle": "啟用主帳戶保護 (5h {short}%,長期 {long}%)?", "codexAuth.mainHardLockConfirmBody": "阻擋期間,主帳戶也無法使用 Luna Reserve。一般額度未用盡時,Reserve 可能不會啟用。新增帳戶與其他供應商仍可使用。進行中的請求、無法比對的鑰匙圈憑證,以及此代理之外的流量不受此保護。", "codexAuth.mainHardLockConfirm": "開啟保護", - "codexAuth.mainHardLockEnabled": "98% 保護已開啟。", - "codexAuth.mainHardLockDisabled": "98% 保護已關閉,其他帳戶限制仍然適用。", + "codexAuth.mainHardLockEnabled": "保護已開啟 (5h {short}%,長期 {long}%)。", + "codexAuth.mainHardLockDisabled": "主帳戶保護已關閉。其他帳戶限制仍然適用。", "codexAuth.mainHardLockLoadFailed": "無法載入此設定。請重試以確認目前狀態。", "codexAuth.mainHardLockSaveFailed": "無法確認是否已儲存。請重新載入設定後再試。", "codexAuth.mainHardLockRefreshFailed": "設定已儲存,但無法更新帳戶狀態。請重試。", - "codexAuth.mainHardLockBlocked": "已被 98% 保護阻擋", + "codexAuth.mainHardLockBlocked": "主帳戶保護已封鎖 (5h {short}%,長期 {long}%)", "codexAuth.mainHardLockUnknown": "保護已開啟 · 用量未知", "codexAuth.mainHardLockMonitoring": "保護已開啟 · 監測中", + "codexAuth.mainExternalUsageWarning": "可能存在 opencodex 以外的使用。封鎖可能無法避免額度耗盡。", "codexAuth.mainHardLockManage": "查看保護設定", "codexAuth.ultraFastDesc": "讓你自行設定的 ultrafast 服務層級在重新產生目錄時不被移除,並在請求記錄中寫下該層級名稱。它不會把 Ultra Fast 加入模型選擇器:上游只公布 Fast,選擇器出現該項等於讓使用者挑一個實際無法提供的速度。", "codexAuth.ultraFastLoadFailed": "無法讀取 Ultra Fast 設定。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 5240a9fefd8..90d200bca96 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1767,19 +1767,20 @@ export const zh: Record = { "codexAuth.creditsToggleFailed": "无法更改 Codex 额度设置", "codexAuth.refreshQuota": "刷新额度", "codexAuth.ultraFastTitle": "Ultra Fast 服务层级", - "codexAuth.mainHardLockTitle": "主账户用量达 98% 时阻止请求", - "codexAuth.mainHardLockDesc": "5 小时额度或周额度任一达到 98% 即阻止(仅有月额度的账户使用月额度)。所有触发阻止的额度都降到 98% 以下后自动解除,保护设置仍保持开启。默认开启。", - "codexAuth.mainHardLockConfirmTitle": "开启主账户 98% 保护?", + "codexAuth.mainHardLockTitle": "阻止主账户 (5h {short}%,长期 {long}%)", + "codexAuth.mainHardLockDesc": "5h 窗口达到 {short}% 或周窗口达到 {long}% 时阻止。仅月度账户使用月窗口。所有阻止窗口低于各自阈值后自动解除,保护保持开启。默认开启。", + "codexAuth.mainHardLockConfirmTitle": "启用主账户保护 (5h {short}%,长期 {long}%)?", "codexAuth.mainHardLockConfirmBody": "阻止期间,主账户也无法使用 Luna Reserve。普通额度未耗尽时,Reserve 可能不会激活。附加账户和其他提供商仍可使用。正在进行的请求、无法匹配的钥匙串凭据以及此代理之外的流量不受此保护。", "codexAuth.mainHardLockConfirm": "开启保护", - "codexAuth.mainHardLockEnabled": "98% 保护已开启。", - "codexAuth.mainHardLockDisabled": "98% 保护已关闭,其他账户限制仍然适用。", + "codexAuth.mainHardLockEnabled": "保护已开启 (5h {short}%,长期 {long}%)。", + "codexAuth.mainHardLockDisabled": "主账户保护已关闭。其他账户限制仍然适用。", "codexAuth.mainHardLockLoadFailed": "无法加载此设置。请重试以确认当前状态。", "codexAuth.mainHardLockSaveFailed": "无法确认是否已保存。请重新加载设置后再试。", "codexAuth.mainHardLockRefreshFailed": "设置已保存,但无法刷新账户状态。请重试。", - "codexAuth.mainHardLockBlocked": "已被 98% 保护阻止", + "codexAuth.mainHardLockBlocked": "主账户保护已阻止 (5h {short}%,长期 {long}%)", "codexAuth.mainHardLockUnknown": "保护已开启 · 用量未知", "codexAuth.mainHardLockMonitoring": "保护已开启 · 监测中", + "codexAuth.mainExternalUsageWarning": "可能存在 opencodex 之外的使用。阻止可能无法避免额度耗尽。", "codexAuth.mainHardLockManage": "查看保护设置", "codexAuth.ultraFastDesc": "让你自己配置的 ultrafast 服务层级在重新生成目录时不被删除,并在请求日志中记录该层级名称。它不会把 Ultra Fast 加入模型选择器:上游只公布 Fast,选择器中出现该项等于让用户选择一个实际无法提供的速度。", "codexAuth.ultraFastLoadFailed": "无法读取 Ultra Fast 设置。", diff --git a/gui/tests/main-account-hard-lock-setting.test.tsx b/gui/tests/main-account-hard-lock-setting.test.tsx index a510f8ede2e..24e79f7e574 100644 --- a/gui/tests/main-account-hard-lock-setting.test.tsx +++ b/gui/tests/main-account-hard-lock-setting.test.tsx @@ -267,15 +267,18 @@ function mainAccount(state: MainAccountHardLockStatus["state"]): CodexAccountEnt quotaAutoRefresh: { fiveHourAvailable: false, weeklyAvailable: false, fiveHourEnabled: false, weeklyEnabled: false }, mainAccountHardLock: { enabled: state !== "off", state } }; } -function MainCard({ state }: { state: MainAccountHardLockStatus["state"] }) { - return {}} onTogglePause={() => {}} pauseUpdatingId={null} pauseBusy={false} onPriorityChange={() => {}} priorityUpdatingId={null} onAutoSwitchThresholdChange={async () => true} autoSwitchDisabled={false} switchingId={null} onOpenReset={() => {}} />; } test.each([ - ["blocked", "Blocked by 98% protection", false], + ["blocked", "Blocked by main-account protection (5h 90%, long 98%)", false], ["unknown", "Protection on · usage unknown", true], ["ready", "Protection on · monitoring", true], ] as const)("main card uses server %s state, not rounded weekly usage", async (state, label, canSwitch) => { @@ -287,6 +290,17 @@ test.each([ expect(testWindow.location.hash).toBe("#codex-set"); }); +test.each([true, false])("outside-usage warning follows the server report (present: %p)", async present => { + const warning = "Possible usage outside opencodex"; + const setting = await mount((async () => response({ codexMainAccountHardLock: true, mainAccountHardLock: { + enabled: true, state: "ready", thresholds: { short: 90, long: 98 }, ...(present ? { externalUsage } : {}) } })) as typeof fetch); + expect(setting.textContent?.includes(warning)).toBe(present); + await act(async () => { root?.unmount(); }); + root = null; + const card = await mount((async () => response({})) as typeof fetch, ); + expect(card.textContent?.includes(warning)).toBe(present); +}); + test("same-page manage opens Advanced; save refreshes the one injected account controller", async () => { let enabled = true; let accountReads = 0; diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c22aabc3c0f..7f9cfe8d685 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,6 +2,8 @@ "version": 1, "root": "tests", "explicit": { + "main-account-hard-lock-thresholds.test.ts": "codex-integration", + "main-account-external-usage.test.ts": "codex-integration", "jev-decision-model-config.test.ts": "routing", "cli-combo-partial-update.test.ts": "cli", "desktop-app-server-shim.test.ts": "clients", diff --git a/src/cli/index.ts b/src/cli/index.ts index f4870bbe7f9..f434fe40537 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1705,7 +1705,7 @@ async function handleStatus() { process.exit(1); } - const status = await collectStatus(); + const status = await collectStatus({ mainAccountPolicy: wantsJson }); if (wantsJson) { console.log(JSON.stringify(status.json, null, 2)); return; diff --git a/src/cli/status-oauth.ts b/src/cli/status-oauth.ts index 8682a9608a1..7775c503027 100644 --- a/src/cli/status-oauth.ts +++ b/src/cli/status-oauth.ts @@ -72,6 +72,11 @@ export function formatOAuthHealthForStatus( parts.push(CODEX_HEALTH_MANAGEMENT_API_UNAVAILABLE_NOTE); break; } + const policy = report.mainAccountHardLock; + if (policy) { + const warning = policy.externalUsage ? "; possible usage outside opencodex — the lock may not prevent exhaustion" : ""; + parts.push(`Codex main-account protection: ${policy.state} (5h ≥ ${policy.thresholds.short}%, weekly/monthly ≥ ${policy.thresholds.long}%)${warning}`); + } const oauthBlock = formatEntryBlock(report.entries); if (oauthBlock) parts.push(oauthBlock); return parts.join("\n"); diff --git a/src/cli/status.ts b/src/cli/status.ts index d1c5c1fb417..55759c63821 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -1,3 +1,4 @@ +import type { CodexMainAccountPolicyHealth } from "../oauth/health"; import { durableBunRuntime } from "../lib/bun-runtime"; import { existsSync, readFileSync } from "node:fs"; import { codexAutoStartEnabled, getConfigPath, readConfigDiagnostics } from "../config"; @@ -104,6 +105,7 @@ export type CliRemoteHubStatus = { }; export type CliStatusJson = { + mainAccountHardLock?: CodexMainAccountPolicyHealth; schemaVersion: 1; /** * This machine's topology role, named rather than inferred (#4236). @@ -676,7 +678,8 @@ export function missingCodexCatalogLines(missingCatalogPath: string | null): str ]; } -export async function collectStatus(): Promise { +/** `mainAccountPolicy`: only `--json` asks; the human report reads the same accounts once via OAuth health. */ +export async function collectStatus(options: { mainAccountPolicy?: boolean } = {}): Promise { const configDiagnostics = readConfigDiagnostics(); const config = configDiagnostics.config; const claudeDesktop = { @@ -714,6 +717,9 @@ export async function collectStatus(): Promise { const live = await findLiveProxy({ configFn: () => ({ port: config.port, hostname: config.hostname }), }); + const mainAccountHardLock = options.mainAccountPolicy && live + ? (await (await import("../oauth/health")).fetchCodexHealthFromLiveProxy(undefined, async () => live)).mainAccountHardLock + : undefined; const pidFile = readPid(); // Preserve an authoritative null from orphan/legacy liveness — do not restore pidFile. const pid = resolveStatusPid(live, pidFile); @@ -865,6 +871,7 @@ export async function collectStatus(): Promise { proxyLabel, healthLabel: health.label, json: { + ...(mainAccountHardLock ? { mainAccountHardLock } : {}), schemaVersion: 1, runtimeRole: config.runtimeRole ?? "standalone", proxy: { diff --git a/src/codex/auth-api/account-list.ts b/src/codex/auth-api/account-list.ts index 7930d5fe00e..7390356ee63 100644 --- a/src/codex/auth-api/account-list.ts +++ b/src/codex/auth-api/account-list.ts @@ -1,3 +1,4 @@ +import { getMainAccountExternalUsageWarning, type MainAccountExternalUsageWarning } from "../main-account-external-usage"; import { codexCreditsDtoField, hasCodexCreditsObservation, pruneCodexCredits } from "../credits"; import type { CodexCredits } from "../credits"; import { getMainChatgptAccountId } from "../auth-collision"; @@ -15,7 +16,7 @@ import { codexPlanValue, isThirtyDayOnlyCodexPlan } from "../plan"; import { isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state"; import { getValidMainAccountToken, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID } from "../main-account"; import { captureConfigGeneration } from "../../lib/state-store-sweeper"; -import { captureMainAccountIdentityGeneration, getMainAccountCredentialPresence, getMainAccountInfoCache, isMainAccountIdentityGenerationLive } from "../main-account-cache"; +import { captureMainAccountIdentityGeneration, getMainAccountCredentialPresence, getMainAccountInfoCache, getObservedMainQuotaIdentityKey, isMainAccountIdentityGenerationLive } from "../main-account-cache"; import type { CodexQuotaRefreshOutcome } from "../quota-refresh-outcome"; import { getMainAccountHardLockStatus } from "../main-account-hard-lock"; import type { MainAccountHardLockStatus } from "../main-account-hard-lock"; @@ -183,7 +184,7 @@ export interface CodexAuthAccountDto { healthAction?: string; quotaProbeSkipped?: true; quotaRefresh?: CodexQuotaRefreshOutcome; - mainAccountHardLock?: MainAccountHardLockStatus; + mainAccountHardLock?: MainAccountHardLockStatus & { externalUsage?: MainAccountExternalUsageWarning }; } export interface FreshPoolPlanUpdate { @@ -371,7 +372,8 @@ export async function listCodexAuthAccountsSnapshot( logLabel: "main", isMain: true, paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), - mainAccountHardLock: getMainAccountHardLockStatus(runtimeConfig), + mainAccountHardLock: { ...getMainAccountHardLockStatus(runtimeConfig), + externalUsage: getMainAccountExternalUsageWarning(getObservedMainQuotaIdentityKey()) }, priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), autoSwitchThresholdOverride: getCodexAccountAutoSwitchThresholdOverride(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), hasCredential: hasMainCredential, diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 0a6a4eaaeb0..bf269fd108f 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1,3 +1,4 @@ +import { noteMainAccountActivity } from "./main-account-external-usage"; import { codexAccountPriorityFailbackEnabled } from "./account-priority"; import type { PoolQuotaWriter } from "./quota-types"; import { createHash, timingSafeEqual } from "node:crypto"; @@ -69,7 +70,7 @@ import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { captureConfigGeneration } from "../lib/state-store-sweeper"; import { extractAccountId, extractEmail } from "../oauth/chatgpt"; import { - MAIN_ACCOUNT_HARD_LOCK_PERCENT, + resolveMainAccountHardLockThresholds, getMainAccountHardLockStatus, isMainAccountHardLockEnabled, isMainAccountHardLocked, @@ -510,11 +511,11 @@ export class CodexAccountCooldownError extends Error { export class CodexMainAccountHardLockError extends CodexAccountCooldownError { readonly resetAt?: number; - constructor(resetAt?: number) { + constructor(resetAt?: number, thresholds = resolveMainAccountHardLockThresholds(undefined)) { super(MAIN_CODEX_ACCOUNT_ID, resetAt ?? 0); this.name = "CodexMainAccountHardLockError"; this.resetAt = resetAt; - this.message = `Codex main account is blocked by the ${MAIN_ACCOUNT_HARD_LOCK_PERCENT}% main-account quota policy.` + this.message = `Codex main account is blocked by the main-account quota policy (5h ≥ ${thresholds.short}%, weekly ≥ ${thresholds.long}%).` + " Choose another account, wait for quota to reset, or disable codexMainAccountHardLock in Settings."; } } @@ -577,7 +578,7 @@ export class CodexRecoveryWithheldError extends CodexAccountCooldownError { } export type CodexAuthPolicyConfig = Readonly>; interface CodexAuthMaterializationOptions { @@ -700,10 +701,14 @@ export function unwrapUpstreamRetryEvidenceError(error: unknown): unknown { return error; } -function assertMainAccountPolicy(config: Pick | undefined): void { - if (!config) return; - const status = getMainAccountHardLockStatus(config); - if (status.state === "blocked") throw new CodexMainAccountHardLockError(status.resetAt); +function assertMainAccountPolicy(config: Pick | undefined): void { + if (config) { + const status = getMainAccountHardLockStatus(config); + if (status.state === "blocked") throw new CodexMainAccountHardLockError(status.resetAt, status.thresholds); + } + // Only an admitted request is opencodex's own use of the main account. Counting a refused one + // would hide outside usage from the warning exactly while the lock is holding. + noteMainAccountActivity(); } /** No auth-file I/O: an unsigned claim alone never identifies a caller as stored main. */ diff --git a/src/codex/main-account-external-usage.ts b/src/codex/main-account-external-usage.ts new file mode 100644 index 00000000000..cee31e1bd81 --- /dev/null +++ b/src/codex/main-account-external-usage.ts @@ -0,0 +1,62 @@ +/** Advisory fresh-observation detector. No persistence, timers, or merged quota reads. */ +export const EXTERNAL_USAGE_QUIET_MS = 30 * 60_000; +export const EXTERNAL_USAGE_TTL_MS = 6 * 60 * 60_000; +export type FreshWindow = { kind: "short" | "long"; percent: number; resetAtMs: number }; +export type MainAccountExternalUsageWarning = { + window: "short" | "long"; + fromPercent: number; + toPercent: number; + observedAt: number; +}; +type Reading = { percent: number; resetAtMs: number; observedAt: number; + warning?: MainAccountExternalUsageWarning }; +let baseline: { identityKey: string; perWindow: Partial> } | undefined; +let lastActivity = -Infinity; + +export function noteMainAccountActivity(now = Date.now()): void { + lastActivity = now; +} + +/** Only validated usage with a known reset from this observation reaches the baseline. */ +export function observeMainAccountUsage(identityKey: string, windows: FreshWindow[], now = Date.now()): void { + if (baseline?.identityKey !== identityKey) baseline = { identityKey, perWindow: {} }; + for (const window of windows) { + if (!Number.isFinite(window.percent) || window.percent < 0 || window.percent > 100 + || !Number.isFinite(window.resetAtMs) || window.resetAtMs <= 0) continue; + const previous = baseline.perWindow[window.kind]; + const sameEpisode = previous?.resetAtMs === window.resetAtMs; + let warning = sameEpisode ? previous?.warning : undefined; + if (sameEpisode && previous && window.percent - previous.percent >= 1 + && lastActivity < previous.observedAt - EXTERNAL_USAGE_QUIET_MS) { + warning = { window: window.kind, fromPercent: previous.percent, + toPercent: window.percent, observedAt: now }; + } + baseline.perWindow[window.kind] = { percent: window.percent, resetAtMs: window.resetAtMs, + observedAt: now, ...(warning ? { warning } : {}) }; + } +} + +export function forgetMainAccountUsage(): void { + baseline = undefined; +} + +export function getMainAccountExternalUsageWarning( + identityKey: string | undefined, now = Date.now(), +): MainAccountExternalUsageWarning | undefined { + if (!identityKey || baseline?.identityKey !== identityKey) return undefined; + for (const kind of ["short", "long"] as const) { + const reading = baseline.perWindow[kind]; + if (!reading?.warning) continue; + if (now >= reading.resetAtMs || now - reading.warning.observedAt >= EXTERNAL_USAGE_TTL_MS) { + delete reading.warning; + continue; + } + return { ...reading.warning }; + } + return undefined; +} + +export function resetMainAccountExternalUsageForTests(): void { + forgetMainAccountUsage(); + lastActivity = -Infinity; +} diff --git a/src/codex/main-account-hard-lock.ts b/src/codex/main-account-hard-lock.ts index 9216ddffffc..fda12552410 100644 --- a/src/codex/main-account-hard-lock.ts +++ b/src/codex/main-account-hard-lock.ts @@ -1,6 +1,6 @@ import type { OcxConfig } from "../types"; import { getMainPolicyQuota } from "./quota"; -import { MAIN_ACCOUNT_HARD_LOCK_PERCENT } from "./quota-types"; +import { MAIN_ACCOUNT_HARD_LOCK_PERCENT, MAIN_ACCOUNT_HARD_LOCK_SHORT_PERCENT, MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT } from "./quota-types"; export { MAIN_ACCOUNT_HARD_LOCK_PERCENT }; @@ -9,9 +9,19 @@ export interface MainAccountHardLockStatus { state: "off" | "unknown" | "ready" | "blocked"; /** Unix milliseconds; absent when a blocking observation has no future reset. */ resetAt?: number; + thresholds: { short: number; long: number }; + window?: "short" | "long"; } -type PolicyConfig = Pick; +type PolicyConfig = Pick; + +export function resolveMainAccountHardLockThresholds(config: PolicyConfig | undefined): { short: number; long: number } { + const valid = (value: unknown, fallback: number): number => typeof value === "number" + && Number.isInteger(value) && value >= MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT && value <= 100 ? value : fallback; + const long = valid(config?.codexMainAccountHardLockThresholds?.long, MAIN_ACCOUNT_HARD_LOCK_PERCENT); + const short = valid(config?.codexMainAccountHardLockThresholds?.short, MAIN_ACCOUNT_HARD_LOCK_SHORT_PERCENT); + return { short: Math.min(short, long), long }; +} /** * Whether the main-account hard lock applies to this config (#5694). @@ -31,11 +41,11 @@ function resetTimestamp(value: number | undefined): number | undefined { return value < 10_000_000_000 ? value * 1000 : value; } -type WindowReading = { percent: number | undefined; resetAt: number | undefined }; +type WindowReading = { kind: "short" | "long"; 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: + * one at its configured threshold 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. */ @@ -44,9 +54,9 @@ function governingWindows(quota: NonNullable validPercent(w.percent) && w.percent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT); + const blocking = windows.filter(w => validPercent(w.percent) && w.percent >= thresholds[w.kind]); if (blocking.length > 0) { // The lock holds until every blocking window reads lower or is authoritatively absent, 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; fresh lower usage or validated WHAM absence 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 } : {}) }; + return { enabled: true, state: "blocked", thresholds, window: blocking[0]!.kind, ...(resetAt !== undefined ? { 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" }; + if (windows.some(w => !validPercent(w.percent))) return { enabled: true, state: "unknown", thresholds }; + return { enabled: true, state: "ready", thresholds }; } export function isMainAccountHardLocked(config: PolicyConfig, now = Date.now()): boolean { diff --git a/src/codex/quota-types.ts b/src/codex/quota-types.ts index f6e6a411412..e15ad5befc2 100644 --- a/src/codex/quota-types.ts +++ b/src/codex/quota-types.ts @@ -1,12 +1,8 @@ /** Quota wire/storage shapes. This leaf must not import credential or config owners. */ -/** - * Observed usage at which new identity-matched main-account requests are refused (#5694). - * - * The policy is on by default, so this constant is what every installation without an explicit - * `codexMainAccountHardLock: false` admits against. 98 leaves one point of headroom under the - * exhausted reading Codex Desktop already treats as its own disabled send button. - */ +/** Long-window default; explicit main-account hard-lock thresholds may override it. */ export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 98; +export const MAIN_ACCOUNT_HARD_LOCK_SHORT_PERCENT = 90; +export const MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT = 80; /** * How recently a 100% burst reading must have been observed to exclude an account when it diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 49c761110d9..289f0ed7a9a 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -1,3 +1,4 @@ +import { forgetMainAccountUsage, observeMainAccountUsage, type FreshWindow } from "./main-account-external-usage"; import { closeSync, constants as fsConstants, existsSync, fstatSync, openSync, readSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; @@ -10,7 +11,7 @@ import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaW import { CodexQuotaHistory, QUOTA_HISTORY_LIMITS, type QuotaHistoryWindow } from "./quota-history"; import { isPoolQuotaWriterLive, poolQuotaHistoryIdentity } from "./account-store"; -import { CODEX_EXHAUSTED_USAGE_PERCENT, MAIN_ACCOUNT_HARD_LOCK_PERCENT, resetAtToMs } from "./quota-types"; +import { CODEX_EXHAUSTED_USAGE_PERCENT, MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT, resetAtToMs } from "./quota-types"; import type { PoolQuotaWriter, StoredAccountQuota, WhamUsageResponse, WhamUsageWindow } from "./quota-types"; export type { StoredAccountQuota, WhamUsageResponse } from "./quota-types"; @@ -260,7 +261,7 @@ function assignCarriedShort( const existingShortPercent = existing.shortPercent; const preserveBlockingEvidence = policyEvidence && finitePercent(existingShortPercent) - && existingShortPercent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT + && existingShortPercent >= MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT && existingShortPercent <= 100; if (!preserveBlockingEvidence && shortResetHasElapsed(existing.shortResetAt, now)) return; if (existing.shortPercent !== undefined) next.shortPercent = existing.shortPercent; @@ -315,6 +316,19 @@ export function setAccountQuotaFromParsed( const next = mergeAccountQuota(quota, legacyExisting, updatedAt); accountQuota.set(accountId, next); if (isMain) { + if (mainWriter && policyQuota && snapshotHasUsage(policyQuota)) { + const windows: FreshWindow[] = []; + const add = (kind: FreshWindow["kind"], percent: number | undefined, reset: number | undefined) => { + if (finitePercent(percent) && percent >= 0 && percent <= 100 + && typeof reset === "number" && Number.isFinite(reset) && reset > 0) { + windows.push({ kind, percent, resetAtMs: resetAtToMs(reset) }); + } + }; + add("short", policyQuota.shortPercent, policyQuota.shortResetAt); + if (policyQuota.monthlyIsPrimaryWindow === true) add("long", policyQuota.monthlyPercent, policyQuota.monthlyResetAt); + else add("long", policyQuota.weeklyPercent, policyQuota.weeklyResetAt); + observeMainAccountUsage(mainWriter.identityKey, windows, updatedAt); + } const policyExisting = mainWriter && mainPolicyQuota?.identityKey === mainWriter.identityKey ? mainPolicyQuota.quota : undefined; @@ -371,7 +385,7 @@ function mergeAccountQuota( && quota.weeklyPercent === undefined && quota.monthlyIsPrimaryWindow !== true && finitePercent(existingWeeklyPercent) - && existingWeeklyPercent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT + && existingWeeklyPercent >= MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT && existingWeeklyPercent <= 100; if (snapshotHasWeekly(quota) && !preserveKnownWeekly) { if (quota.weeklyPercent !== undefined) next.weeklyPercent = quota.weeklyPercent; @@ -404,7 +418,7 @@ function mergeAccountQuota( const preserveKnownShort = policyEvidence && quota.shortPercent === undefined && finitePercent(existingShortPercent) - && existingShortPercent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT + && existingShortPercent >= MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT && existingShortPercent <= 100; if (snapshotHasShort(quota) && !preserveKnownShort) { if (quota.shortPercent !== undefined) { @@ -778,6 +792,7 @@ function forgetCodexQuotaBaseline(accountId?: string): void { } export function clearAccountQuota(accountId?: string): void { + if (!accountId || accountId === MAIN_CODEX_ACCOUNT_ID) forgetMainAccountUsage(); if (accountId) hydrateAccountQuotasFromDisk(); quotaHistory.clear(accountId); if (accountId) { diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 2248a431574..d545169d5a3 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -1,3 +1,4 @@ +import { MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT } from "../../codex/quota-types"; import * as z from "zod/v4"; import { compactionRecoverySchema } from "./compaction-recovery"; import { blockedModelRedirectsSchema } from "./blocked-model-redirects"; @@ -191,6 +192,10 @@ export const configSchema = z.object({ // Default-on policy (#5694): absence and malformed hand edits both mean "on", and only an // explicit `false` written by the settings PUT opts out. codexMainAccountHardLock: z.boolean().optional().catch(undefined), + codexMainAccountHardLockThresholds: z.object({ + short: z.number().int().min(MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT).max(100).optional().catch(undefined), + long: z.number().int().min(MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT).max(100).optional().catch(undefined), + }).optional().catch(undefined), // Future versions remain opaque through passthrough-compatible whole-config saves. // Only version 1 grants deletion authority in the rebase path. configRebaseProvenance: z.unknown().optional(), diff --git a/src/oauth/health.ts b/src/oauth/health.ts index 7f596f13464..12ac4930ced 100644 --- a/src/oauth/health.ts +++ b/src/oauth/health.ts @@ -1,3 +1,5 @@ +import type { MainAccountHardLockStatus } from "../codex/main-account-hard-lock"; +import type { MainAccountExternalUsageWarning } from "../codex/main-account-external-usage"; import { getCodexAccountHealthSnapshot, type CodexCooldownSource } from "../codex/routing"; import { getAnthropicAccountHealthSnapshot } from "./anthropic-routing"; import { isAccountNeedsReauth } from "../codex/account-runtime-state"; @@ -353,7 +355,10 @@ export function collectOAuthHealthEntries( return entries; } +export type CodexMainAccountPolicyHealth = MainAccountHardLockStatus & { externalUsage?: MainAccountExternalUsageWarning }; + type ProxyCodexAccountHealth = { + mainAccountHardLock?: unknown; id: string; health?: OAuthAccountHealth; needsReauth?: boolean; @@ -372,11 +377,37 @@ function coerceRemoteAccountHealth( } type LiveProxyCodexHealthResult = { + mainAccountHardLock?: CodexMainAccountPolicyHealth; source: CodexHealthSource; entries: OAuthHealthEntry[] | null; }; -async function fetchCodexHealthFromLiveProxy( +/** Whitelisted projection: never forward account metadata or arbitrary management fields. */ +export function projectMainAccountPolicyHealth(value: unknown): CodexMainAccountPolicyHealth | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const raw = value as Record; + const thresholds = raw.thresholds as { short?: unknown; long?: unknown } | undefined; + const percent = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 100; + if (typeof raw.enabled !== "boolean" || typeof raw.state !== "string" + || !(raw.enabled ? ["unknown", "ready", "blocked"] : ["off"]).includes(raw.state) + || !thresholds || !percent(thresholds.short) || !percent(thresholds.long) + || !Number.isInteger(thresholds.short) || !Number.isInteger(thresholds.long) + || thresholds.short < 80 || thresholds.short > thresholds.long) return undefined; + const result: CodexMainAccountPolicyHealth = { enabled: raw.enabled, + state: raw.state as MainAccountHardLockStatus["state"], thresholds: { short: thresholds.short, long: thresholds.long } }; + if (raw.window === "short" || raw.window === "long") result.window = raw.window; + if (typeof raw.resetAt === "number" && Number.isFinite(raw.resetAt)) result.resetAt = raw.resetAt; + const warning = raw.externalUsage as Partial | undefined; + if (warning && (warning.window === "short" || warning.window === "long") + && percent(warning.fromPercent) && percent(warning.toPercent) + && typeof warning.observedAt === "number" && Number.isFinite(warning.observedAt)) { + result.externalUsage = { window: warning.window, fromPercent: warning.fromPercent, + toPercent: warning.toPercent, observedAt: warning.observedAt }; + } + return result; +} + +export async function fetchCodexHealthFromLiveProxy( fetchImpl: typeof fetch | undefined = undefined, findLiveProxyImpl: typeof findLiveProxy = findLiveProxy, readRuntimePortImpl: typeof readRuntimePort = readRuntimePort, @@ -404,7 +435,10 @@ async function fetchCodexHealthFromLiveProxy( if (!account?.id || typeof account.id !== "string") continue; pushEntry(entries, "codex", account.id, coerceRemoteAccountHealth(account)); } - return { source: "management-api", entries }; + const mainAccountHardLock = projectMainAccountPolicyHealth( + json.accounts.find(account => account?.id === MAIN_CODEX_ACCOUNT_ID)?.mainAccountHardLock, + ); + return { source: "management-api", entries, ...(mainAccountHardLock ? { mainAccountHardLock } : {}) }; } catch { return { source: "management-api-unavailable", entries: null }; } @@ -418,6 +452,7 @@ export type CodexHealthSource = | "management-api-unavailable"; export type OAuthCliHealthReport = { + mainAccountHardLock?: CodexMainAccountPolicyHealth; entries: OAuthHealthEntry[]; codexHealthSource: CodexHealthSource; }; @@ -450,7 +485,8 @@ export async function collectOAuthHealthEntriesForCli( ); if (remote.entries) { for (const entry of remote.entries) entries.push(entry); - return { entries, codexHealthSource: "management-api" }; + return { entries, codexHealthSource: "management-api", + ...(remote.mainAccountHardLock ? { mainAccountHardLock: remote.mainAccountHardLock } : {}) }; } return { entries, codexHealthSource: remote.source }; } diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 2bef4bdb9e0..7e4da177eb3 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -1,3 +1,7 @@ +import { MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT } from "../../codex/quota-types"; +import { resolveMainAccountHardLockThresholds } from "../../codex/main-account-hard-lock"; +import { getMainAccountExternalUsageWarning } from "../../codex/main-account-external-usage"; +import { getObservedMainQuotaIdentityKey } from "../../codex/main-account-cache"; import { compactionRoutingSchema, memoryModelsSchema } from "../../config/schema/leaf-validators"; import { compactionRecoverySchema } from "../../config/schema/compaction-recovery"; import { captureConfigTopLevelRollback } from "../../config/rebase-provenance"; @@ -363,7 +367,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise key !== "short" && key !== "long") + || Object.values(value).some(percent => typeof percent !== "number" || !Number.isInteger(percent) + || percent < MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT || percent > 100)) { + return jsonResponse({ error: "codexMainAccountHardLockThresholds requires integer short/long percentages from 80 to 100" }, 400); + } + // A partial body updates one window and keeps the other stored value. + hardLockThresholds = { ...config.codexMainAccountHardLockThresholds, + ...(value as NonNullable) }; + const defaults = resolveMainAccountHardLockThresholds(undefined); + if ((hardLockThresholds.short ?? defaults.short) > (hardLockThresholds.long ?? defaults.long)) { + return jsonResponse({ error: "codexMainAccountHardLockThresholds short must not exceed long" }, 400); + } + } if (body.codexDesktopAuthless !== undefined && typeof body.codexDesktopAuthless !== "boolean") { return jsonResponse({ error: "codexDesktopAuthless boolean is required" }, 400); } @@ -586,6 +611,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise; /** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */ diff --git a/structure/config.md b/structure/config.md index 7f416405eed..984432d6bb2 100644 --- a/structure/config.md +++ b/structure/config.md @@ -516,7 +516,7 @@ described in [Responses transport](transports/responses.md), not upstream policy ## Codex Pool low-quota protection -`codexPool.lowQuotaProtection` is opt-in, requires a 1–100 threshold and a selected action/window when enabled, covers pool accounts only and is independent of proactive switching and the main account’s 98% hard lock. `src/codex/low-quota-protection.ts` pauses in live `pausedCodexAccountIds` before selection, then coalesces a deferred config save with bounded retry and shutdown flush. Fresh accepted observations reach `src/codex/low-quota-observer.ts`; credits-only and expired windows do not act. Manual resume suppresses repause across currently qualifying window episodes; a new reset boundary or below-threshold reading re-arms the policy, but never automatically resumes an account. A timed-out in-flight save remains pending until its eventual success or failure; queued work is cancelled at owner close. An unsuccessful save does not survive restart. The default alert is log-and-API only and records `logged`, not notification delivery. +`codexPool.lowQuotaProtection` is opt-in, requires a 1–100 threshold and a selected action/window when enabled, covers pool accounts only and is independent of proactive switching and the main account’s per-window hard lock (`codexMainAccountHardLockThresholds`, short/long integers 80–100 defaulting to 90/98, owned by [OpenAI accounts](providers/openai-accounts.md)). `src/codex/low-quota-protection.ts` pauses in live `pausedCodexAccountIds` before selection, then coalesces a deferred config save with bounded retry and shutdown flush. Fresh accepted observations reach `src/codex/low-quota-observer.ts`; credits-only and expired windows do not act. Manual resume suppresses repause across currently qualifying window episodes; a new reset boundary or below-threshold reading re-arms the policy, but never automatically resumes an account. A timed-out in-flight save remains pending until its eventual success or failure; queued work is cancelled at owner close. An unsuccessful save does not survive restart. The default alert is log-and-API only and records `logged`, not notification delivery. ## Management-backed CLI commands need a management plane diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 1dd78681f18..64b54f86085 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -597,4 +597,4 @@ label `anthropic-fast-downgrade` projects to `fast_downgrade`, separate from rea Cursor Claude Fast pricing applies the published Fast tuples to Opus 4.8, Opus 5 and Opus 5.5. Explicit `-fast` model IDs use the Fast tuple directly; a Cursor variant tier outcome applies the same 2x multiplier to a base model estimate. Opus 4.7 remains standard-priced because its -upstream Fast mode is unavailable. Configured model prices retain precedence over compiled rows. +upstream Fast mode is unavailable. Configured model prices retain precedence over compiled rows. Main-account protection copy uses the effective short/long thresholds from the server projection. The main account card and setting render an amber advisory for possible usage outside opencodex, including when the admission policy is off; warning detection and expiry belong to [main-account observations](providers/openai-accounts.md#main-account-policy-observations). diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index ab490244b89..537368a7945 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -291,7 +291,7 @@ The credits module beside `src/codex/quota-types.ts` retains validated WHAM cred ## Low-quota protection -`src/codex/quota.ts` sends accepted usage observations through `src/codex/low-quota-observer.ts`; credits-only updates never replay carried usage into protection, and pool WHAM/header observations reach the policy only after raw percentages pass validation and the captured credential writer is still live; clamped display bars and responses from retired credentials cannot authorize a pause. The pool-account-only [configuration policy](../config.md#codex-pool-low-quota-protection) pauses live selection immediately, defers a bounded config save, and deduplicates account/window notices. Manual resume is respected across every qualifying window already active for that account until recovery or a new reset episode. Native main keeps its separate 98% hard lock. Each server registration owns a bounded status ledger; its authenticated management route exposes only its own account ids. The default alert is a log line plus a `logged` event, with no OS notification. A pool response that could not capture a writer at dispatch (credential already replaced, or the store unreadable) fails closed the same way: its snapshot still refreshes the display cache, but it never authorizes a policy pause. Writer-free legacy and login paths are explicitly distinct from that pool contract and keep their existing observation behavior. +`src/codex/quota.ts` sends accepted usage observations through `src/codex/low-quota-observer.ts`; credits-only updates never replay carried usage into protection, and pool WHAM/header observations reach the policy only after raw percentages pass validation and the captured credential writer is still live; clamped display bars and responses from retired credentials cannot authorize a pause. The pool-account-only [configuration policy](../config.md#codex-pool-low-quota-protection) pauses live selection immediately, defers a bounded config save, and deduplicates account/window notices. Manual resume is respected across every qualifying window already active for that account until recovery or a new reset episode. Native main keeps its separate per-window hard lock. Each server registration owns a bounded status ledger; its authenticated management route exposes only its own account ids. The default alert is a log line plus a `logged` event, with no OS notification. A pool response that could not capture a writer at dispatch (credential already replaced, or the store unreadable) fails closed the same way: its snapshot still refreshes the display cache, but it never authorizes a policy pause. Writer-free legacy and login paths are explicitly distinct from that pool contract and keep their existing observation behavior. ## Bounded pool quota observations @@ -427,3 +427,23 @@ quota-cache freshness after the existing attempt backoff. Main refresh keeps its passive intent: cache bypass does not clear an inference reauthentication mark. Other prime reasons retain their existing cache rules. The split config schema degrades malformed optional values to false. Exact-account and Direct routes are unchanged. + +## Main-account policy observations + +The main-account admission policy defaults to 90% for short windows and 98% for long windows; +`codexMainAccountHardLockThresholds` permits ordered integer thresholds from 80 through 100. +Policy evidence retention uses the minimum configurable 80%, so a partial update or elapsed reset +cannot erase a reading that may block under an allowed configuration. Fresh lower usage and +validated window-absence evidence still own recovery. + +The outside-usage detector keeps a separate process-local baseline keyed by observed main identity. +Only validated fresh percentages with a known normalized reset from the current observation enter it; +carried policy windows, credits-only updates, writer-free observations and retired writers cannot +create warnings. A rise of at least one point in the same reset episode warns only when no main +activity was noted since 30 minutes before the preceding reading. Main credential admission notes +activity only after the policy check admits the request; a hard-lock refusal is not opencodex's own +use, so repeated refusals cannot hide a rise while the lock holds. Identity replacement drops all warnings; reset +replacement drops that window's warning. Main/all quota cleanup forgets the baseline. Warnings +expire at reset or six hours after detection. Main-card/settings DTOs and live CLI projections expose +only window kind, previous/current percentage and observation time; the notice says possible usage +outside opencodex because a long-running request can also explain the rise. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 95b3e318112..31d1dca99cc 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -317,8 +317,9 @@ 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%. 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 +short 90% and long `MAIN_ACCOUNT_HARD_LOCK_PERCENT` = 98%, overridden by ordered integer +`codexMainAccountHardLockThresholds` in 80..100. The 5h/short and weekly windows each govern on +their own: either one reaching its effective threshold 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 or is authoritatively absent, so `resetAt` is the latest blocking reset, omitted when any blocking window has none. In the policy snapshot a reset-only weekly observation @@ -326,7 +327,7 @@ keeps a blocking weekly tuple, mirroring the short-window rule; monthly-primary 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. -Fresh valid usage below 98%, including 0%, or validated WHAM absence retires a measured short block; +Fresh valid usage below the effective threshold, including 0%, or validated WHAM absence retires a measured short block; passing a reset timestamp alone does not. The minute sweep waits locally until the latest known blocking reset; when no future reset is known or reads remain blocked, main recovery uses the same capped 5/10/20/40/60-minute delay calculation as usage-query failures. Skipped ticks do not extend it; @@ -353,11 +354,11 @@ policy. The trade-off is admission, not accounting. The main account's Luna Reserve needs an exhausted ordinary window to activate, so while the lock is blocking Reserve cannot engage; an operator who wants Reserve turns the setting off rather than deleting the key. This is not a reservation of the -last 2%: already-admitted, parallel, unmatched-keyring, or direct upstream traffic can still reach +remaining headroom: already-admitted, parallel, unmatched-keyring, or direct upstream traffic can still reach exhaustion. Settings and the main-account DTO report enabled state separately from the current `off`, `unknown`, `ready`, or `blocked` status. Status semantics stay in -`tests/codex-integration/main-account-hard-lock-policy.test.ts`; the default-on resolver, the 98% -boundary, the admission consequence, and the settings opt-out round trip are covered by the +`tests/codex-integration/main-account-hard-lock-policy.test.ts`; the default-on resolver, the per-window +boundaries, the admission consequence, and the settings opt-out round trip are covered by the hard-lock tests registered in `scripts/test-layout/layout.json`, including `tests/config/settings-main-account-hard-lock.test.ts`. @@ -369,7 +370,7 @@ qualifies, not only a seven-day or monthly window. The policy trusts that one re it does not require repeated observations or independently confirm upstream window completeness. Any omitted window field, an unreadable long window, an unknown duration, partial headers, or invalid usage cannot prove that the short window disappeared. All-null credits-only and tertiary-only Go/Free responses remain insufficient. Replacement proof belongs only to that observation and is never persisted; -the resulting weekly/monthly window still blocks at 98%. This prevents old short-window exhaustion +the resulting weekly/monthly window still blocks at its long threshold (98% by default). This prevents old short-window exhaustion from surviving indefinitely on a now weekly/monthly account. Coverage lives in `tests/codex-integration/main-quota-evidence-validation.test.ts`, `tests/codex-integration/main-account-hard-lock-retirement.test.ts`, diff --git a/structure/runtime.md b/structure/runtime.md index 4d5a5ba833b..3039702b47f 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -597,4 +597,4 @@ registration succeeds. Bun updater lease and recovery behavior follows the [update transaction contract](ops/service-and-sidecars.md#bun-updater-ownership-transaction). -Companion timeline and filtered totals follow the [companion usage contract](companion.md). [Ongoing priority failback](providers/openai-accounts.md#ongoing-priority-failback) reuses request-triggered quota priming and captured-account dispatch; it adds no periodic worker or mid-request account switch. The serving-install census and bounded foreground delegation in `src/config/serving-runtimes.ts` follow [service command selection](ops/service-and-sidecars.md#background-service-command-selection). The experimental macOS `ocx chatgpt` launcher, restore and status commands follow the [ChatGPT Desktop contract](clients/chatgpt-desktop.md); the internal stdout filter remains hidden from public capability discovery. +Companion timeline and filtered totals follow the [companion usage contract](companion.md). [Ongoing priority failback](providers/openai-accounts.md#ongoing-priority-failback) reuses request-triggered quota priming and captured-account dispatch; it adds no periodic worker or mid-request account switch. The serving-install census and bounded foreground delegation in `src/config/serving-runtimes.ts` follow [service command selection](ops/service-and-sidecars.md#background-service-command-selection). The live main-account policy projection is fetched from the attested account-list management API. `src/cli/status-oauth.ts` renders its state and effective thresholds with an optional outside-usage advisory; `src/cli/status.ts` includes the same whitelisted projection in `ocx status --json`. No policy state is inferred from the CLI process's local quota cache when the live read is unavailable. The experimental macOS `ocx chatgpt` launcher, restore and status commands follow the [ChatGPT Desktop contract](clients/chatgpt-desktop.md); the internal stdout filter remains hidden from public capability discovery. diff --git a/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts b/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts index 946e23b56f3..7af348306f5 100644 --- a/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts +++ b/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts @@ -245,7 +245,8 @@ describe("quota auto-refresh native-main admission", () => { const calls = installFetch(async () => completedResponse()); try { await runCodexQuotaAutoRefresh(cfg, now, { persistCompleted: recordMarkers }); - expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "blocked" }); + expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "blocked", + thresholds: { short: 90, long: 98 }, window: "short" }); expect(token).not.toHaveBeenCalled(); expect(calls).toEqual([]); expect(cfg.codexQuotaAutoRefresh?.[MAIN]).toEqual({ fiveHour: true, weekly: true }); diff --git a/tests/codex-integration/main-account-external-usage.test.ts b/tests/codex-integration/main-account-external-usage.test.ts new file mode 100644 index 00000000000..c6ac865d2d5 --- /dev/null +++ b/tests/codex-integration/main-account-external-usage.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, expect, test } from "bun:test"; +import { EXTERNAL_USAGE_QUIET_MS, EXTERNAL_USAGE_TTL_MS, getMainAccountExternalUsageWarning, + noteMainAccountActivity, observeMainAccountUsage, resetMainAccountExternalUsageForTests } from "../../src/codex/main-account-external-usage"; +const now = 2_000_000_000_000; +const resetAtMs = now + 10 * 60 * 60_000; +const reading = (percent: number, reset = resetAtMs) => [{ kind: "short" as const, percent, resetAtMs: reset }]; +beforeEach(resetMainAccountExternalUsageForTests); +test("rise by one point with no activity warns; smaller rises do not", () => { + observeMainAccountUsage("a", reading(10), now); + observeMainAccountUsage("a", reading(10.5), now + 1000); + expect(getMainAccountExternalUsageWarning("a", now + 1000)).toBeUndefined(); + observeMainAccountUsage("a", reading(11.5), now + 2000); + expect(getMainAccountExternalUsageWarning("a", now + 2000)).toEqual({ window: "short", + fromPercent: 10.5, toPercent: 11.5, observedAt: now + 2000 }); +}); +test("activity inside the quiet margin suppresses, including its boundary", () => { + noteMainAccountActivity(now - EXTERNAL_USAGE_QUIET_MS); + observeMainAccountUsage("a", reading(10), now); + observeMainAccountUsage("a", reading(12), now + 1000); + expect(getMainAccountExternalUsageWarning("a", now + 1000)).toBeUndefined(); +}); +test("old activity outside the margin permits a warning", () => { + noteMainAccountActivity(now - EXTERNAL_USAGE_QUIET_MS - 1); + observeMainAccountUsage("a", reading(10), now); + observeMainAccountUsage("a", reading(12), now + 1000); + expect(getMainAccountExternalUsageWarning("a", now + 1000)).toBeDefined(); +}); +test("changed reset clears warning and rebaselines the episode", () => { + observeMainAccountUsage("a", reading(10), now); + observeMainAccountUsage("a", reading(12), now + 1000); + observeMainAccountUsage("a", reading(15, resetAtMs + 1000), now + 2000); + expect(getMainAccountExternalUsageWarning("a", now + 2000)).toBeUndefined(); +}); +test("identity change clears; getters cannot leak another identity", () => { + observeMainAccountUsage("a", reading(10), now); + observeMainAccountUsage("a", reading(12), now + 1000); + expect(getMainAccountExternalUsageWarning("b", now + 1000)).toBeUndefined(); + observeMainAccountUsage("b", reading(20), now + 2000); + expect(getMainAccountExternalUsageWarning("b", now + 2000)).toBeUndefined(); + expect(getMainAccountExternalUsageWarning("a", now + 2000)).toBeUndefined(); +}); +test("warning expires at six hours even with unchanged observations", () => { + observeMainAccountUsage("a", reading(10), now); + observeMainAccountUsage("a", reading(12), now + 1000); + observeMainAccountUsage("a", reading(12), now + EXTERNAL_USAGE_TTL_MS); + expect(getMainAccountExternalUsageWarning("a", now + EXTERNAL_USAGE_TTL_MS)).toBeDefined(); + expect(getMainAccountExternalUsageWarning("a", now + 1000 + EXTERNAL_USAGE_TTL_MS)).toBeUndefined(); +}); +test("reset expires before TTL and missing or invalid resets cannot prove a rise", () => { + observeMainAccountUsage("a", reading(10, now + 5000), now); + observeMainAccountUsage("a", reading(12, now + 5000), now + 1000); + expect(getMainAccountExternalUsageWarning("a", now + 4999)).toBeDefined(); + expect(getMainAccountExternalUsageWarning("a", now + 5000)).toBeUndefined(); + observeMainAccountUsage("a", reading(20, NaN), now + 6000); + observeMainAccountUsage("a", reading(25, NaN), now + 7000); + expect(getMainAccountExternalUsageWarning("a", now + 7000)).toBeUndefined(); +}); +test("long windows warn independently", () => { + observeMainAccountUsage("a", [{ kind: "long", percent: 20, resetAtMs }], now); + observeMainAccountUsage("a", [{ kind: "long", percent: 21, resetAtMs }], now + 1000); + expect(getMainAccountExternalUsageWarning("a", now + 1000)?.window).toBe("long"); +}); diff --git a/tests/codex-integration/main-account-hard-lock-auth.test.ts b/tests/codex-integration/main-account-hard-lock-auth.test.ts index 877a0877a95..c8cfc4090ad 100644 --- a/tests/codex-integration/main-account-hard-lock-auth.test.ts +++ b/tests/codex-integration/main-account-hard-lock-auth.test.ts @@ -26,6 +26,11 @@ import { observeMainQuotaIdentity, } from "../../src/codex/main-account-cache"; import { clearAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { + getMainAccountExternalUsageWarning, + observeMainAccountUsage, + resetMainAccountExternalUsageForTests, +} from "../../src/codex/main-account-external-usage"; import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexUpstreamHealth, getCodexQuotaHealthSnapshot, recordCodexUpstreamOutcome } from "../../src/codex/routing"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar } from "../../src/providers/openai-sidecar"; import { mapCodexAuthContextErrorToResponse } from "../../src/server/responses/codex-auth-error"; @@ -359,7 +364,7 @@ describe("main quota policy at native admission", () => { const cfg = accountZeroConfig(); addAlternative(cfg); setAccountQuotaFromParsed("hard-lock-pool", { weeklyPercent: 1, shortPercent: 1 }); - setAccountQuotaFromParsed(MAIN, { weeklyPercent: 97.99, shortPercent: 97.99 }, undefined, + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 97.99, shortPercent: 89.99 }, undefined, captureMainQuotaWriter(accountId)); // Above global 95: ignoring the explicit zero would proactively leave main here. await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) @@ -397,7 +402,7 @@ describe("main quota policy at native admission", () => { test("per-account zero cannot bypass hard-lock when selected main headers materialize", async () => { const cfg = accountZeroConfig(); - quota(97.99); + quota(89.99); const context = await resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: MAIN }); expect(context.kind).toBe("main-pool"); quota(99); @@ -420,6 +425,20 @@ describe("main quota policy at native admission", () => { expect(isCodexAccountUsable(cfg, MAIN, { nativeMainSelectionOnly: true })).toBe(false); }); + test("a refused main request is not counted as opencodex activity for the outside-usage warning", async () => { + resetMainAccountExternalUsageForTests(); + quota(99); + await expect(resolveCodexAuthContext(new Headers(), config(), "pool", { accountId: MAIN })) + .rejects.toBeInstanceOf(CodexMainAccountHardLockError); + const now = Date.now(); + const resetAtMs = now + 60 * 60_000; + observeMainAccountUsage("outside-usage-fixture", [{ kind: "short", percent: 10, resetAtMs }], now); + observeMainAccountUsage("outside-usage-fixture", [{ kind: "short", percent: 12, resetAtMs }], now + 1_000); + expect(getMainAccountExternalUsageWarning("outside-usage-fixture", now + 1_000)) + .toEqual({ window: "short", fromPercent: 10, toPercent: 12, observedAt: now + 1_000 }); + resetMainAccountExternalUsageForTests(); + }); + test("eligible added account continues when main is blocked", async () => { const cfg = config(); addAlternative(cfg); @@ -453,7 +472,7 @@ describe("main quota policy at native admission", () => { })).rejects.toBeInstanceOf(CodexMainAccountHardLockError); }); - for (const percent of [97.99, 99]) { + for (const percent of [89.99, 99]) { test(`Pool cooldown caller fallback keeps the main ${percent}% policy boundary`, async () => { const cfg = config(); addAlternative(cfg); @@ -471,7 +490,7 @@ describe("main quota policy at native admission", () => { const context = resolveCodexAuthContext(caller(), cfg, "pool", { requestScopedMainCredential: true, modelId: "gpt-5.6-terra", }); - if (percent < 98) { + if (percent < 90) { await expect(context).resolves.toMatchObject({ kind: "main", accountId: null }); } else { await expect(context).rejects.toBeInstanceOf(CodexMainAccountHardLockError); @@ -495,7 +514,7 @@ describe("main quota policy at native admission", () => { test("selection writer survives to headers; live quota and toggle are checked at materialization", async () => { const cfg = config(); - quota(97.99); + quota(89.99); const ctx = await resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: MAIN }); expect(ctx.kind).toBe("main-pool"); if (ctx.kind !== "main-pool") throw new Error("expected stored main context"); @@ -506,12 +525,12 @@ describe("main quota policy at native admission", () => { expect(headersForCodexAuthContext(new Headers(), ctx, cfg).get("authorization")).toBe(`Bearer ${bearer()}`); cfg.codexMainAccountHardLock = true; expect(() => headersForCodexAuthContext(new Headers(), ctx, cfg)).toThrow(CodexMainAccountHardLockError); - quota(97.99); + quota(89.99); expect(() => headersForCodexAuthContext(new Headers(), ctx, cfg)).not.toThrow(); }); test("quota changing while selected main refresh awaits rejects without quarantining it", async () => { - quota(97.99); + quota(89.99); await expect(resolveCodexAuthContext(new Headers(), config(), "pool", { accountId: MAIN, getValidMainAccountToken: async () => { @@ -526,7 +545,7 @@ describe("main quota policy at native admission", () => { test("actual Direct substitution rechecks after awaited native refresh", async () => { writeMain(bearer(true)); - quota(97.99); + quota(89.99); const cfg = config(); cfg.codexMainAccountHardLock = false; await expect(materializeCodexUpstreamAuthAsync(caller("proxy-admission"), { kind: "main", accountId: null }, { @@ -646,7 +665,7 @@ describe("main quota policy at native admission", () => { }); test("stale writer is retained for rejection rather than converted into an untrusted write", async () => { - quota(97.99); + quota(89.99); const ctx = await resolveCodexAuthContext(new Headers(), config(), "pool", { accountId: MAIN }); if (ctx.kind !== "main-pool") throw new Error("expected stored main context"); const writer = ctx.mainQuotaWriter; diff --git a/tests/codex-integration/main-account-hard-lock-default.test.ts b/tests/codex-integration/main-account-hard-lock-default.test.ts index 346cbca1914..6dce103d110 100644 --- a/tests/codex-integration/main-account-hard-lock-default.test.ts +++ b/tests/codex-integration/main-account-hard-lock-default.test.ts @@ -86,7 +86,7 @@ describe("main-account hard lock default (#5694)", () => { expect(isMainAccountHardLockEnabled({ codexMainAccountHardLock: true })).toBe(true); expect(isMainAccountHardLockEnabled({ codexMainAccountHardLock: false })).toBe(false); // Absent is not "no signal" for the status reader either: it reports the default as on. - expect(getMainAccountHardLockStatus({})).toEqual({ enabled: true, state: "unknown" }); + expect(getMainAccountHardLockStatus({})).toMatchObject({ enabled: true, state: "unknown" }); }); test("a malformed hand edit parses to the default instead of disabling the policy", () => { @@ -106,11 +106,11 @@ describe("main-account hard lock default (#5694)", () => { observe({ weeklyPercent: 97.9 }); expect(getMainAccountHardLockStatus({}).state).toBe("ready"); observe({ shortPercent: 98 }); - expect(getMainAccountHardLockStatus({})).toEqual({ enabled: true, state: "blocked" }); + expect(getMainAccountHardLockStatus({})).toMatchObject({ enabled: true, state: "blocked" }); expect(isMainAccountHardLocked({})).toBe(true); // The opt-out keeps the same observation admissible. expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: false })) - .toEqual({ enabled: false, state: "off" }); + .toMatchObject({ enabled: false, state: "off" }); }); test("GET reports the lock as on when no key is stored", async () => { 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 142d8be30d9..6feaf594436 100644 --- a/tests/codex-integration/main-account-hard-lock-policy.test.ts +++ b/tests/codex-integration/main-account-hard-lock-policy.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { getMainAccountHardLockStatus, isMainAccountHardLocked } from "../../src/codex/main-account-hard-lock"; +import { getMainAccountHardLockStatus, isMainAccountHardLocked, type MainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; import { clearAccountQuota, getAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed, type StoredAccountQuota } from "../../src/codex/quota"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -35,18 +35,24 @@ function observe(quota: Omit): void { setAccountQuotaFromParsed("__main__", quota, undefined, writer); } +/** Exact status contract (absent resetAt included); `window` is covered by the thresholds suite. */ +function expectPolicy(actual: MainAccountHardLockStatus, expected: Omit): void { + const rest: Partial = { ...actual }; + delete rest.window; + expect(rest).toEqual({ thresholds: { short: 90, long: 98 }, ...expected }); +} + describe("identity-bound main-account hard-lock policy", () => { test("an explicit opt-out preserves admission even at 100", () => { observe({ weeklyPercent: 100 }); // The key is default-on since #5694: absent means enabled, only `false` opts out. - expect(getMainAccountHardLockStatus({}, now)).toEqual({ enabled: true, state: "blocked" }); + expectPolicy(getMainAccountHardLockStatus({}, now), { enabled: true, state: "blocked" }); expect(isMainAccountHardLocked({ codexMainAccountHardLock: false }, now)).toBe(false); - expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: false }, now)) - .toEqual({ enabled: false, state: "off" }); + expectPolicy(getMainAccountHardLockStatus({ codexMainAccountHardLock: false }, now), { enabled: false, state: "off" }); }); test("unknown is not a fabricated empty or exhausted quota", () => { - expect(getMainAccountHardLockStatus(enabled, now)).toEqual({ enabled: true, state: "unknown" }); + expectPolicy(getMainAccountHardLockStatus(enabled, now), { enabled: true, state: "unknown" }); setAccountQuotaFromParsed("__main__", { weeklyPercent: 100 }); expect(getMainAccountHardLockStatus(enabled, now).state).toBe("unknown"); }); @@ -63,14 +69,14 @@ describe("identity-bound main-account hard-lock policy", () => { test("reset times accept seconds and milliseconds but recovery requires fresh evidence", () => { observe({ weeklyPercent: 99, weeklyResetAt: (now + 60_000) / 1000, shortPercent: 100, shortResetAt: now + 120_000 }); - expect(getMainAccountHardLockStatus(enabled, now)).toEqual({ enabled: true, state: "blocked", resetAt: now + 120_000 }); + expectPolicy(getMainAccountHardLockStatus(enabled, now), { enabled: true, state: "blocked", resetAt: now + 120_000 }); expect(getMainAccountHardLockStatus(enabled, now + 60_000).state).toBe("blocked"); - expect(getMainAccountHardLockStatus(enabled, now + 120_000)).toEqual({ enabled: true, state: "blocked" }); + expectPolicy(getMainAccountHardLockStatus(enabled, now + 120_000), { 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" }); + expectPolicy(getMainAccountHardLockStatus(enabled, now + 120_000), { enabled: true, state: "ready" }); }); test.each([ @@ -110,7 +116,7 @@ describe("identity-bound main-account hard-lock policy", () => { test("one missing reset prevents a false scheduled-unlock promise", () => { observe({ weeklyPercent: 99, monthlyPercent: 99, monthlyResetAt: now + 60_000 }); - expect(getMainAccountHardLockStatus(enabled, now)).toEqual({ enabled: true, state: "blocked" }); + expectPolicy(getMainAccountHardLockStatus(enabled, now), { enabled: true, state: "blocked" }); expect(isMainAccountHardLocked(enabled, now + 24 * 60 * 60_000)).toBe(true); }); @@ -118,7 +124,7 @@ describe("identity-bound main-account hard-lock policy", () => { observe({ [field]: 98 }); expect(isMainAccountHardLocked(enabled, now)).toBe(true); observe({ [field]: 0 }); - expect(getMainAccountHardLockStatus(enabled, now)).toEqual({ enabled: true, state: "ready" }); + expectPolicy(getMainAccountHardLockStatus(enabled, now), { enabled: true, state: "ready" }); observe({ [field]: 98 }); expect(getMainAccountHardLockStatus(enabled, now).state).toBe("blocked"); }); @@ -128,7 +134,7 @@ describe("identity-bound main-account hard-lock policy", () => { { 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" }, + { shortPercent: 89.99, 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); @@ -136,7 +142,7 @@ describe("identity-bound main-account hard-lock policy", () => { 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" }); + expectPolicy(getMainAccountHardLockStatus(enabled, now), { enabled: true, state: "blocked" }); observe({ shortPercent: 0 }); expect(getMainAccountHardLockStatus(enabled, now).state).toBe("blocked"); observe({ weeklyPercent: 97 }); @@ -147,9 +153,9 @@ describe("identity-bound main-account hard-lock policy", () => { 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 }); + expectPolicy(getMainAccountHardLockStatus(enabled, now), { 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 }); + expectPolicy(getMainAccountHardLockStatus(enabled, now), { enabled: true, state: "blocked", resetAt: now + 60_000 }); }); test("an unknown 5h reading neither hides a weekly block nor blocks alone", () => { diff --git a/tests/codex-integration/main-account-hard-lock-recovery.test.ts b/tests/codex-integration/main-account-hard-lock-recovery.test.ts index 17dbb5f6736..14f61f78001 100644 --- a/tests/codex-integration/main-account-hard-lock-recovery.test.ts +++ b/tests/codex-integration/main-account-hard-lock-recovery.test.ts @@ -681,7 +681,7 @@ describe("main hard-lock background recovery", () => { } })); await runMainAccountHardLockRecovery(config()); expect(calls).toEqual([whamUrl]); - expect(getMainAccountHardLockStatus(config())).toEqual({ enabled: true, state: "ready" }); + expect(getMainAccountHardLockStatus(config())).toMatchObject({ enabled: true, state: "ready" }); expect(getMainPolicyQuota()?.shortPercent).toBeUndefined(); expect(getMainPolicyQuota()?.weeklyPercent).toBe(35); expect(getNativeMainProfileRequestCount()).toBe(0); @@ -692,7 +692,7 @@ describe("main hard-lock background recovery", () => { const calls = fetchWith(async () => usage(percent)); const cfg = config(); await fetchMainAccountInfo(true); - expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "blocked" }); + expect(getMainAccountHardLockStatus(cfg)).toMatchObject({ enabled: true, state: "blocked" }); percent = 0; let afterTick: (() => void) | undefined; const registration = spyOn(sweeper, "registerStateSweepAfterTick").mockImplementation(entry => { @@ -707,7 +707,7 @@ describe("main hard-lock background recovery", () => { await runMainAccountHardLockRecovery(cfg); expect(timer).not.toHaveBeenCalled(); expect(calls).toEqual([whamUrl, whamUrl]); - expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "ready" }); + expect(getMainAccountHardLockStatus(cfg)).toMatchObject({ enabled: true, state: "ready" }); expect(getNativeMainProfileRequestCount()).toBe(0); } finally { registration.mockRestore(); @@ -794,7 +794,7 @@ describe("main hard-lock background recovery", () => { expect(isAccountNeedsReauth(MAIN)).toBe(true); expect(calls).toEqual([tokenUrl]); expect(getMainPolicyQuota()).toEqual(retained); - expect(getMainAccountHardLockStatus(config())).toEqual({ enabled: true, state: "blocked" }); + expect(getMainAccountHardLockStatus(config())).toMatchObject({ enabled: true, state: "blocked" }); expect(getNativeMainProfileRequestCount()).toBe(0); } finally { response.resolve(Response.json(refreshed)); @@ -844,7 +844,7 @@ describe("main hard-lock background recovery", () => { expect(cooldown).not.toBeNull(); fetchWith(async () => usage()); await runMainAccountHardLockRecovery(cfg); - expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "ready" }); + expect(getMainAccountHardLockStatus(cfg)).toMatchObject({ enabled: true, state: "ready" }); expect(cfg.pausedCodexAccountIds).toEqual([MAIN]); expect(getCodexQuotaHealthSnapshot(MAIN, "shared", now)).toEqual(cooldown); }); diff --git a/tests/codex-integration/main-account-hard-lock-thresholds.test.ts b/tests/codex-integration/main-account-hard-lock-thresholds.test.ts new file mode 100644 index 00000000000..eb20828f3ef --- /dev/null +++ b/tests/codex-integration/main-account-hard-lock-thresholds.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getMainAccountHardLockStatus, resolveMainAccountHardLockThresholds } from "../../src/codex/main-account-hard-lock"; +import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; +import { clearAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed, type StoredAccountQuota } from "../../src/codex/quota"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +let home: string; +let previousHome: string | undefined; +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-main-thresholds-")); + process.env.OPENCODEX_HOME = home; + clearAccountQuota(); clearMainAccountInfoCache(); observeMainQuotaIdentity("threshold-fixture"); +}); +afterEach(() => { + clearAccountQuota(); clearMainAccountInfoCache(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); +function observe(quota: Omit) { + setAccountQuotaFromParsed("__main__", quota, undefined, captureMainQuotaWriter("threshold-fixture")); +} +describe("per-window main-account hard lock", () => { + test("5h at 90 blocks even with weekly headroom", () => { + observe({ shortPercent: 90, weeklyPercent: 30 }); + expect(getMainAccountHardLockStatus({})).toEqual({ enabled: true, state: "blocked", + thresholds: { short: 90, long: 98 }, window: "short" }); + }); + test("weekly 95 admits by default and blocks at a configured 95", () => { + observe({ weeklyPercent: 95 }); + expect(getMainAccountHardLockStatus({}).state).toBe("ready"); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLockThresholds: { long: 95 } })) + .toMatchObject({ state: "blocked", window: "long", thresholds: { short: 90, long: 95 } }); + }); + test("configured thresholds govern independently, including monthly-only usage", () => { + const config = { codexMainAccountHardLockThresholds: { short: 85, long: 96 } }; + observe({ shortPercent: 84.99, weeklyPercent: 95.99 }); + expect(getMainAccountHardLockStatus(config).state).toBe("ready"); + observe({ shortPercent: 85 }); + expect(getMainAccountHardLockStatus(config).window).toBe("short"); + clearAccountQuota(); + observe({ monthlyPercent: 96, monthlyIsPrimaryWindow: true }); + expect(getMainAccountHardLockStatus(config).window).toBe("long"); + }); + test("invalid in-memory values fall back and reversed values force short <= long", () => { + expect(resolveMainAccountHardLockThresholds({ codexMainAccountHardLockThresholds: { short: 79, long: NaN } })) + .toEqual({ short: 90, long: 98 }); + expect(resolveMainAccountHardLockThresholds({ codexMainAccountHardLockThresholds: { short: 99, long: 85 } })) + .toEqual({ short: 85, long: 85 }); + expect(resolveMainAccountHardLockThresholds({ codexMainAccountHardLockThresholds: { short: 90.1, long: 101 } })) + .toEqual({ short: 90, long: 98 }); + }); + test("malformed disk fields degrade individually without discarding valid siblings", () => { + const parsed = configSchema.parse({ port: 10100, defaultProvider: "example", providers: { example: { adapter: "openai-chat", baseUrl: "https://example.test/v1" } }, + codexMainAccountHardLockThresholds: { short: "85", long: 95 } }); + expect(resolveMainAccountHardLockThresholds(parsed)).toEqual({ short: 90, long: 95 }); + }); + test.each([{ weeklyPercent: 30 }, { resetCredits: 2 }, { shortResetAt: 4_000_000_000 }])( + "92 percent short evidence survives elapsed reset and partial %j", partial => { + const elapsed = Math.floor(Date.now() / 1000) - 60; + observe({ shortPercent: 92, shortResetAt: elapsed, weeklyPercent: 30 }); + observe(partial); + expect(getMainPolicyQuota()).toMatchObject({ shortPercent: 92, shortResetAt: elapsed }); + expect(getMainAccountHardLockStatus({}).state).toBe("blocked"); + observe({ shortPercent: 0 }); + expect(getMainAccountHardLockStatus({}).state).toBe("ready"); + }); + test("weekly evidence below 98 survives reset-only updates at the minimum threshold", () => { + observe({ weeklyPercent: 80, weeklyResetAt: 4_000_000_000 }); + observe({ weeklyResetAt: 4_100_000_000 }); + expect(getMainPolicyQuota()).toMatchObject({ weeklyPercent: 80, weeklyResetAt: 4_000_000_000 }); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLockThresholds: { short: 80, long: 80 } }).state).toBe("blocked"); + }); +}); diff --git a/tests/codex-integration/main-quota-evidence-validation.test.ts b/tests/codex-integration/main-quota-evidence-validation.test.ts index fb87c15c3b9..614672184ff 100644 --- a/tests/codex-integration/main-quota-evidence-validation.test.ts +++ b/tests/codex-integration/main-quota-evidence-validation.test.ts @@ -334,21 +334,21 @@ describe("cold persisted policy percentage ranges", () => { ? { monthlyPercent: 99, monthlyIsPrimaryWindow: true } : { weeklyPercent: 99 }; const disk = writeColdPolicy({ ...blocking, [field]: value }); - expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "blocked" }); + expect(getMainAccountHardLockStatus(cfg)).toMatchObject({ enabled: true, state: "blocked" }); expect(getMainPolicyQuota()).toEqual({ updatedAt: disk.updatedAt, ...blocking }); // The ordinary disk cache is intentionally not sanitized by the policy parser. expect(getAccountQuota(MAIN)).toEqual(disk); const isolated = writeColdPolicy({ [field]: value, monthlyIsPrimaryWindow: true }); expect(getMainPolicyQuota()).toEqual({ updatedAt: isolated.updatedAt }); - expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "unknown" }); + expect(getMainAccountHardLockStatus(cfg)).toMatchObject({ enabled: true, state: "unknown" }); }, ); test.each([0, 97.99, 98, 99, 100])(`${field}=%s survives disk hydration without clamping`, value => { const disk = writeColdPolicy({ [field]: value }); expect(getMainPolicyQuota()).toEqual(disk); - expect(getMainAccountHardLockStatus(cfg).state).toBe(value < 98 ? "ready" : "blocked"); + expect(getMainAccountHardLockStatus(cfg).state).toBe(value < (field === "shortPercent" ? 90 : 98) ? "ready" : "blocked"); }); } @@ -364,7 +364,7 @@ describe("cold persisted policy percentage ranges", () => { expect(getMainPolicyQuota()).toEqual({ updatedAt: disk.updatedAt, weeklyPercent: 99, shortWindowSeconds: 18_000, shortResetAt: 2_000_000_000, shortObservedAt: 1_700_000_000_000, resetCredits: 150 }); // The rejected 5h reading is unknown, but it cannot mask the valid weekly99 block. - expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "blocked" }); + expect(getMainAccountHardLockStatus(cfg)).toMatchObject({ 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 5b5226a08ce..6bfdcb28c29 100644 --- a/tests/codex-integration/main-quota-window-observation.test.ts +++ b/tests/codex-integration/main-quota-window-observation.test.ts @@ -107,7 +107,7 @@ describe("declared short-window producer evidence", () => { percent = 99; await fetchMainAccountInfo(true); const retained = getMainPolicyQuota(); - expect(getMainAccountHardLockStatus(enabled)).toEqual({ enabled: true, state: "blocked" }); + expect(getMainAccountHardLockStatus(enabled)).toMatchObject({ enabled: true, state: "blocked" }); invalid = true; percent = 0; await fetchMainAccountInfo(true); @@ -118,7 +118,7 @@ describe("declared short-window producer evidence", () => { expect(getMainAccountHardLockStatus(enabled).state).toBe("blocked"); invalid = false; await fetchMainAccountInfo(true); - expect(getMainAccountHardLockStatus(enabled)).toEqual({ enabled: true, state: "ready" }); + expect(getMainAccountHardLockStatus(enabled)).toMatchObject({ enabled: true, state: "ready" }); percent = 99; await fetchMainAccountInfo(true); expect(calls).toBe(5); @@ -140,7 +140,7 @@ describe("declared short-window producer evidence", () => { headers.set("x-codex-primary-used-percent", "99"); applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); const retained = getMainPolicyQuota(); - expect(getMainAccountHardLockStatus(enabled)).toEqual({ enabled: true, state: "blocked" }); + expect(getMainAccountHardLockStatus(enabled)).toMatchObject({ enabled: true, state: "blocked" }); headers.set("x-codex-primary-used-percent", "0"); headers.set(`x-codex-${slot}-used-percent`, String(value)); applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); @@ -151,7 +151,7 @@ describe("declared short-window producer evidence", () => { expect(getMainAccountHardLockStatus(enabled).state).toBe("blocked"); headers.set(`x-codex-${slot}-used-percent`, "0"); applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); - expect(getMainAccountHardLockStatus(enabled)).toEqual({ enabled: true, state: "ready" }); + expect(getMainAccountHardLockStatus(enabled)).toMatchObject({ enabled: true, state: "ready" }); headers.set("x-codex-primary-used-percent", "99"); applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); expect(getMainAccountHardLockStatus(enabled).state).toBe("blocked"); @@ -301,7 +301,7 @@ describe("declared short-window producer evidence", () => { expect(getMainPolicyQuota()).not.toHaveProperty("shortObservedAt"); expect(matchesMainQuotaCredential(accessToken, "fixture-main-a")).toBe(true); // The unknown 5h reading cannot hide the retained weekly99: either window blocks alone. - expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true })).toEqual({ enabled: true, state: "blocked" }); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true })).toMatchObject({ enabled: true, state: "blocked" }); // Unknown metadata replaces the legacy tuple but must retain trusted policy short99. const firstShortObservedAt = observationTime; await fetchMainAccountInfo(true); @@ -315,7 +315,7 @@ describe("declared short-window producer evidence", () => { shortResetAt: 3_000_000_000, shortObservedAt: firstShortObservedAt, updatedAt: observationTime }); const enabled = { codexMainAccountHardLock: true }; expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000 - 1).state).toBe("blocked"); - expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000)).toEqual({ enabled: true, state: "blocked" }); + expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000)).toMatchObject({ enabled: true, state: "blocked" }); observationTime += 60_000; await fetchMainAccountInfo(true); expect(calls).toBe(5); @@ -352,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: "blocked" }); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true })).toMatchObject({ enabled: true, state: "blocked" }); const firstShortObservedAt = observationTime; applyAccountQuotaFromUpstreamHeaders(MAIN, new Headers({ "x-codex-primary-used-percent": "99", "x-codex-primary-window-minutes": "300", @@ -368,7 +368,7 @@ describe("declared short-window producer evidence", () => { shortResetAt: 3_000_000_000, shortObservedAt: firstShortObservedAt, updatedAt: observationTime }); const enabled = { codexMainAccountHardLock: true }; expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000 - 1).state).toBe("blocked"); - expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000)).toEqual({ enabled: true, state: "blocked" }); + expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000)).toMatchObject({ enabled: true, state: "blocked" }); headers.set("x-codex-primary-used-percent", "0"); observationTime += 60_000; applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); diff --git a/tests/config/settings-main-account-hard-lock.test.ts b/tests/config/settings-main-account-hard-lock.test.ts index 5f3ca54b1e4..5b50dc95e41 100644 --- a/tests/config/settings-main-account-hard-lock.test.ts +++ b/tests/config/settings-main-account-hard-lock.test.ts @@ -48,7 +48,7 @@ afterEach(() => { removeTreeWithRetry(home); }); -describe("main-account 98 percent setting", () => { +describe("main-account hard-lock setting", () => { test("GET reports the default-on lock without a stored key", async () => { const response = await request(config()); expect(await response!.json()).toMatchObject({ @@ -116,4 +116,40 @@ describe("main-account 98 percent setting", () => { const response = await request(loaded); expect(await response!.json()).toMatchObject({ codexMainAccountHardLock: true }); }); + test("PUT thresholds persists and GET projects effective values", async () => { + const cfg = config(); + const response = await request(cfg, { codexMainAccountHardLockThresholds: { short: 85, long: 95 } }); + expect(response!.status).toBe(200); + expect(await response!.json()).toMatchObject({ mainAccountHardLock: { thresholds: { short: 85, long: 95 } } }); + expect(loadConfig().codexMainAccountHardLockThresholds).toEqual({ short: 85, long: 95 }); + expect(await (await request(cfg))!.json()).toMatchObject({ mainAccountHardLock: { thresholds: { short: 85, long: 95 } } }); + }); + test.each([null, [], 90, {}, { short: 79 }, { long: 101 }, { short: 90.5 }, { long: "95" }, + { short: 96, long: 95 }, { short: 99 }, { long: 85 }, { extra: 90 }].map(value => [value]))( + "invalid thresholds %j reject before any settings mutation", async thresholds => { + const cfg = config(); + const response = await request(cfg, { codexMainAccountHardLock: false, codexMainAccountHardLockThresholds: thresholds }); + expect(response!.status).toBe(400); + expect(cfg.codexMainAccountHardLock).toBeUndefined(); + expect(cfg.codexMainAccountHardLockThresholds).toBeUndefined(); + }); + test("threshold save failure restores absent and present objects exactly", async () => { + for (const previous of [undefined, { short: 85, long: 95 }]) { + const cfg = config(); + if (previous) cfg.codexMainAccountHardLockThresholds = previous; + await expect(request(cfg, { codexMainAccountHardLockThresholds: { short: 90, long: 100 } }, { + saveConfigPreservingClaudeCode: () => { throw new Error("fixture save failure"); }, + })).rejects.toThrow("fixture save failure"); + expect(cfg.codexMainAccountHardLockThresholds).toBe(previous); + expect(Object.hasOwn(cfg, "codexMainAccountHardLockThresholds")).toBe(previous !== undefined); + } + }); + test("partial thresholds keep the stored sibling and validate against it", async () => { + const cfg = config(); + cfg.codexMainAccountHardLockThresholds = { short: 85, long: 92 }; + expect((await request(cfg, { codexMainAccountHardLockThresholds: { short: 93 } }))!.status).toBe(400); + expect(cfg.codexMainAccountHardLockThresholds).toEqual({ short: 85, long: 92 }); + expect((await request(cfg, { codexMainAccountHardLockThresholds: { short: 88 } }))!.status).toBe(200); + expect(cfg.codexMainAccountHardLockThresholds).toEqual({ short: 88, long: 92 }); + }); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a70ab9a8cf8..f51ce535fda 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,6 @@ { + "main-account-hard-lock-thresholds.test.ts": "codex-integration", + "main-account-external-usage.test.ts": "codex-integration", "jev-decision-model-config.test.ts": "routing", "cli-combo-partial-update.test.ts": "cli", "desktop-app-server-shim.test.ts": "clients", diff --git a/tests/oauth/oauth-health.test.ts b/tests/oauth/oauth-health.test.ts index 2f42bac60b8..fcd57e6ba3b 100644 --- a/tests/oauth/oauth-health.test.ts +++ b/tests/oauth/oauth-health.test.ts @@ -9,6 +9,7 @@ import { collectOAuthHealthEntriesForCli, projectOAuthAccountHealth, projectCodexAccountHealth, + projectMainAccountPolicyHealth, } from "../../src/oauth/health"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { getAccountSet, markAccountNeedsReauth, saveCredential } from "../../src/oauth/store"; @@ -448,3 +449,50 @@ describe("getCodexAccountHealthSnapshot", () => { expect(getCodexAccountHealthSnapshot("missing", now)).toBeNull(); }); }); + +describe("projectMainAccountPolicyHealth", () => { + const valid = { enabled: true, state: "ready", thresholds: { short: 90, long: 98 } }; + + test("projects a valid policy and copies only whitelisted fields", () => { + expect(projectMainAccountPolicyHealth({ ...valid, window: "short", resetAt: 1_000, accountId: "leak" })) + .toEqual({ enabled: true, state: "ready", thresholds: { short: 90, long: 98 }, window: "short", resetAt: 1_000 }); + }); + + test("rejects a state that does not match enabled", () => { + expect(projectMainAccountPolicyHealth({ ...valid, enabled: false })).toBeUndefined(); + expect(projectMainAccountPolicyHealth({ ...valid, state: "off" })).toBeUndefined(); + expect(projectMainAccountPolicyHealth({ enabled: false, state: "off", thresholds: valid.thresholds })) + .toEqual({ enabled: false, state: "off", thresholds: { short: 90, long: 98 } }); + }); + + test("rejects thresholds that are out of range, unordered, fractional or missing", () => { + for (const thresholds of [ + { short: 79, long: 98 }, + { short: 99, long: 98 }, + { short: 90.5, long: 98 }, + { short: 90, long: 101 }, + { short: "90", long: 98 }, + undefined, + ]) { + expect(projectMainAccountPolicyHealth({ ...valid, thresholds })).toBeUndefined(); + } + expect(projectMainAccountPolicyHealth(null)).toBeUndefined(); + expect(projectMainAccountPolicyHealth([valid])).toBeUndefined(); + }); + + test("drops a malformed external-usage warning but keeps the policy", () => { + for (const externalUsage of [ + { window: "monthly", fromPercent: 10, toPercent: 20, observedAt: 1 }, + { window: "short", fromPercent: -1, toPercent: 20, observedAt: 1 }, + { window: "short", fromPercent: 10, toPercent: 120, observedAt: 1 }, + { window: "short", fromPercent: 10, toPercent: 20, observedAt: Number.NaN }, + ]) { + const projected = projectMainAccountPolicyHealth({ ...valid, externalUsage }); + expect(projected).toEqual({ enabled: true, state: "ready", thresholds: { short: 90, long: 98 } }); + } + expect(projectMainAccountPolicyHealth({ + ...valid, + externalUsage: { window: "long", fromPercent: 10, toPercent: 20, observedAt: 5, extra: "x" }, + })?.externalUsage).toEqual({ window: "long", fromPercent: 10, toPercent: 20, observedAt: 5 }); + }); +});