diff --git a/devlog/_plan/260905_provider_usage_quota_parity/000_plan.md b/devlog/_plan/260905_provider_usage_quota_parity/000_plan.md
index c94a13f9ab..ae7e9abd88 100644
--- a/devlog/_plan/260905_provider_usage_quota_parity/000_plan.md
+++ b/devlog/_plan/260905_provider_usage_quota_parity/000_plan.md
@@ -53,9 +53,27 @@ Static source checker and GUI build passed; existing chunk-size warning only. No
Next work-phase: attribution. Open risk: unseen historical upstream model identity cannot be
recovered; we qualify only saved fallback provenance instead of guessing or rewriting rows.
+Attribution D: `9a9ad98b8`, PR #3582, CI run33938837845 all four backend test shards,
+GUI gates (1371 pass / 0 fail), API usage succeeded; independent Volta PASS. Remaining macOS
+jobs are still mandatory at landing, not claimed green. Next quota-api P reverified 020 against
+the unchanged quota/management baseline; attribution changes do not alter its signatures.
+API layer branch `codex/provider-account-quota-api` starts at the verified attribution head.
+
+Quota API D: `768e5a004`, PR #3584, run33940065554 all four backend shards/gates/API usage
+successful, including explicit credential isolation, passive no-network and post-await age
+regressions. Wegener full/interdiff review and Kant omitted-mode delta PASS. No local validation
+was executed. Next UI P reverified 030 against that additive row contract; remaining platform
+rollups remain mandatory before merge. UI branch `codex/provider-quota-parity` starts here.
+
## Delegation write map (locked before Build)
+User reiterated no local suites during quota-api A. No local test suite has been run;
+reported regression results came from GitHub Actions. From this point no further local
+typecheck/build/lint/scan commands either: command-based validation is remote CI only.
+Source inspection and browser observation remain scoped QA; receipts may only wrap remote
+CI result checks. This supersedes the earlier local-static/build verification allowance.
+
- Attribution cycle: Harvey owns `src/usage`, `src/router.ts`, the identified Chat/Messages error catches, `src/server/management/shared.ts`, and corresponding existing backend regression files. Main owns the provider model annotation/share/caching UI, its GUI regressions, docs, commits and CI. No overlapping writes.
- Quota API cycle: Euclid owns quota readers/key-cache/types and relevant provider regression files. Main owns management route joins, API-route regressions and docs; clarify exact exported signatures before either writes.
-- Quota UI cycle: main owns report projection/current section; a bounded worker may own account hook/panel refresh and regression changes only after an explicit P amendment gives disjoint paths.
+- Quota UI cycle: main owns shared `types.ts`, `report.ts`, NEW ProviderAccountQuota/ProviderCurrentQuota, ProviderDetails/Overview/Usage, all locale keys, public docs and direct shared-renderer regression. Harvey owns useProviderAccountPools, Providers page refresh coordinator, shell refresh callback epoch, ProviderAuthPanel and focused hook/refresh regressions. Worker may read shared contracts but never edit them. No overlapping writes or local validation.
- Independent Kant audits plans read-only; fresh independent final implementation review remains required. No worker changes FSM, goals, branches, commits or remote state, and no worker runs local tests.
diff --git a/devlog/_plan/260905_provider_usage_quota_parity/021_api_build.md b/devlog/_plan/260905_provider_usage_quota_parity/021_api_build.md
new file mode 100644
index 0000000000..ab6dfb30cd
--- /dev/null
+++ b/devlog/_plan/260905_provider_usage_quota_parity/021_api_build.md
@@ -0,0 +1,46 @@
+# Account quota API build record
+
+Layer: `codex/provider-account-quota-api`, parent `9a9ad98b8` / PR #3582.
+Main owns existing management route projection, route-level regression file and test-layout
+registration. Euclid owns credential-scoped quota readers, key cache, shared fields and provider
+regressions. No local test, typecheck, build, lint or scan commands run in this layer.
+
+Route projection now advertises capability on cheap lists, enriches only opt-in supported
+credentials, preserves active selection, clears prior failure flags on success, and checks
+private identity guards immediately before serializing safe quota fields. Passive reads remain
+cache-only. Internal callbacks and credential identities are never spread into response DTOs.
+
+Kant's scoped route review found one missing activation test: replacing an environment key
+inside fetch proves worker rejection but not the final route guard. Accepted and added a
+completed non-null quota fixture with `isCurrent() === false`, one callback invocation and
+null/unavailable JSON expectation. Same final-projection scenario added for OAuth rows.
+Re-review of the key guard delta: PASS, static only. Full independent review and remote CI pending.
+
+C inspection at `979249852` found a new-cache age gap: a failed read refreshes attempt TTL
+but preserves the measurement timestamp; a cache hit checked only attempt TTL and could keep
+the measurement past the stated last-good age. Accepted root cause; repair only the new key
+and explicit-OAuth cache hit paths, with deterministic clock boundary regressions. Existing
+three OAuth reader cache behavior stays outside this repair. No local execution authorized.
+
+Remote CI run33939675289 at979249852 failed typecheck (`TS2322`, quota.ts1808): inferred
+object return widened unique sentinel symbols to `symbol`. The valid translator-budget type
+fixture failed downstream of that source compile error. Repair is an explicit return contract
+for `readExplicitAccountQuota`, not changing or weakening the translator-budget regression.
+
+Independent Wegener review confirmed those two blockers and refined the age case: a failure
+which settles after the last-good deadline must recheck measurement age after its await,
+not just on the next cache hit. Accepted; both new cache paths gain post-await age checks and
+an independently reachable delayed-failure clock fixture before this repair is republished.
+
+The same baseline run's test4/4 also exposed `Kimi quota skips usages when OAuth refresh fails`:
+the new current-account dispatch returned an asynchronous reader without awaiting it inside its
+catch boundary. Add the missing await so credential renewal rejection degrades to unavailable
+as before; retain the existing regression unchanged. This is a separate runtime cause from
+the type inference failure, not an assumed CI flake.
+
+Codex GitHub review3939190788 identified the inherited explicit-key-only Kimi guard as
+inconsistent with omitted authMode's documented key default. Accepted for this feature:
+the shared key selector's initial normalized auth guard already rejects OAuth/forward/local,
+so Kimi now uses that same default and its canonical URL check. Added an omitted-mode
+per-key regression plus forward-mode negative. This supersedes 020's preserve-stricter-Kimi
+note, without adding destinations or sending OAuth credentials down the key path.
diff --git a/devlog/_plan/260905_provider_usage_quota_parity/030_quota_views.md b/devlog/_plan/260905_provider_usage_quota_parity/030_quota_views.md
index 05aed92e96..04c5a7f298 100644
--- a/devlog/_plan/260905_provider_usage_quota_parity/030_quota_views.md
+++ b/devlog/_plan/260905_provider_usage_quota_parity/030_quota_views.md
@@ -79,3 +79,48 @@ MODIFY `structure/05_gui-and-management-api.md` usage/quota paragraphs and `docs
## Bypass/residual
UI state is presentation, not credential authorization. Backend fixed-destination account/key readers are the actual boundary. Existing account mutation permissions are unchanged; no new enforcement claim.
+
+## P stale-check and locked refinements at 768e5a004
+
+The backend now emits the row-level mode/availability fields, including Kimi's omitted-key-auth
+default. 000's no-local-validation directive supersedes all local command examples above.
+
+1. Main owns a shared `AccountQuotaMode` and `AccountQuotaReading` in existing view types:
+ `quotaMode?: "probe"|"passive"|"unsupported"`, `quota?: AccountQuota|null`,
+ `quotaUnavailable?: boolean`, `quotaPending?: boolean`. OAuth/key rows and hook interfaces
+ reuse it. Pending is GUI-only. Worker reads this contract, does not edit types.ts.
+2. Main owns NEW `ProviderAccountQuota.tsx`, shared by all three tabs. Props exactly match
+ AccountQuotaReading. Unsupported ignores retained data; passive without observation has an
+ honest unobserved message; only explicit probe pending can show a skeleton. Failed reads
+ retain last-good numbers with a warning and their actual age. Reuse ProviderCapacityQuota
+ for windows AND credits-only balances. No summation of different accounts/keys.
+3. NEW ProviderCurrentQuota accepts `report`, optional `reading: AccountQuotaReading`, and
+ optional refresh callback. ProviderDetails supplies the active OAuth/key row only. A present
+ row with a known quotaMode (including passive with no observation), pending/unavailable,
+ or an explicit quota overrides the provider report;
+ never substitute the previous active account's quota. Codex uses report aggregation's current
+ account. Use the actual quota.updatedAt for current metadata, not aggregate refresh time.
+4. `currentAccountQuotaReport` treats present malformed aggregation as unknown, not as a
+ non-pool current quota. Valid aggregate missing current likewise stays unknown. Tests pin
+ aggregate20/current70, absent current, malformed aggregation and distinct measurement age.
+5. Preserve callback argument order: shell reports `onQuotaRefreshSettled(ok, epoch)` with
+ captured epoch second. Page resolves matching ticket only; superseded/unmounted tickets
+ resolve false. Existing one-argument callback consumers still receive a boolean.
+6. The account loader merges last-good quota only by surviving credential id, publishes explicit
+ pending for probe rows, awaits forced enrichment, handles HTTP failure behind generation
+ fences, and uses a bounded fetch deadline rather than a never-ending spinner. Passive and
+ unsupported rows never get absence-driven loading. Key subset refresh merges other providers.
+7. Refresh outcome text says the quota check completed, not that every upstream value is freshly
+ measured; passive reloads and a provider-report last-good fallback cannot prove freshness.
+
+New localized keys owned by main: `pws.currentAccountUsage`, `pws.quotaUnsupported`,
+`pws.quotaUnobserved`, `pws.quotaCheckCompleted`. Existing error/refresh labels are reused.
+Worker's AuthPanel imports ProviderAccountQuota and uses the above keys; no locale edits there.
+
+Source delta verification is remote CI plus existing in-app browser on synthetic data only.
+
+A repair: Kant identified a passive account switch while the old provider report remains cached.
+Accepted. Any active passive row is authoritative even with no quota: render unobserved and never
+reuse the old report. The direct current-section regression must provide an old report75% plus a
+new active passive row lacking quota and assert that75% is absent. Unknown-mode legacy rows may
+only use a provider report when no row-specific state/data exists; absence is never confirmation.
diff --git a/devlog/_plan/260905_provider_usage_quota_parity/031_ui_build.md b/devlog/_plan/260905_provider_usage_quota_parity/031_ui_build.md
new file mode 100644
index 0000000000..e9d7e9e2ed
--- /dev/null
+++ b/devlog/_plan/260905_provider_usage_quota_parity/031_ui_build.md
@@ -0,0 +1,44 @@
+# Unified quota presentation evidence
+
+Base `768e5a004`. Main owns shared reading/current projection and three-tab integration;
+Harvey owns account enrichment, refresh epochs and AuthPanel integration. No local test,
+typecheck, build, lint or privacy-scan commands were executed in this layer. Remote CI owns
+all command-based validation.
+
+Implemented one state/credit/window renderer across Overview, Usage and Accounts/API keys.
+Known-mode active rows are authoritative, including empty passive readings. Pool totals are
+not current-account fallback. Explicit null from the API remains null during client merges;
+only absent data can carry forward. Forced reads settle both account enrichment and the
+matching provider-report epoch. Main-owned source slice reviewed PASS by Kant; fresh integrated
+review remains in progress.
+
+## Browser evidence
+
+Existing isolated Vite server on127.0.0.1:18184; natural production components mounted from
+`gui/.tmp/provider-parity.html` with synthetic responses only. No live tokens, accounts or
+upstream requests. The temporary fixture is not a shipped page.
+
+- 031_current_overview.png: current35%/61% below usage statistics.
+- 032_current_usage.png: same current reading below provider model usage.
+- 033_oauth_accounts.png: two account rows show61% and12% separately.
+- 034_api_key_balances.png: two keys show75USD and25USD separately, never summed.
+- 035_passive_current_unobserved.png: actually selected the unobserved second passive account;
+ the previous account's report remains in fixture state but its numbers are absent.
+- 036_pool_current.png: aggregate20%, current70%; selected-provider current section shows70%.
+- Unsupported configured provider: explicit unsupported message, no current refresh control,
+ no retained quota numbers.
+- 037_tablet_usage.png: actual CSS viewport767px, document width767px; Korean layout observed.
+- 039_mobile_current.png: actual CSS viewport390px, document width390px; current section and
+ refresh control stay inside the viewport; the model table keeps its local horizontal scroll.
+ Refresh was activated with pointer and Return, displaying check-completed feedback with
+ visible focus. Raw browser capture avoids the in-app screenshot wrapper's zoom clipping.
+
+Desktop, tablet and mobile captures were read back. Temporary viewport overrides were reset.
+The source fixture's active account switch and refresh are synthetic UI state transitions,
+not writes to the user's account. Exact-head remote CI and final stack integration are pending.
+
+Remote React Doctor atd78a02a63 reported test-harness render-time global assignments in two
+new hook tests, one unused mock parameter, and cleanup-ref capture warnings in the account
+loader. Its detail was read from the signed-in GitHub summary using Aside, not by running
+the tool locally. Repair keeps assertions: capture test observations in layout effects,
+remove the unused parameter and capture the stable cleanup containers inside the effect.
diff --git a/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md
index 1d1426f687..61d17baa5c 100644
--- a/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md
+++ b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md
@@ -29,6 +29,13 @@ commit `b37841448816107c856171277dff0464032d282e`, limited to the update-recover
and its numbered record. Retain it as a fourth test-only stack layer. No new production
behavior is planned. Any semantic conflict requires a concrete plan amendment and review.
+The UI cascade has one textual conflict in `gui/src/pages/Providers.tsx`: retain this
+unit's `useQuotaRefreshCoordinator(apiBase)` and upstream's `fetchConfig:
+refreshConfigResult` binding together. Keep the upstream void-returning `fetchConfig`
+adapter and result-aware `useProviderModelsNotice` caller unchanged. Existing quota epoch
+and registration-notice tests must both execute in the new CI. This composes the two
+existing contracts; it does not restore superseded unbounded refresh waiters.
+
Preserve all original commits; use normal merge commits and fast-forward no-verify pushes,
not rebases or force-pushes. Inspect each integration diff, check that inherited quota,
registration and pricing semantics survive, and obtain independent review before publication.
diff --git a/devlog/_plan/260905_provider_usage_quota_parity/assets/031_current_overview.png b/devlog/_plan/260905_provider_usage_quota_parity/assets/031_current_overview.png
new file mode 100644
index 0000000000..37c61896be
Binary files /dev/null and b/devlog/_plan/260905_provider_usage_quota_parity/assets/031_current_overview.png differ
diff --git a/devlog/_plan/260905_provider_usage_quota_parity/assets/032_current_usage.png b/devlog/_plan/260905_provider_usage_quota_parity/assets/032_current_usage.png
new file mode 100644
index 0000000000..75ee3d8f8c
Binary files /dev/null and b/devlog/_plan/260905_provider_usage_quota_parity/assets/032_current_usage.png differ
diff --git a/devlog/_plan/260905_provider_usage_quota_parity/assets/033_oauth_accounts.png b/devlog/_plan/260905_provider_usage_quota_parity/assets/033_oauth_accounts.png
new file mode 100644
index 0000000000..c1e6cfa9f5
Binary files /dev/null and b/devlog/_plan/260905_provider_usage_quota_parity/assets/033_oauth_accounts.png differ
diff --git a/devlog/_plan/260905_provider_usage_quota_parity/assets/034_api_key_balances.png b/devlog/_plan/260905_provider_usage_quota_parity/assets/034_api_key_balances.png
new file mode 100644
index 0000000000..e3dbc46f7a
Binary files /dev/null and b/devlog/_plan/260905_provider_usage_quota_parity/assets/034_api_key_balances.png differ
diff --git a/devlog/_plan/260905_provider_usage_quota_parity/assets/035_passive_current_unobserved.png b/devlog/_plan/260905_provider_usage_quota_parity/assets/035_passive_current_unobserved.png
new file mode 100644
index 0000000000..11ce1209a4
Binary files /dev/null and b/devlog/_plan/260905_provider_usage_quota_parity/assets/035_passive_current_unobserved.png differ
diff --git a/devlog/_plan/260905_provider_usage_quota_parity/assets/036_pool_current.png b/devlog/_plan/260905_provider_usage_quota_parity/assets/036_pool_current.png
new file mode 100644
index 0000000000..cb84f100e2
Binary files /dev/null and b/devlog/_plan/260905_provider_usage_quota_parity/assets/036_pool_current.png differ
diff --git a/devlog/_plan/260905_provider_usage_quota_parity/assets/037_tablet_usage.png b/devlog/_plan/260905_provider_usage_quota_parity/assets/037_tablet_usage.png
new file mode 100644
index 0000000000..0d596a5666
Binary files /dev/null and b/devlog/_plan/260905_provider_usage_quota_parity/assets/037_tablet_usage.png differ
diff --git a/devlog/_plan/260905_provider_usage_quota_parity/assets/039_mobile_current.png b/devlog/_plan/260905_provider_usage_quota_parity/assets/039_mobile_current.png
new file mode 100644
index 0000000000..c6b2ea15a9
Binary files /dev/null and b/devlog/_plan/260905_provider_usage_quota_parity/assets/039_mobile_current.png differ
diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md
index 860884189f..67224fbcf2 100644
--- a/docs-site/src/content/docs/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/guides/web-dashboard.md
@@ -109,6 +109,14 @@ vendor's model price alone is not enough to estimate cost; an exact provider or
still applies. Model shares are calculated within the selected provider. Requests for an unknown
reserved `policy/` name now fail before reaching an upstream provider; historical usage is retained.
+The selected provider's **Overview** and **Usage** tabs show **Current account usage** below the
+usage statistics. **Accounts** and **API keys** show each supported credential's own quota,
+including credit balances. The provider-wide overview still shows pooled capacity where available;
+it is not substituted for a missing current-account reading. Unsupported lookup, no passive
+observation yet, loading, failed lookup with last-known values, and measured zero are separate
+states. **Quota check completed** means the read settled—not that a passive observation became
+new or that every upstream measurement was refreshed.
+
## Model visibility
The **Models** switches show final Codex visibility: a routed model is on only when its provider allowlist includes it (or no allowlist is set) and it is not disabled. Turning a model on reconciles both filters atomically; **All on** clears the provider allowlist so newly discovered models are also on.
@@ -256,6 +264,7 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints
| `GET /api/models` · `PUT /api/disabled-models` | List native/routed model rows and update the shared disabled-model set. |
| `GET /api/selected-models` · `PUT /api/model-visibility` | Read provider allowlists and atomically change the final visibility of one model or provider group. |
| `GET /api/key-providers` · `GET /api/oauth/providers` | Read the API-key and OAuth provider catalogs. |
+| `GET /api/oauth/accounts?provider=..."a=1` · `GET /api/providers/keys?name=..."a=1` | Read each account or key's quota where supported, without changing the active credential. Add `refresh=1` to bypass settled quota cache; an in-flight same-credential read can be shared. Omit `quota=1` for a cheap local list with each row's `quotaMode`: `probe`, `passive`, or `unsupported`. Passive reads return existing observations without a network probe. No reading is not the same as 0% used, and quotas for multiple keys are not summed. |
| `POST /api/oauth/login` · `GET /api/oauth/status` | Start a provider OAuth flow and poll for completion. |
| `GET /api/codex-auth/accounts?refresh=1` | List main and pool accounts, force quota refresh, and report main-account `hasCredential` / terminal `needsReauth` state. |
| `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | Select the account for the next request and configure pool routing. |
diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md
index 41f416940a..6f340ddd8b 100644
--- a/docs-site/src/content/docs/ko/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md
@@ -55,6 +55,11 @@ bun run dev:gui
**Logs**와 **Usage**의 비용 값은 보고된 토큰으로 계산한 API 정가 환산치입니다. 결제 영수증이나
실제 청구 증거가 아니며, 구독 사용량 또는 프로바이더 크레딧이 대신 적용될 수 있습니다.
+프로바이더를 선택하면 **개요**와 **사용량** 탭의 통계 아래에 **현재 계정 사용량**이 표시됩니다.
+**계정**과 **API 키** 탭에서는 조회를 지원하는 계정·키마다 할당량과 크레딧 잔액을 볼 수 있습니다.
+풀 전체 용량을 현재 계정 값 대신 보여주지 않으며, 조회 미지원·관측값 없음·조회 중·조회 실패·실제 0%를 구별합니다.
+**할당량 확인 완료**는 조회가 끝났다는 뜻입니다. 패시브 관측값까지 새 값으로 바뀌었다는 뜻은 아닙니다.
+
## 모델 노출
**Models** 스위치는 Codex의 최종 노출 상태를 나타냅니다. 라우팅 모델은 프로바이더 allowlist에 포함되거나 allowlist가 없고, 동시에 비활성화되지 않았을 때만 켜집니다. 모델을 켜면 두 필터를 원자적으로 조정하며, **모두 활성화**는 allowlist를 해제해 새로 발견되는 모델도 켭니다.
diff --git a/gui/src/components/provider-workspace/ProviderAccountQuota.tsx b/gui/src/components/provider-workspace/ProviderAccountQuota.tsx
new file mode 100644
index 0000000000..42f4d12af2
--- /dev/null
+++ b/gui/src/components/provider-workspace/ProviderAccountQuota.tsx
@@ -0,0 +1,30 @@
+import { useT } from "../../i18n/shared";
+import { accountQuotaFromReport } from "../../provider-workspace/report";
+import { formatRelativeTime, relativeTimeLabelsFromT } from "../../provider-workspace/usage";
+import { ProviderCapacityQuota } from "./ProviderCapacityQuota";
+import type { AccountQuotaReading } from "./types";
+
+/** The same reading states and credit/window renderer for current and all-account views. */
+export default function ProviderAccountQuota({ quota: rawQuota, quotaMode, quotaUnavailable, quotaPending }: AccountQuotaReading) {
+ const t = useT();
+ const quota = accountQuotaFromReport({ quota: rawQuota });
+ if (quotaMode === "unsupported") {
+ return
{t("pws.quotaUnsupported")}
;
+ }
+ const pending = quotaMode === "probe" && quotaPending === true;
+ const state = quotaUnavailable ? "unavailable" : pending ? "pending" : quota ? "ready" : quotaMode === "passive" ? "unobserved" : "unknown";
+ return
+ {quotaUnavailable &&
{t("pws.accountQuotaUnavailable")}
}
+ {quota || pending ? (
+
+ ) : !quotaUnavailable && (
+
{t(quotaMode === "passive" ? "pws.quotaUnobserved" : "pws.quotaUnavailable")}
+ )}
+ {quotaUnavailable && quota &&
+ {t("pws.stats.quotaUpdated")}: {formatRelativeTime(quota.updatedAt, relativeTimeLabelsFromT(t))}
+
}
+
;
+}
diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx
index 68f4f1fa13..d475262263 100644
--- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx
+++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx
@@ -20,7 +20,7 @@ import CodexAccountPool from "../CodexAccountPool";
import AnthropicAccountPoolSettings from "./AnthropicAccountPoolSettings";
import { LoginHint as LoginHintView } from "../login-url-block";
import { OpenBrowserPrefToggle } from "../open-browser-pref-toggle";
-import QuotaBars from "../QuotaBars";
+import ProviderAccountQuota from "./ProviderAccountQuota";
import type { CodexAccountPoolController } from "../../hooks/useCodexAccountPool";
import { Switch } from "../../ui";
import type {
@@ -33,7 +33,6 @@ import type {
ProviderUpdateResult,
} from "./types";
-const QUOTA_ENRICH_RESERVE_MS = 4_000;
const COCKPIT_IMPORT_MAX_BYTES = 256 * 1024;
const EMPTY_OAUTH_ACCOUNTS: OAuthAccountRow[] = [];
const EMPTY_API_KEYS: ApiKeyRow[] = [];
@@ -190,54 +189,39 @@ export default function ProviderAuthPanel({
const [importBusy, setImportBusy] = useState(false);
const [importStatus, setImportStatus] = useState<"idle" | "invalid" | "failed" | "complete">("idle");
const [importResult, setImportResult] = useState(null);
- const [reserveQuotaSlots, setReserveQuotaSlots] = useState(false);
const importFileRef = useRef(null);
const [manualCode, setManualCode] = useState("");
const [manualCodeBusy, setManualCodeBusy] = useState(false);
const [manualCodeMsg, setManualCodeMsg] = useState("");
const [manualCodeOk, setManualCodeOk] = useState(true);
- const [refreshingQuota, setRefreshingQuota] = useState(false);
- const [quotaRefreshResult, setQuotaRefreshResult] = useState<{ ok: boolean; text: string } | null>(null);
+ const connectionIdentity = JSON.stringify([apiBase, item.name, accounts.find(account => account.active)?.id, keys.find(key => key.active)?.id]);
+ const [quotaRefreshState, setQuotaRefreshState] = useState<{
+ identity: string; refreshing: boolean; result: { ok: boolean; text: string } | null;
+ }>({ identity: connectionIdentity, refreshing: false, result: null });
+ const refreshingQuota = quotaRefreshState.identity === connectionIdentity && quotaRefreshState.refreshing;
+ const quotaRefreshResult = quotaRefreshState.identity === connectionIdentity ? quotaRefreshState.result : null;
+ const quotaRefreshGeneration = useRef(0);
+ useEffect(() => {
+ quotaRefreshGeneration.current += 1;
+ return () => { quotaRefreshGeneration.current += 1; };
+ }, [connectionIdentity]);
const onRefreshQuota = authHandlers?.onRefreshQuota;
const refreshQuota = async () => {
if (!onRefreshQuota || refreshingQuota) return;
- setRefreshingQuota(true);
+ const generation = ++quotaRefreshGeneration.current;
// Cleared on click so a previous "refreshed" cannot sit under a later failure.
- setQuotaRefreshResult(null);
+ setQuotaRefreshState({ identity: connectionIdentity, refreshing: true, result: null });
try {
const ok = await onRefreshQuota(item.name);
- setQuotaRefreshResult({ ok, text: t(ok ? "codexAuth.quotaRefreshed" : "codexAuth.quotaRefreshFailed") });
+ if (quotaRefreshGeneration.current === generation) setQuotaRefreshState({ identity: connectionIdentity, refreshing: false,
+ result: { ok, text: t(ok ? "pws.quotaCheckCompleted" : "codexAuth.quotaRefreshFailed") } });
} catch {
- setQuotaRefreshResult({ ok: false, text: t("codexAuth.quotaRefreshFailed") });
- } finally {
- setRefreshingQuota(false);
+ if (quotaRefreshGeneration.current === generation) setQuotaRefreshState({ identity: connectionIdentity, refreshing: false,
+ result: { ok: false, text: t("codexAuth.quotaRefreshFailed") } });
}
};
- // Soft "a=1 enrichment lands after the local account list. Reserve stacked
- // bar height briefly so bars don't shove rows when WHAM returns.
- //
- // Deliberately a timed state machine, not a derived value: the reservation must EXPIRE
- // after QUOTA_ENRICH_RESERVE_MS so a stalled enrichment cannot leave skeleton rows up
- // forever. A plain `accounts.some(...)` boolean would drop that bound, so the rule is
- // suppressed here rather than refactored away.
- useEffect(() => {
- if (accounts.length === 0) {
- // eslint-disable-next-line react-hooks/set-state-in-effect, react/react-compiler
- setReserveQuotaSlots(false);
- return;
- }
- const needsFill = accounts.some(a => a.quota == null && !a.quotaUnavailable);
- if (!needsFill) {
- setReserveQuotaSlots(false);
- return;
- }
- setReserveQuotaSlots(true);
- const timer = window.setTimeout(() => setReserveQuotaSlots(false), QUOTA_ENRICH_RESERVE_MS);
- return () => window.clearTimeout(timer);
- }, [accounts]);
-
const surface = providerAuthSurface({ ...item, hasApiKey: item.hasApiKey || keys.length > 0 });
const isOauth = surface === "oauth-accounts";
const isKeyAuth = surface === "api-keys";
@@ -294,6 +278,9 @@ export default function ProviderAuthPanel({
const loggedIn = accounts.length > 0 || oauth?.loggedIn === true;
const activeReauthAccount = accounts.find(a => a.active && a.needsReauth);
const activeNeedsReauth = Boolean(activeReauthAccount);
+ const quotaRows = isOauth ? accounts : keys;
+ const canRefreshQuota = Boolean(onRefreshQuota)
+ && !(quotaRows.length > 0 && quotaRows.every(row => row.quotaMode === "unsupported"));
const submitKey = async () => {
const key = newKey.trim();
@@ -368,7 +355,7 @@ export default function ProviderAuthPanel({
*/}
{isOauth ? t("pws.availableAccounts") : t("pws.apiKeys")}
- {isOauth && loggedIn && onRefreshQuota && (
+ {((isOauth && loggedIn) || isKeyAuth) && canRefreshQuota && (
{quotaRefreshResult && (
@@ -561,25 +548,10 @@ export default function ProviderAuthPanel({
- {(account.quota != null || account.quotaUnavailable || (reserveQuotaSlots && account.quota == null)) && (
-
- {account.quotaUnavailable ? (
-
{t("pws.accountQuotaUnavailable")}
- ) : (
-
- )}
-
- )}
+
);
})}
@@ -594,7 +566,7 @@ export default function ProviderAuthPanel({
onClick={() => void authHandlers.onLogin(item.name, true)} disabled={busy || Boolean(switchingAccountId)}>
{t("pws.addAccount")}
- {onRefreshQuota && (
+ {canRefreshQuota && (
0 && (
{keys.map(entry => (
-
+
+
void authHandlers.onSwitchApiKey(item.name, entry)}
disabled={entry.active}>
@@ -642,6 +615,11 @@ export default function ProviderAuthPanel({
onClick={() => void authHandlers.onRemoveApiKey(item.name, entry)}>
+
+
))}
diff --git a/gui/src/components/provider-workspace/ProviderCurrentQuota.tsx b/gui/src/components/provider-workspace/ProviderCurrentQuota.tsx
new file mode 100644
index 0000000000..157ad4e8e4
--- /dev/null
+++ b/gui/src/components/provider-workspace/ProviderCurrentQuota.tsx
@@ -0,0 +1,57 @@
+import { useState } from "react";
+import { useT } from "../../i18n/shared";
+import { IconRefresh } from "../../icons";
+import { accountQuotaFromReport, currentAccountQuotaReport, formatQuotaSourceLabel, type ProviderQuotaReportView } from "../../provider-workspace/report";
+import { formatRelativeTime, relativeTimeLabelsFromT } from "../../provider-workspace/usage";
+import ProviderAccountQuota from "./ProviderAccountQuota";
+import type { AccountQuotaReading } from "./types";
+
+export default function ProviderCurrentQuota({ report, reading, onRefreshQuota }: {
+ report?: ProviderQuotaReportView;
+ reading?: AccountQuotaReading;
+ onRefreshQuota?: () => Promise;
+}) {
+ const t = useT();
+ const current = currentAccountQuotaReport(report);
+ const rowOwnsReading = reading !== undefined && (
+ reading.quotaMode !== undefined || reading.quota !== undefined
+ || reading.quotaUnavailable !== undefined || reading.quotaPending !== undefined
+ );
+ const effective: AccountQuotaReading = rowOwnsReading ? reading : {
+ quota: accountQuotaFromReport(current),
+ ...(current?.observed === true ? { quotaMode: "passive" } : {}),
+ };
+ const quota = accountQuotaFromReport({ quota: effective.quota });
+ const [refreshing, setRefreshing] = useState(false);
+ const [result, setResult] = useState(null);
+ const refresh = async () => {
+ if (!onRefreshQuota || refreshing) return;
+ setRefreshing(true);
+ setResult(null);
+ try { setResult(await onRefreshQuota()); }
+ catch { setResult(false); }
+ finally { setRefreshing(false); }
+ };
+ return
+
+
{t("pws.currentAccountUsage")}
+ {onRefreshQuota && effective.quotaMode !== "unsupported" &&
+ {result !== null &&
+ {t(result ? "pws.quotaCheckCompleted" : "codexAuth.quotaRefreshFailed")}
+ }
+ { void refresh(); }}>
+ {" "}
+ {t(refreshing ? "codexAuth.refreshingQuota" : "codexAuth.refreshQuota")}
+
+
}
+
+
+ {quota && !effective.quotaUnavailable && effective.quotaMode !== "unsupported" &&
+ {!rowOwnsReading && current?.source?.trim() &&
+
{t("pws.stats.source")} {formatQuotaSourceLabel(current.source)}
+ }
+
{t("pws.stats.quotaUpdated")}
+ {formatRelativeTime(quota.updatedAt, relativeTimeLabelsFromT(t))}
+ }
+ ;
+}
diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx
index 78590390c1..645511f69d 100644
--- a/gui/src/components/provider-workspace/ProviderDetails.tsx
+++ b/gui/src/components/provider-workspace/ProviderDetails.tsx
@@ -110,6 +110,9 @@ export default function ProviderDetails({
const free = useMemo(() => isFreeProvider(item), [item]);
const local = useMemo(() => isLocalProvider(item), [item]);
const authSurface = useMemo(() => providerAuthSurface(item), [item]);
+ const currentQuotaReading = authSurface === "oauth-accounts"
+ ? accounts?.find(account => account.active)
+ : authSurface === "api-keys" ? keys?.find(entry => entry.active) : undefined;
// Global counter from Providers — only honor it for the reveal target.
const scopedAccountsFocusToken = accountsFocusProvider === item.name ? accountsFocusToken : 0;
const connectionIdentity = JSON.stringify([
@@ -256,6 +259,8 @@ export default function ProviderDetails({
connectionIdentity={connectionIdentity}
usageTotals={usageTotals}
quotaReport={quotaReport}
+ currentQuotaReading={currentQuotaReading}
+ onRefreshQuota={onRefreshQuota}
oauthEmail={oauthEmail}
oauth={oauth}
onEditSettings={() => switchTab("settings")}
@@ -303,6 +308,8 @@ export default function ProviderDetails({
item={item}
usageTotals={usageTotals}
quotaReport={quotaReport}
+ currentQuotaReading={currentQuotaReading}
+ quotaIdentity={connectionIdentity}
modelUsage={modelUsage}
{...(onRefreshQuota ? { onRefreshQuota } : {})}
/>
diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx
index 5c5e7468cf..6598f43b07 100644
--- a/gui/src/components/provider-workspace/ProviderOverview.tsx
+++ b/gui/src/components/provider-workspace/ProviderOverview.tsx
@@ -7,12 +7,12 @@ import { readJsonOrThrow } from "../../fetch-json";
import { useT, useI18n } from "../../i18n/shared";
import { IconAlert, IconCheck } from "../../icons";
import { binProviderStatus, type WorkspaceItem } from "../../provider-workspace/catalog";
-import { formatRelativeTime, relativeTimeLabelsFromT, formatRequestCount, formatTokenCount } from "../../provider-workspace/usage";
-import { accountQuotaFromReport, formatQuotaSourceLabel, type ProviderQuotaReportView } from "../../provider-workspace/report";
-import type { ProviderUsageTotals } from "./types";
+import { formatRequestCount, formatTokenCount } from "../../provider-workspace/usage";
+import type { ProviderQuotaReportView } from "../../provider-workspace/report";
+import type { AccountQuotaReading, ProviderUsageTotals } from "./types";
import { authModeLabel } from "./ProviderRail";
import type { ProviderUpdatePatch, ProviderUpdateResult } from "./types";
-import { ProviderCapacityQuota } from "./ProviderCapacityQuota";
+import ProviderCurrentQuota from "./ProviderCurrentQuota";
type ConnectionTestResult = {
applicable?: boolean;
@@ -30,7 +30,7 @@ type ConnectionTestState = {
};
export default function ProviderOverview({
- item, usageTotals, quotaReport, oauthEmail, oauth,
+ item, usageTotals, quotaReport, currentQuotaReading, onRefreshQuota, oauthEmail, oauth,
apiBase, connectionIdentity,
onEditSettings, onViewUsage, onUpdateProvider,
onReauthenticate, onCancelLogin, reauthBusy = false,
@@ -38,6 +38,8 @@ export default function ProviderOverview({
item: WorkspaceItem;
usageTotals?: ProviderUsageTotals;
quotaReport?: ProviderQuotaReportView;
+ currentQuotaReading?: AccountQuotaReading;
+ onRefreshQuota?: () => Promise;
oauthEmail?: string;
/** Login state for OAuth summaries that carry no email (e.g. Cursor/Kimi). */
oauth?: { loggedIn?: boolean };
@@ -53,7 +55,6 @@ export default function ProviderOverview({
}) {
const t = useT();
const { locale } = useI18n();
- const timeLabels = relativeTimeLabelsFromT(t);
const status = binProviderStatus(item);
const needsAttention = Boolean(item.activeNeedsReauth);
const statusText = status === "ready"
@@ -63,7 +64,6 @@ export default function ProviderOverview({
: t("prov.disabledBadge");
const requests = usageTotals?.requests;
const tokens = usageTotals?.totalTokens;
- const quota = accountQuotaFromReport(quotaReport);
const connectionProbeKey = JSON.stringify([
apiBase ?? null,
item.name,
@@ -201,13 +201,6 @@ export default function ProviderOverview({
)}
- {quotaReport && (
-
- {t("pws.rateLimits")}
-
-
- )}
-
{t("pws.authSummary")}
{needsAttention ? (
@@ -275,18 +268,7 @@ export default function ProviderOverview({
{formatTokenCount(tokens, locale)}
)}
- {quotaReport && (
-
-
{t("pws.stats.quotaUpdated")}
-
- {formatRelativeTime(quotaReport.updatedAt, timeLabels)}
-
-
- )}
- {typeof requests !== "number" && typeof tokens !== "number" && !quotaReport && (
+ {typeof requests !== "number" && typeof tokens !== "number" && (
{t("pws.usageUnavailable")}
)}
@@ -295,9 +277,9 @@ export default function ProviderOverview({
{t("pws.viewUsage")} →
)}
- {quota && {t("pws.stats.quotaTracked")}
}
+
diff --git a/gui/src/components/provider-workspace/ProviderUsage.tsx b/gui/src/components/provider-workspace/ProviderUsage.tsx
index 8fc802ba08..c598f07233 100644
--- a/gui/src/components/provider-workspace/ProviderUsage.tsx
+++ b/gui/src/components/provider-workspace/ProviderUsage.tsx
@@ -4,48 +4,26 @@
*/
import { Fragment, useMemo, useState } from "react";
import { useT, useI18n } from "../../i18n/shared";
-import { IconRefresh } from "../../icons";
-import QuotaBars from "../QuotaBars";
import type { WorkspaceItem } from "../../provider-workspace/catalog";
-import { formatRelativeTime, relativeTimeLabelsFromT, formatRequestCount, formatTokenCount, formatCostUsd } from "../../provider-workspace/usage";
-import { accountQuotaFromReport, formatQuotaSourceLabel, observedAtFromReport, type ProviderQuotaReportView } from "../../provider-workspace/report";
-import type { ProviderUsageTotals, ProviderModelUsageRow } from "./types";
+import { formatRequestCount, formatTokenCount, formatCostUsd } from "../../provider-workspace/usage";
+import type { ProviderQuotaReportView } from "../../provider-workspace/report";
+import type { AccountQuotaReading, ProviderUsageTotals, ProviderModelUsageRow } from "./types";
+import ProviderCurrentQuota from "./ProviderCurrentQuota";
-export default function ProviderUsage({ item, usageTotals, quotaReport, modelUsage, onRefreshQuota }: {
+export default function ProviderUsage({ item, usageTotals, quotaReport, currentQuotaReading, quotaIdentity, modelUsage, onRefreshQuota }: {
item: WorkspaceItem;
usageTotals?: ProviderUsageTotals;
quotaReport?: ProviderQuotaReportView;
+ currentQuotaReading?: AccountQuotaReading;
+ quotaIdentity?: string;
modelUsage?: ProviderModelUsageRow[];
/** Force a fresh quota read; omitted when the page cannot drive one. */
onRefreshQuota?: () => Promise;
}) {
const t = useT();
const { locale } = useI18n();
- const timeLabels = relativeTimeLabelsFromT(t);
const hasUsage = usageTotals?.requests !== undefined;
- const quota = accountQuotaFromReport(quotaReport);
- // Passive providers only. An age line beside a probed number would be noise; beside an
- // observation it is the difference between a live reading and a remembered one.
- const observedAt = observedAtFromReport(quotaReport);
const [expandedModel, setExpandedModel] = useState(null);
- const [refreshingQuota, setRefreshingQuota] = useState(false);
- const [refreshResult, setRefreshResult] = useState<{ ok: boolean; text: string } | null>(null);
- void item;
-
- const refreshQuota = async () => {
- if (!onRefreshQuota || refreshingQuota) return;
- setRefreshingQuota(true);
- // Cleared on click so a previous "refreshed" cannot sit under a later failure.
- setRefreshResult(null);
- try {
- const ok = await onRefreshQuota();
- setRefreshResult({ ok, text: t(ok ? "codexAuth.quotaRefreshed" : "codexAuth.quotaRefreshFailed") });
- } catch {
- setRefreshResult({ ok: false, text: t("codexAuth.quotaRefreshFailed") });
- } finally {
- setRefreshingQuota(false);
- }
- };
const sortedModels = useMemo(() => {
if (!modelUsage?.length) return [];
@@ -160,58 +138,7 @@ export default function ProviderUsage({ item, usageTotals, quotaReport, modelUsa
)}
-
-
-
{t("pws.rateLimits")}
- {onRefreshQuota && (
- // Rendered even when there is no quota to show: "nothing here" is exactly when
- // an operator wants to retry.
-
- {refreshResult && (
-
- {refreshResult.text}
-
- )}
- { void refreshQuota(); }}
- >
-
- {" "}
- {refreshingQuota ? t("codexAuth.refreshingQuota") : t("codexAuth.refreshQuota")}
-
-
- )}
-
- {quota ? (
- <>
-
-
- {quotaReport?.source?.trim() && (
-
-
{t("pws.stats.source")}
- {formatQuotaSourceLabel(quotaReport.source)}
-
- )}
-
-
{t("pws.stats.quotaUpdated")}
- {formatRelativeTime(quotaReport?.updatedAt, timeLabels)}
-
-
- >
- ) : (
-
{t("pws.quotaUnavailable")}
- )}
-
+
);
}
diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx
index a6ad7fee99..91faecb6fc 100644
--- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx
+++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx
@@ -6,6 +6,7 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useKeyedClientResource } from "../../client-resource";
+import { createBoundedFetch } from "../../bounded-fetch";
import { usageSummary30dResourceKey } from "../../usage-summary-resource";
import { useT } from "../../i18n/shared";
import { IconFilter, IconSearch, IconBoxes, IconGlobe, IconLock, IconKey, IconTrash } from "../../icons";
@@ -117,7 +118,7 @@ export default function ProviderWorkspaceShell({
* report success before the response landed — `fetchProviderQuotas(true)` is a
* synchronous state bump, not a request.
*/
- onQuotaRefreshSettled?: (ok: boolean) => void;
+ onQuotaRefreshSettled?: (ok: boolean, epoch: number) => void;
/** True when the bump came from a mutation that needs the server to bypass its TTL. */
quotaForceRefresh?: boolean;
/**
@@ -231,7 +232,9 @@ export default function ProviderWorkspaceShell({
// A forced bump means a mutation just changed the answer, so the server's TTL has to
// be bypassed. The old derived-key effect always read the cached view, which is why a
// switch could leave the bars showing the previous account's quota.
- void fetch(`${apiBase}/api/provider-quotas${quotaForceRefresh ? "?refresh=1" : ""}`)
+ const bounded = createBoundedFetch(20_000);
+ abortRead = () => { bounded.controller.abort(); bounded.clear(); };
+ void fetch(`${apiBase}/api/provider-quotas${quotaForceRefresh ? "?refresh=1" : ""}`, { signal: bounded.signal })
.then(r => readJsonIfOk<{ reports?: Array<{ provider: string; label?: string; source?: string; updatedAt?: number; quota?: unknown; observed?: boolean; aggregation?: unknown }> }>(r))
.then((data) => {
if (cancelled) return;
@@ -239,7 +242,7 @@ export default function ProviderWorkspaceShell({
// That is a FAILED refresh, and it must be reported: returning silently here
// would leave an operator's button spinning until the component unmounted.
if (!data) {
- if (quotaForceRefresh) onQuotaRefreshSettled?.(false);
+ if (quotaForceRefresh) onQuotaRefreshSettled?.(false, quotaRefreshEpoch);
return;
}
// A successful endpoint response is authoritative, including an empty report list.
@@ -247,7 +250,7 @@ export default function ProviderWorkspaceShell({
setQuotaReports(next);
writeSessionListCache(quotasCacheKey, next);
// Report only for a forced read: an ordinary revalidation has no operator waiting on it.
- if (quotaForceRefresh) onQuotaRefreshSettled?.(true);
+ if (quotaForceRefresh) onQuotaRefreshSettled?.(true, quotaRefreshEpoch);
})
.catch(() => {
if (cancelled) return;
@@ -257,13 +260,15 @@ export default function ProviderWorkspaceShell({
writeSessionListCache(quotasCacheKey, next);
return next;
});
- if (quotaForceRefresh) onQuotaRefreshSettled?.(false);
+ if (quotaForceRefresh) onQuotaRefreshSettled?.(false, quotaRefreshEpoch);
})
- .finally(() => { if (!cancelled) setQuotasLoading(false); });
+ .finally(() => { bounded.clear(); if (!cancelled) setQuotasLoading(false); });
}, 0);
+ let abortRead: (() => void) | undefined;
return () => {
cancelled = true;
window.clearTimeout(timeout);
+ abortRead?.();
};
// Keyed on the explicit revision: account arrival is silent, real mutations re-read.
}, [apiBase, quotaRefreshEpoch, quotaForceRefresh, quotasCacheKey, onQuotaRefreshSettled]);
diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts
index f329e60ce3..e722d10dd7 100644
--- a/gui/src/components/provider-workspace/types.ts
+++ b/gui/src/components/provider-workspace/types.ts
@@ -41,7 +41,16 @@ export interface ProviderModelUsageRow {
// Auth types consumed by ProviderAuthPanel (WP091).
export type OAuthAccountHealthStatus = "healthy" | "cooldown" | "reauth_required" | "warning";
-export type OAuthAccountRow = {
+export type AccountQuotaMode = "probe" | "passive" | "unsupported";
+export interface AccountQuotaReading {
+ quotaMode?: AccountQuotaMode;
+ quota?: AccountQuota | null;
+ quotaUnavailable?: boolean;
+ /** Client-owned enrichment state, never inferred from missing quota data. */
+ quotaPending?: boolean;
+}
+
+export type OAuthAccountRow = AccountQuotaReading & {
id: string;
alias?: string;
email?: string;
@@ -51,12 +60,9 @@ export type OAuthAccountRow = {
healthLabel?: string;
healthSummary?: string;
healthAction?: string;
- /** Per-account rate limits, for providers that report usage per credential (anthropic). */
- quota?: AccountQuota | null;
- quotaUnavailable?: boolean;
};
-export type ApiKeyRow = {
+export type ApiKeyRow = AccountQuotaReading & {
id: string;
label?: string;
masked: string;
diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts
index 317f4bffb9..255ad4dfbe 100644
--- a/gui/src/hooks/useProviderAccountPools.ts
+++ b/gui/src/hooks/useProviderAccountPools.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
-import type { AccountLoadState } from "../components/provider-workspace/types";
+import type { AccountLoadState, AccountQuotaReading } from "../components/provider-workspace/types";
+import { createBoundedFetch } from "../bounded-fetch";
import { accountNeedsReauth } from "../oauth-health-display";
-import type { AccountQuota } from "../codex-quota-utils";
import { oauthAccountDisplayLabel } from "../provider-workspace/auth";
export interface Config {
@@ -11,7 +11,7 @@ export interface Config {
}
export interface OAuthStatus { loggedIn: boolean; email?: string; error?: string; done?: boolean; needsReauth?: boolean; activeAccountId?: string | null }
-export interface OAuthAccount {
+export interface OAuthAccount extends AccountQuotaReading {
id: string;
alias?: string;
email?: string;
@@ -22,12 +22,36 @@ export interface OAuthAccount {
healthLabel?: string;
healthSummary?: string;
healthAction?: string;
- /** Per-account rate limits (providers that report usage per credential, e.g. anthropic). */
- quota?: AccountQuota | null;
- /** Set when the per-account probe could not reach upstream (expired login, 429, network). */
- quotaUnavailable?: boolean;
}
-export interface ApiKeyEntry { id: string; label?: string; masked: string; active: boolean }
+export interface ApiKeyEntry extends AccountQuotaReading { id: string; label?: string; masked: string; active: boolean }
+
+type QuotaRow = AccountQuotaReading & { id: string };
+const supportsQuotaRead = (row: AccountQuotaReading) => row.quotaMode === "probe" || row.quotaMode === "passive";
+
+function mergeQuotaRows(rows: T[], previous: T[], enriched: boolean): T[] {
+ const prior = new Map(previous.map(row => [row.id, row]));
+ return rows.map(row => {
+ const supported = supportsQuotaRead(row);
+ // Legacy/unknown mode must not acquire synthetic flags that would override
+ // a provider report or imply that a quota probe is supported.
+ if (!supported && row.quotaMode !== "unsupported") return { ...row, quotaMode: undefined, quotaPending: undefined };
+ // Only surviving credential IDs can retain omitted data. Explicit null is an
+ // authoritative invalidation, including failed/expired credential readings.
+ const retain = supported && (!enriched || row.quotaUnavailable === true);
+ return {
+ ...row,
+ quota: row.quotaMode === "unsupported" ? null : row.quota !== undefined ? row.quota : retain ? prior.get(row.id)?.quota : undefined,
+ quotaPending: !enriched && row.quotaMode === "probe",
+ quotaUnavailable: enriched ? row.quotaUnavailable === true : false,
+ };
+ });
+}
+
+function unavailableQuotaRows(rows: T[]): T[] {
+ return rows.map(row => supportsQuotaRead(row)
+ ? { ...row, quotaUnavailable: true, quotaPending: false }
+ : row);
+}
/** Pure aggregate map used by Providers overview / rail attention state. */
export function buildActiveAccountNeedsReauthMap(
@@ -67,13 +91,51 @@ export function useProviderAccountPools(deps: {
const [addingKeyFor, setAddingKeyFor] = useState(null);
const [newKeyValue, setNewKeyValue] = useState("");
const accountRequestGenerationRef = useRef>({});
+ const requestsRef = useRef(new Set());
+ const mountedRef = useRef(true);
+ const serverRef = useRef(apiBase);
+ useEffect(() => {
+ const generations = accountRequestGenerationRef.current;
+ const requests = requestsRef.current;
+ mountedRef.current = true;
+ const serverChanged = serverRef.current !== apiBase;
+ serverRef.current = apiBase;
+ if (serverChanged) void Promise.resolve().then(() => {
+ if (!mountedRef.current || serverRef.current !== apiBase) return;
+ setAccountSets({});
+ setKeyPools({});
+ setAccountLoadStates({});
+ });
+ return () => {
+ mountedRef.current = false;
+ for (const key of Object.keys(generations)) generations[key] += 1;
+ for (const controller of requests) controller.abort();
+ requests.clear();
+ };
+ }, [apiBase]);
// Provider lists this instance has already fetched for. The deferred loads below are deliberately
// uncancellable, and StrictMode double-invokes their effects, so dedupe by list identity here.
const accountSetsKeyRef = useRef(null);
const keyPoolsKeyRef = useRef(null);
const switchingAccountRef = useRef<{ provider: string; accountId: string } | null>(null);
- const fetchAccountSets = useCallback(async (providers: string[]) => {
+ const readRoster = useCallback(async (url: string): Promise => {
+ const bounded = createBoundedFetch(20_000);
+ requestsRef.current.add(bounded.controller);
+ try {
+ const response = await fetch(url, { signal: bounded.signal });
+ if (!response.ok) throw new Error(String(response.status));
+ const data = await response.json() as T;
+ if (bounded.signal.aborted) throw new Error("Quota roster deadline exceeded");
+ return data;
+ } finally {
+ bounded.clear();
+ requestsRef.current.delete(bounded.controller);
+ }
+ }, []);
+
+ const fetchAccountSets = useCallback(async (providers: string[], refresh = false): Promise => {
+ if (!aliveRef.current || !mountedRef.current || serverRef.current !== apiBase) return false;
const uniqueProviders = [...new Set(providers)];
setAccountLoadStates(current => {
const next = { ...current };
@@ -81,54 +143,99 @@ export function useProviderAccountPools(deps: {
return next;
});
const results = await Promise.all(uniqueProviders.map(async provider => {
- const generation = (accountRequestGenerationRef.current[provider] ?? 0) + 1;
- accountRequestGenerationRef.current[provider] = generation;
+ const key = `oauth:${provider}`;
+ const generation = (accountRequestGenerationRef.current[key] ?? 0) + 1;
+ accountRequestGenerationRef.current[key] = generation;
+ const currentRequest = () => aliveRef.current && mountedRef.current && serverRef.current === apiBase && accountRequestGenerationRef.current[key] === generation;
+ const url = `${apiBase}/api/oauth/accounts?provider=${encodeURIComponent(provider)}`;
try {
// Cheap local read first so account switch / reauth / remove controls appear
// even when Anthropic's usage endpoint is slow or timing out.
- const res = await fetch(`${apiBase}/api/oauth/accounts?provider=${encodeURIComponent(provider)}`);
- if (!res.ok) throw new Error(String(res.status));
- const data = await res.json() as { activeAccountId?: string | null; accounts?: OAuthAccount[] };
- if (!aliveRef.current || accountRequestGenerationRef.current[provider] !== generation) return true;
- setAccountSets(current => ({ ...current, [provider]: { activeAccountId: data.activeAccountId ?? null, accounts: data.accounts ?? [] } }));
- setAccountLoadStates(current => ({ ...current, [provider]: "ready" }));
+ const data = await readRoster<{ activeAccountId?: string | null; accounts?: OAuthAccount[] }>(url);
+ if (!Array.isArray(data.accounts)) throw new Error("Invalid account roster");
+ if (!currentRequest()) return false;
+ const rows = data.accounts;
+ setAccountSets(current => currentRequest() ? { ...current, [provider]: {
+ activeAccountId: data.activeAccountId ?? null,
+ accounts: mergeQuotaRows(rows, current[provider]?.accounts ?? [], false),
+ } } : current);
+ setAccountLoadStates(current => currentRequest() ? { ...current, [provider]: "ready" } : current);
+ if (!rows.some(supportsQuotaRead)) return true;
- // Enrich with per-account rate limits asynchronously (Anthropic reports usage
- // per credential). Failures leave the already-ready account rows untouched.
- void (async () => {
+ const enrich = async (): Promise => {
try {
- const quotaRes = await fetch(`${apiBase}/api/oauth/accounts?provider=${encodeURIComponent(provider)}"a=1`);
- if (!quotaRes.ok) return;
- const quotaData = await quotaRes.json() as { activeAccountId?: string | null; accounts?: OAuthAccount[] };
- if (!aliveRef.current || accountRequestGenerationRef.current[provider] !== generation) return;
- setAccountSets(current => ({
+ const quotaData = await readRoster<{ activeAccountId?: string | null; accounts?: OAuthAccount[] }>(`${url}"a=1${refresh ? "&refresh=1" : ""}`);
+ if (!Array.isArray(quotaData.accounts)) throw new Error("Invalid account quota roster");
+ if (!currentRequest()) return false;
+ const enriched = quotaData.accounts;
+ setAccountSets(current => currentRequest() ? {
...current,
[provider]: {
activeAccountId: quotaData.activeAccountId ?? data.activeAccountId ?? null,
- accounts: quotaData.accounts ?? data.accounts ?? [],
+ accounts: mergeQuotaRows(enriched, current[provider]?.accounts ?? [], true),
},
- }));
+ } : current);
+ return !enriched.some(row => row.quotaUnavailable === true);
} catch {
- /* keep local account rows without quota enrichment */
+ if (!currentRequest()) return false;
+ setAccountSets(current => currentRequest() && current[provider] ? {
+ ...current, [provider]: { ...current[provider], accounts: unavailableQuotaRows(current[provider].accounts) },
+ } : current);
+ return false;
}
- })();
+ };
+ if (refresh) return await enrich();
+ void enrich();
return true;
} catch {
- if (!aliveRef.current || accountRequestGenerationRef.current[provider] !== generation) return true;
- setAccountLoadStates(current => ({ ...current, [provider]: "error" }));
+ if (!currentRequest()) return false;
+ setAccountLoadStates(current => currentRequest() ? { ...current, [provider]: "error" } : current);
+ setAccountSets(current => currentRequest() && current[provider] ? {
+ ...current, [provider]: { ...current[provider], accounts: unavailableQuotaRows(current[provider].accounts) },
+ } : current);
return false;
}
}));
return results.every(Boolean);
- }, [aliveRef, apiBase]);
+ }, [aliveRef, apiBase, readRoster]);
- const fetchKeyPools = useCallback(async (providers: string[]) => {
- const entries = await Promise.all(providers.map(async name => {
- const data = await fetch(`${apiBase}/api/providers/keys?name=${encodeURIComponent(name)}`).then(async r => { if (!r.ok) throw new Error(String(r.status)); return r.json(); }).catch(() => null) as { keys?: ApiKeyEntry[] } | null;
- return [name, data?.keys ?? []] as const;
+ const fetchKeyPools = useCallback(async (providers: string[], refresh = false): Promise => {
+ if (!aliveRef.current || !mountedRef.current || serverRef.current !== apiBase) return false;
+ const results = await Promise.all([...new Set(providers)].map(async name => {
+ const key = `key:${name}`;
+ const generation = (accountRequestGenerationRef.current[key] ?? 0) + 1;
+ accountRequestGenerationRef.current[key] = generation;
+ const currentRequest = () => aliveRef.current && mountedRef.current && serverRef.current === apiBase && accountRequestGenerationRef.current[key] === generation;
+ const url = `${apiBase}/api/providers/keys?name=${encodeURIComponent(name)}`;
+ const failed = () => {
+ if (currentRequest()) setKeyPools(current => currentRequest()
+ ? { ...current, [name]: unavailableQuotaRows(current[name] ?? []) } : current);
+ return false;
+ };
+ try {
+ const data = await readRoster<{ keys?: ApiKeyEntry[] }>(url);
+ if (!Array.isArray(data.keys)) throw new Error("Invalid key roster");
+ if (!currentRequest()) return false;
+ const rows = data.keys;
+ setKeyPools(current => currentRequest() ? { ...current, [name]: mergeQuotaRows(rows, current[name] ?? [], false) } : current);
+ if (!rows.some(supportsQuotaRead)) return true;
+ const enrich = async (): Promise => {
+ try {
+ const data = await readRoster<{ keys?: ApiKeyEntry[] }>(`${url}"a=1${refresh ? "&refresh=1" : ""}`);
+ if (!Array.isArray(data.keys)) throw new Error("Invalid key quota roster");
+ if (!currentRequest()) return false;
+ const enriched = data.keys;
+ setKeyPools(current => currentRequest() ? { ...current, [name]: mergeQuotaRows(enriched, current[name] ?? [], true) } : current);
+ return !enriched.some(row => row.quotaUnavailable === true);
+ } catch { return failed(); }
+ };
+ if (refresh) return await enrich();
+ void enrich();
+ return true;
+ } catch { return failed(); }
}));
- setKeyPools(Object.fromEntries(entries));
- }, [apiBase]);
+ return results.every(Boolean);
+ }, [apiBase, aliveRef, readRoster]);
const switchAccount = async (provider: string, account: OAuthAccount) => {
if (account.active || account.needsReauth || switchingAccountRef.current) return;
@@ -249,11 +356,11 @@ export function useProviderAccountPools(deps: {
// guaranteeing the request goes out.
// Keyed on the provider list because this effect re-runs whenever that memo changes, and
// StrictMode double-invokes it on mount; an uncancellable microtask would otherwise duplicate.
- const key = oauthCardProviders.join(",");
+ const key = `${apiBase}:${oauthCardProviders.join(",")}`;
if (accountSetsKeyRef.current === key) return;
accountSetsKeyRef.current = key;
void Promise.resolve().then(() => { void fetchAccountSets(oauthCardProviders); });
- }, [fetchAccountSets, oauthCardProviders]);
+ }, [apiBase, fetchAccountSets, oauthCardProviders]);
const keyCardProviders = useMemo(
() => config ? Object.entries(config.providers).filter(([, p]) => p.hasApiKey && p.authMode !== "oauth" && p.authMode !== "forward").map(([n]) => n) : [],
@@ -261,11 +368,11 @@ export function useProviderAccountPools(deps: {
);
useEffect(() => {
if (keyCardProviders.length === 0) return;
- const key = keyCardProviders.join(",");
+ const key = `${apiBase}:${keyCardProviders.join(",")}`;
if (keyPoolsKeyRef.current === key) return;
keyPoolsKeyRef.current = key;
void Promise.resolve().then(() => { void fetchKeyPools(keyCardProviders); });
- }, [fetchKeyPools, keyCardProviders]);
+ }, [apiBase, fetchKeyPools, keyCardProviders]);
const activeAccountNeedsReauth = useMemo(
() => buildActiveAccountNeedsReauthMap(accountSets, codexActiveNeedsReauth),
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index a196fd215a..1ee335120d 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -1910,6 +1910,10 @@ export const de: Record = {
"pws.estimatedCost": "Geschätzte Kosten",
"pws.costDisclaimer": "Schätzung basierend auf API-Listenpreisen, keine tatsächliche Abrechnung.",
"pws.unresolvedRequestedModel": "Enthält Nutzung eines nicht aufgelösten angefragten Modells",
+ "pws.currentAccountUsage": "Nutzung des aktuellen Kontos",
+ "pws.quotaUnsupported": "Für dieses Konto ist keine Kontingentabfrage verfügbar.",
+ "pws.quotaUnobserved": "Noch keine Nutzungsdaten beobachtet.",
+ "pws.quotaCheckCompleted": "Kontingentprüfung abgeschlossen",
"pws.modelBreakdown": "Modellaufschlüsselung",
"pws.col.model": "Modell",
"pws.col.cost": "Gesch. Kosten",
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index 88ffb02908..addd46b549 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -1175,6 +1175,10 @@ export const en = {
"pws.estimatedCost": "Estimated cost",
"pws.costDisclaimer": "API list-price estimate, not an actual charge.",
"pws.unresolvedRequestedModel": "Includes unresolved requested model usage",
+ "pws.currentAccountUsage": "Current account usage",
+ "pws.quotaUnsupported": "Quota lookup is not supported for this account.",
+ "pws.quotaUnobserved": "No usage observation yet.",
+ "pws.quotaCheckCompleted": "Quota check completed",
"pws.modelBreakdown": "Model breakdown",
"pws.col.model": "Model",
"pws.col.cost": "Est. cost",
diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts
index 14532847f1..57b74dbe23 100644
--- a/gui/src/i18n/fr.ts
+++ b/gui/src/i18n/fr.ts
@@ -1148,6 +1148,10 @@ export const fr: Record = {
"pws.estimatedCost": "Coût estimé",
"pws.costDisclaimer": "Estimation fondée sur le tarif public de l’API, et non montant réellement facturé.",
"pws.unresolvedRequestedModel": "Inclut l’utilisation d’un modèle demandé non résolu",
+ "pws.currentAccountUsage": "Utilisation du compte actuel",
+ "pws.quotaUnsupported": "La consultation du quota n’est pas prise en charge pour ce compte.",
+ "pws.quotaUnobserved": "Aucune utilisation observée pour le moment.",
+ "pws.quotaCheckCompleted": "Vérification du quota terminée",
"pws.modelBreakdown": "Répartition par modèle",
"pws.col.model": "Modèle",
"pws.col.cost": "Coût est.",
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index e347babd59..fd15b0aeef 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -2336,6 +2336,10 @@ export const ja: Record = {
"pws.estimatedCost": "Estimated cost",
"pws.costDisclaimer": "API list-price estimate, not an actual charge.",
"pws.unresolvedRequestedModel": "要求モデルを特定せず既定プロバイダーで処理した使用量を含む",
+ "pws.currentAccountUsage": "現在のアカウントの使用量",
+ "pws.quotaUnsupported": "このアカウントは割り当て量の照会に対応していません。",
+ "pws.quotaUnobserved": "使用量はまだ観測されていません。",
+ "pws.quotaCheckCompleted": "割り当て量の確認が完了しました",
"pws.modelBreakdown": "Model breakdown",
"pws.col.model": "Model",
"pws.col.cost": "Est. cost",
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index 29a937ffd7..f320b5785a 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -1937,6 +1937,10 @@ export const ko: Record = {
"pws.estimatedCost": "추정 비용",
"pws.costDisclaimer": "API 공시가 기준 추정치이며, 실제 청구 금액이 아닙니다.",
"pws.unresolvedRequestedModel": "기본 경로 요청 포함 · 실제 모델 미확인",
+ "pws.currentAccountUsage": "현재 계정 사용량",
+ "pws.quotaUnsupported": "이 계정은 할당량 조회를 지원하지 않습니다.",
+ "pws.quotaUnobserved": "아직 관측된 사용량이 없습니다.",
+ "pws.quotaCheckCompleted": "할당량 확인 완료",
"pws.modelBreakdown": "모델별 사용량",
"pws.col.model": "모델",
"pws.col.cost": "추정 비용",
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index bfd2e086c3..6df77b0a79 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -1159,6 +1159,10 @@ export const ru: Record = {
"pws.estimatedCost": "Ориентировочная стоимость",
"pws.costDisclaimer": "Оценка на основе публичных цен API, не фактический счёт.",
"pws.unresolvedRequestedModel": "Включает запросы с неразрешённым именем модели",
+ "pws.currentAccountUsage": "Использование текущего аккаунта",
+ "pws.quotaUnsupported": "Запрос квоты для этого аккаунта не поддерживается.",
+ "pws.quotaUnobserved": "Данных о наблюдаемом использовании пока нет.",
+ "pws.quotaCheckCompleted": "Проверка квоты завершена",
"pws.modelBreakdown": "Разбивка по моделям",
"pws.col.model": "Модель",
"pws.col.cost": "Ориент. стоимость",
diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts
index c55fba1651..7b1f913c45 100644
--- a/gui/src/i18n/tr.ts
+++ b/gui/src/i18n/tr.ts
@@ -1166,6 +1166,10 @@ export const tr: Record = {
"pws.estimatedCost": "Tahmini maliyet",
"pws.costDisclaimer": "API liste fiyatı tahminidir.",
"pws.unresolvedRequestedModel": "Çözümlenemeyen istenen model kullanımını içerir",
+ "pws.currentAccountUsage": "Geçerli hesabın kullanımı",
+ "pws.quotaUnsupported": "Bu hesap için kota sorgulama desteklenmiyor.",
+ "pws.quotaUnobserved": "Henüz kullanım gözlemi yok.",
+ "pws.quotaCheckCompleted": "Kota kontrolü tamamlandı",
"pws.modelBreakdown": "Model dağılımı",
"pws.col.model": "Model",
"pws.col.cost": "Tahm. maliyet",
diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts
index 7f57a4d45f..ec764ebda2 100644
--- a/gui/src/i18n/zh-TW.ts
+++ b/gui/src/i18n/zh-TW.ts
@@ -959,6 +959,10 @@ export const zhTW: Record = {
"pws.estimatedCost": "預估費用",
"pws.costDisclaimer": "基於 API 公示價格的預估值,非實際計費金額。",
"pws.unresolvedRequestedModel": "包含未解析請求模型、由預設供應商處理的用量",
+ "pws.currentAccountUsage": "目前帳戶用量",
+ "pws.quotaUnsupported": "此帳戶不支援查詢配額。",
+ "pws.quotaUnobserved": "尚未觀測到用量。",
+ "pws.quotaCheckCompleted": "配額檢查完成",
"pws.modelBreakdown": "模型用量明細",
"pws.col.model": "模型",
"pws.col.cost": "預估費用",
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index 0e04a2fb77..2fca1a9e68 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -1930,6 +1930,10 @@ export const zh: Record = {
"pws.estimatedCost": "预估费用",
"pws.costDisclaimer": "基于 API 公示价格的预估值,非实际计费金额。",
"pws.unresolvedRequestedModel": "包含未解析请求模型、由默认提供商处理的用量",
+ "pws.currentAccountUsage": "当前账户用量",
+ "pws.quotaUnsupported": "此账户不支持查询配额。",
+ "pws.quotaUnobserved": "尚未观测到用量。",
+ "pws.quotaCheckCompleted": "配额检查完成",
"pws.modelBreakdown": "模型用量明细",
"pws.col.model": "模型",
"pws.col.cost": "预估费用",
diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx
index 6dfff66723..8701b3acd3 100644
--- a/gui/src/pages/Providers.tsx
+++ b/gui/src/pages/Providers.tsx
@@ -2,7 +2,7 @@ import { usageSummary30dResourceKey } from "../usage-summary-resource";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ProviderWorkspaceShell, { type AddProviderIntent } from "../components/provider-workspace/ProviderWorkspaceShell";
import ProviderDetails from "../components/provider-workspace/ProviderDetails";
-import type { WorkspaceProvider } from "../provider-workspace/catalog";
+import { isAccountProvider, type WorkspaceProvider } from "../provider-workspace/catalog";
import { ensureOpenAiProvider, openAiAccountProviderState, OpenAiEnableError } from "../provider-payload";
import { oauthTosRisk } from "../oauth-tos-risk";
import { ToastNotice, type NoticeTone } from "../ui";
@@ -23,6 +23,58 @@ import type { CodexAccountMutationCompletion } from "../codex-account-mutation";
import { useProviderModelsNotice } from "./use-provider-models-notice";
import { navigateHash } from "../hash-routing";
+/** The page's real refresh tickets: only the captured report epoch and account read can settle them. */
+// oxlint-disable-next-line react/only-export-components -- keep the page-owned coordinator and its direct race tests in the authorized owner.
+export function useQuotaRefreshCoordinator(apiBase: string) {
+ const [quotaRefresh, setQuotaRefresh] = useState({ epoch: 0, force: false });
+ const epochRef = useRef(0);
+ const mountedRef = useRef(true);
+ const ticketsRef = useRef(new Map void;
+ accounts?: boolean;
+ report?: boolean;
+ }>());
+ const cancelTickets = useCallback(() => {
+ for (const ticket of ticketsRef.current.values()) ticket.resolve(false);
+ ticketsRef.current.clear();
+ }, []);
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => { mountedRef.current = false; cancelTickets(); };
+ }, [apiBase, cancelTickets]);
+ const invalidateProviderQuotas = useCallback((force = false) => {
+ cancelTickets();
+ const epoch = ++epochRef.current;
+ if (mountedRef.current) setQuotaRefresh({ epoch, force });
+ return epoch;
+ }, [cancelTickets]);
+ const finish = useCallback((epoch: number, part: "accounts" | "report", ok: boolean) => {
+ const ticket = ticketsRef.current.get(epoch);
+ if (!ticket || !mountedRef.current) return;
+ ticket[part] = ok;
+ if (ticket.accounts !== undefined && ticket.report !== undefined) {
+ ticketsRef.current.delete(epoch);
+ ticket.resolve(ticket.accounts && ticket.report);
+ }
+ }, []);
+ const settleQuotaRefresh = useCallback((ok: boolean, epoch: number) => finish(epoch, "report", ok), [finish]);
+ const beginQuotaRefresh = useCallback((readAccounts?: () => Promise): Promise => {
+ if (!mountedRef.current) return Promise.resolve(false);
+ const epoch = invalidateProviderQuotas(true);
+ const settled = new Promise(resolve => {
+ ticketsRef.current.set(epoch, { resolve, accounts: readAccounts ? undefined : true });
+ });
+ if (readAccounts) {
+ void Promise.resolve().then(readAccounts).then(
+ ok => finish(epoch, "accounts", ok),
+ () => finish(epoch, "accounts", false),
+ );
+ }
+ return settled;
+ }, [finish, invalidateProviderQuotas]);
+ return { quotaRefresh, invalidateProviderQuotas, settleQuotaRefresh, beginQuotaRefresh };
+}
+
export default function Providers({ apiBase }: { apiBase: string }) {
const t = useT();
const configCacheKey = `ocx.providers.config.v1:${apiBase}`;
@@ -140,23 +192,7 @@ export default function Providers({ apiBase }: { apiBase: string }) {
* A counter only moves when something actually invalidates the quotas, so account arrival
* is silent while every real mutation path still forces a re-read.
*/
- const [quotaRefresh, setQuotaRefresh] = useState({ epoch: 0, force: false });
- const invalidateProviderQuotas = useCallback((force = false) => {
- setQuotaRefresh(previous => ({ epoch: previous.epoch + 1, force }));
- }, []);
- /*
- * Operator-initiated refresh needs an answer, and the bump above is not one: it is a
- * setState, so awaiting it tells you only that React was told to re-render. The shell
- * owns the actual `/api/provider-quotas` read, so the resolver is parked here and the
- * shell settles it. Without this a refresh button would flip back to idle and report
- * success while the old numbers were still on screen.
- */
- const quotaRefreshWaiters = useRef void>>([]);
- const settleQuotaRefresh = useCallback((ok: boolean) => {
- const waiters = quotaRefreshWaiters.current;
- quotaRefreshWaiters.current = [];
- for (const resolve of waiters) resolve(ok);
- }, []);
+ const { quotaRefresh, invalidateProviderQuotas, settleQuotaRefresh, beginQuotaRefresh } = useQuotaRefreshCoordinator(apiBase);
const { fetchConfig: refreshConfigResult, fetchOauth, fetchProviderQuotas } = useProvidersFetch({
apiBase, t, setConfig, setOauthProviders, setOauthStatus, notify,
invalidateProviderQuotas,
@@ -206,7 +242,7 @@ export default function Providers({ apiBase }: { apiBase: string }) {
fetchConfig, fetchOauth, fetchProviderQuotas, codexActiveNeedsReauth,
});
const {
- accountSets, setAccountSets, accountLoadStates, switchingAccount, keyPools, fetchAccountSets,
+ accountSets, setAccountSets, accountLoadStates, switchingAccount, keyPools, fetchAccountSets, fetchKeyPools,
switchAccount, switchApiKey, removeApiKey, addApiKeyValue, editCredentialAlias,
removeAccount, activeAccountNeedsReauth,
} = pools;
@@ -231,15 +267,20 @@ export default function Providers({ apiBase }: { apiBase: string }) {
* Declared here because it needs `fetchAccountSets` from the account-pool hook above.
* Per-account bars come from a different read (`"a=1` inside `fetchAccountSets`),
* so both must fire or the rows beside each account keep their old numbers. That read's
- * enrichment is best-effort by design — the panel shows its own load state — so the
- * REPORTED result is the provider-level read, which is what the button is about.
+ * forced enrichment must settle as well as the matching provider-report epoch.
*/
const refreshProviderQuota = useCallback((provider: string): Promise => {
- const settled = new Promise(resolve => { quotaRefreshWaiters.current.push(resolve); });
- void fetchAccountSets([provider]);
- void fetchProviderQuotas(true);
- return settled;
- }, [fetchAccountSets, fetchProviderQuotas]);
+ const configured = config?.providers[provider];
+ const mode = configured?.authMode;
+ const readAccounts = configured && isAccountProvider(provider, configured)
+ ? () => codexPool.load(true)
+ : mode === "oauth"
+ ? () => fetchAccountSets([provider], true)
+ : mode === "forward" || mode === "local"
+ ? undefined
+ : () => fetchKeyPools([provider], true);
+ return beginQuotaRefresh(readAccounts);
+ }, [config, codexPool, fetchAccountSets, fetchKeyPools, beginQuotaRefresh]);
/**
* Force a fresh read of EVERY provider's quota, for the overview where no provider
@@ -252,10 +293,8 @@ export default function Providers({ apiBase }: { apiBase: string }) {
* server-side, so this is one request that answers exactly what the overview shows.
*/
const refreshAllProviderQuotas = useCallback((): Promise => {
- const settled = new Promise(resolve => { quotaRefreshWaiters.current.push(resolve); });
- void fetchProviderQuotas(true);
- return settled;
- }, [fetchProviderQuotas]);
+ return beginQuotaRefresh();
+ }, [beginQuotaRefresh]);
useEffect(() => {
// Deferred by a microtask, not a timer. A timer had to be cancelled in cleanup, so navigating
diff --git a/gui/src/provider-workspace/report.ts b/gui/src/provider-workspace/report.ts
index f434a6f353..bd79ac584e 100644
--- a/gui/src/provider-workspace/report.ts
+++ b/gui/src/provider-workspace/report.ts
@@ -197,6 +197,15 @@ export function accountQuotaFromReport(report?: ProviderQuotaReportView): Accoun
return quotaFromUnknown(report?.quota, report?.updatedAt);
}
+/** A pool total is never a substitute for the selected account's own reading. */
+export function currentAccountQuotaReport(report?: ProviderQuotaReportView): ProviderQuotaReportView | undefined {
+ if (!report) return undefined;
+ if (report.aggregation === undefined) return report;
+ const aggregation = capacityAggregationFromReport(report);
+ const quota = aggregation?.currentAccount?.quota ?? null;
+ return { ...report, aggregation: undefined, quota, updatedAt: quota?.updatedAt };
+}
+
function capacityWindow(value: unknown): CapacityWindowView | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const row = value as Record;
diff --git a/gui/tests/auth-panel-refresh-placement.test.ts b/gui/tests/auth-panel-refresh-placement.test.ts
index 60e2a78efe..e4fbff674e 100644
--- a/gui/tests/auth-panel-refresh-placement.test.ts
+++ b/gui/tests/auth-panel-refresh-placement.test.ts
@@ -20,7 +20,7 @@ const headStart = src.indexOf('className="pwi-auth-head"');
const head = src.slice(headStart, src.indexOf('className="pwi-auth-body"', headStart));
test("the section head carries the refresh control", () => {
- expect(head).toContain("onRefreshQuota");
+ expect(head).toContain("canRefreshQuota");
expect(head).toContain("refreshQuota()");
expect(head).toContain("codexAuth.refreshQuota");
});
@@ -38,10 +38,8 @@ test("the refresh result is announced exactly once", () => {
expect(statuses).toBe(1);
});
-test("the head control only renders for a logged-in OAuth provider", () => {
- // An API-key provider has no per-account quota to re-read, and a logged-out one has
- // no account at all.
- expect(head).toContain("isOauth && loggedIn && onRefreshQuota");
+test("the head control supports logged-in OAuth and API-key rosters behind quota capability", () => {
+ expect(head).toContain("((isOauth && loggedIn) || isKeyAuth) && canRefreshQuota");
});
test("the head lays title and control on one wrapping row", () => {
diff --git a/gui/tests/provider-account-quota-loading.test.tsx b/gui/tests/provider-account-quota-loading.test.tsx
new file mode 100644
index 0000000000..5b856945b4
--- /dev/null
+++ b/gui/tests/provider-account-quota-loading.test.tsx
@@ -0,0 +1,202 @@
+import { afterEach, beforeEach, expect, jest, test } from "bun:test";
+import { Window } from "happy-dom";
+import { act, useLayoutEffect, useRef } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { useProviderAccountPools, type OAuthAccount, type ApiKeyEntry } from "../src/hooks/useProviderAccountPools";
+
+const globals = ["document", "window", "navigator", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const;
+let previous: Record<(typeof globals)[number], unknown>;
+let win: Window;
+let root: Root | null;
+let host: HTMLElement;
+let pools: ReturnType;
+let requests: Array<{ url: string; signal?: AbortSignal | null }>;
+let respond: (url: string, signal?: AbortSignal | null) => Promise;
+const noop = async () => {};
+const reading = { fiveHourPercent: 21, weeklyPercent: 34, updatedAt: 1_700_000_000_000 };
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise(done => { resolve = done; });
+ return { promise, resolve };
+}
+function Harness({ apiBase = "/quota-hook" }: { apiBase?: string }) {
+ const aliveRef = useRef(true);
+ const currentPools = useProviderAccountPools({ apiBase, config: null, aliveRef, t: key => key,
+ oauthStatus: {}, notify: () => {}, fetchConfig: noop, fetchOauth: noop,
+ fetchProviderQuotas: noop, codexActiveNeedsReauth: false });
+ useLayoutEffect(() => { pools = currentPools; }, [currentPools]);
+ return null;
+}
+beforeEach(async () => {
+ previous = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previous;
+ win = new Window({ url: "http://localhost" });
+ Object.defineProperties(globalThis, {
+ document: { configurable: true, value: win.document }, window: { configurable: true, value: win },
+ navigator: { configurable: true, value: win.navigator }, IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true },
+ });
+ requests = [];
+ respond = async () => Response.json({ accounts: [], keys: [] });
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: (input: RequestInfo | URL, init?: RequestInit) => {
+ requests.push({ url: String(input), signal: init?.signal });
+ return respond(String(input), init?.signal);
+ } });
+ host = win.document.createElement("div") as unknown as HTMLElement;
+ win.document.body.appendChild(host as never);
+ await act(async () => { root = createRoot(host); root.render( ); });
+});
+afterEach(async () => {
+ if (root) await act(async () => { root!.unmount(); root = null; });
+ for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] });
+ await win.happyDOM.close();
+});
+
+test("cheap probe rows paint with same-ID last-good; forced enrichment awaits and HTTP failure clears pending", async () => {
+ const account: OAuthAccount = { id: "same", active: true, quotaMode: "probe", quota: reading };
+ await act(async () => { pools.setAccountSets({ oauth: { activeAccountId: "same", accounts: [account, { ...account, id: "removed" }] } }); });
+ const quota = deferred();
+ const started = deferred();
+ respond = async url => {
+ if (url.includes("quota=1")) { started.resolve(); return quota.promise; }
+ return Response.json({ activeAccountId: "new", accounts: [
+ { id: "same", active: false, quotaMode: "probe" }, { id: "new", active: true, quotaMode: "probe" },
+ ] });
+ };
+ let result!: Promise;
+ let settled = false;
+ await act(async () => { result = pools.fetchAccountSets(["oauth"], true); void result.then(() => { settled = true; }); await started.promise; });
+ expect(pools.accountLoadStates.oauth).toBe("ready");
+ expect(pools.accountSets.oauth.accounts.map(row => row.id)).toEqual(["same", "new"]);
+ expect(pools.accountSets.oauth.accounts[0]).toMatchObject({ quota: reading, quotaPending: true });
+ expect(pools.accountSets.oauth.accounts[1].quota).toBeUndefined();
+ expect(settled).toBe(false);
+ expect(requests[1].url).toContain(""a=1&refresh=1");
+ expect(requests.every(request => request.signal instanceof AbortSignal)).toBe(true);
+ await act(async () => { quota.resolve(new Response(null, { status: 503 })); expect(await result).toBe(false); });
+ expect(pools.accountSets.oauth.accounts[0]).toMatchObject({ quota: reading, quotaPending: false, quotaUnavailable: true });
+ expect(pools.accountSets.oauth.accounts[1]).toMatchObject({ quotaPending: false, quotaUnavailable: true });
+});
+
+test("key subset refresh preserves other providers, clears old failure on success, and never calls OAuth", async () => {
+ const key: ApiKeyEntry = { id: "key-a", masked: "masked", active: true, quotaMode: "probe", quota: reading, quotaUnavailable: true };
+ await act(async () => { pools.setKeyPools({ first: [key], untouched: [{ ...key, id: "other" }] }); });
+ const quota = deferred();
+ const started = deferred();
+ respond = async url => {
+ if (url.includes("quota=1")) { started.resolve(); return quota.promise; }
+ return Response.json({ keys: [{ id: "key-a", masked: "masked", active: true, quotaMode: "probe" }] });
+ };
+ let result!: Promise;
+ await act(async () => { result = pools.fetchKeyPools(["first"], true); await started.promise; });
+ expect(pools.keyPools.untouched[0].id).toBe("other");
+ expect(pools.keyPools.first[0]).toMatchObject({ quota: reading, quotaPending: true, quotaUnavailable: false });
+ await act(async () => {
+ quota.resolve(Response.json({ keys: [{ id: "key-a", masked: "masked", active: true, quotaMode: "probe", quota: { ...reading, fiveHourPercent: 55 } }] }));
+ expect(await result).toBe(true);
+ });
+ expect(pools.keyPools.first[0]).toMatchObject({ quotaPending: false, quotaUnavailable: false, quota: { fiveHourPercent: 55 } });
+ expect(requests.every(request => request.url.includes("/api/providers/keys"))).toBe(true);
+});
+
+test("unsupported and unknown-mode rows do not enrich; passive missing observations never spin", async () => {
+ for (const quotaMode of ["unsupported", undefined, "future-mode"]) {
+ requests = [];
+ respond = async () => Response.json({ keys: [{ id: "key", masked: "masked", active: true, quotaMode }] });
+ await act(async () => { expect(await pools.fetchKeyPools(["keys"], true)).toBe(true); });
+ expect(requests).toHaveLength(1);
+ expect(pools.keyPools.keys[0].quotaPending).not.toBe(true);
+ if (quotaMode !== "unsupported") {
+ expect(pools.keyPools.keys[0].quotaPending).toBeUndefined();
+ expect(pools.keyPools.keys[0].quotaUnavailable).toBeUndefined();
+ }
+ }
+ const started = deferred();
+ const quota = deferred();
+ respond = async url => {
+ if (url.includes("quota=1")) { started.resolve(); return quota.promise; }
+ return Response.json({ accounts: [{ id: "passive", active: true, quotaMode: "passive" }] });
+ };
+ await act(async () => { expect(await pools.fetchAccountSets(["passive"])).toBe(true); await started.promise; });
+ expect(pools.accountSets.passive.accounts[0]).toMatchObject({ quotaMode: "passive", quotaPending: false });
+ await act(async () => { quota.resolve(Response.json({ accounts: [{ id: "passive", active: true, quotaMode: "passive", quota: null }] })); });
+ expect(pools.accountSets.passive.accounts[0].quota).toBeNull();
+});
+
+test("stale generations settle false and cannot overwrite a newer roster", async () => {
+ const old = deferred();
+ let call = 0;
+ respond = async () => ++call === 1 ? old.promise : Response.json({ keys: [{ id: "new", active: true, masked: "new", quotaMode: "unsupported" }] });
+ let first!: Promise;
+ await act(async () => { first = pools.fetchKeyPools(["keys"], true); });
+ await act(async () => { expect(await pools.fetchKeyPools(["keys"], true)).toBe(true); });
+ await act(async () => { old.resolve(Response.json({ keys: [{ id: "old", masked: "old", active: true, quotaMode: "unsupported" }] })); expect(await first).toBe(false); });
+ expect(pools.keyPools.keys.map(row => row.id)).toEqual(["new"]);
+});
+
+test("one unavailable enriched account fails forced refresh and preserves its own last-good quota", async () => {
+ respond = async url => Response.json({ accounts: [{ id: "account", active: true, quotaMode: "probe",
+ ...(url.includes("quota=1") ? { quotaUnavailable: true } : { quota: reading }),
+ }] });
+ await act(async () => { expect(await pools.fetchAccountSets(["oauth"], true)).toBe(false); });
+ expect(pools.accountSets.oauth.accounts[0]).toMatchObject({ quota: reading, quotaPending: false, quotaUnavailable: true });
+});
+
+test("explicit null in a failed enriched reading invalidates last-good for OAuth and keys", async () => {
+ await act(async () => {
+ pools.setAccountSets({ oauth: { activeAccountId: "account", accounts: [
+ { id: "account", active: true, quotaMode: "probe", quota: reading },
+ ] } });
+ pools.setKeyPools({ keys: [
+ { id: "key", active: true, masked: "masked", quotaMode: "probe", quota: reading },
+ ] });
+ });
+ respond = async url => {
+ const invalidation = url.includes("quota=1") ? { quota: null, quotaUnavailable: true } : {};
+ return Response.json(url.includes("/api/oauth/accounts")
+ ? { activeAccountId: "account", accounts: [{ id: "account", active: true, quotaMode: "probe", ...invalidation }] }
+ : { keys: [{ id: "key", active: true, masked: "masked", quotaMode: "probe", ...invalidation }] });
+ };
+ await act(async () => {
+ expect(await pools.fetchAccountSets(["oauth"], true)).toBe(false);
+ expect(await pools.fetchKeyPools(["keys"], true)).toBe(false);
+ });
+ expect(pools.accountSets.oauth.accounts[0]).toMatchObject({ quota: null, quotaUnavailable: true, quotaPending: false });
+ expect(pools.keyPools.keys[0]).toMatchObject({ quota: null, quotaUnavailable: true, quotaPending: false });
+});
+
+test("unmount aborts bounded roster reads and returns false", async () => {
+ const started = deferred();
+ respond = async (_url, signal) => new Promise((_resolve, reject) => {
+ signal!.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
+ started.resolve();
+ });
+ let result!: Promise;
+ await act(async () => { result = pools.fetchAccountSets(["oauth"], true); await started.promise; });
+ await act(async () => { root!.unmount(); root = null; expect(await result).toBe(false); });
+});
+
+test("a hanging fetch reaches its deadline, preserves last-good and clears probe pending", async () => {
+ const timeoutDescriptor = Object.getOwnPropertyDescriptor(AbortSignal, "timeout");
+ jest.useFakeTimers();
+ Object.defineProperty(AbortSignal, "timeout", { configurable: true, value: undefined });
+ try {
+ await act(async () => { pools.setKeyPools({ keys: [{ id: "key", active: true, masked: "masked", quotaMode: "probe", quota: reading }] }); });
+ const started = deferred();
+ respond = async (url, signal) => {
+ if (!url.includes("quota=1")) return Response.json({ keys: [{ id: "key", active: true, masked: "masked", quotaMode: "probe" }] });
+ return new Promise((_resolve, reject) => {
+ signal!.addEventListener("abort", () => reject(new Error("deadline")), { once: true });
+ started.resolve();
+ });
+ };
+ let result!: Promise;
+ await act(async () => { result = pools.fetchKeyPools(["keys"], true); await started.promise; });
+ expect(pools.keyPools.keys[0].quotaPending).toBe(true);
+ await act(async () => { jest.advanceTimersByTime(20_000); expect(await result).toBe(false); });
+ expect(pools.keyPools.keys[0]).toMatchObject({ quota: reading, quotaPending: false, quotaUnavailable: true });
+ } finally {
+ jest.useRealTimers();
+ if (timeoutDescriptor) Object.defineProperty(AbortSignal, "timeout", timeoutDescriptor);
+ else Reflect.deleteProperty(AbortSignal, "timeout");
+ }
+});
diff --git a/gui/tests/provider-current-quota.test.tsx b/gui/tests/provider-current-quota.test.tsx
new file mode 100644
index 0000000000..4acb16fc5f
--- /dev/null
+++ b/gui/tests/provider-current-quota.test.tsx
@@ -0,0 +1,102 @@
+import { expect, test } from "bun:test";
+import type { ReactNode } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { LanguageProvider } from "../src/i18n/provider";
+import { accountQuotaFromReport, currentAccountQuotaReport, type ProviderQuotaReportView } from "../src/provider-workspace/report";
+import ProviderAccountQuota from "../src/components/provider-workspace/ProviderAccountQuota";
+import ProviderCurrentQuota from "../src/components/provider-workspace/ProviderCurrentQuota";
+import ProviderOverview from "../src/components/provider-workspace/ProviderOverview";
+import ProviderUsage from "../src/components/provider-workspace/ProviderUsage";
+import type { WorkspaceItem } from "../src/provider-workspace/catalog";
+
+const item: WorkspaceItem = { name: "openai", adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" };
+const observedAt = Date.UTC(2026, 8, 5);
+function poolReport(current: unknown = { weeklyPercent: 70, updatedAt: observedAt }): ProviderQuotaReportView {
+ return {
+ quota: { weeklyPercent: 20, updatedAt: observedAt + 60000 }, updatedAt: observedAt + 60000,
+ aggregation: { kind: "capacity-weighted-v1", scope: "routable-known", presentation: "aggregate",
+ excludedAccounts: 0, unknownPlanAccounts: 0, incomplete: false,
+ currentAccount: { plan: "pro", quota: current },
+ },
+ };
+}
+const render = (node: ReactNode) => renderToStaticMarkup({node} );
+
+test("current quota projection uses the account measurement and timestamp, never the aggregate", () => {
+ const projected = currentAccountQuotaReport(poolReport());
+ expect(accountQuotaFromReport(projected)?.weeklyPercent).toBe(70);
+ expect(projected?.updatedAt).toBe(observedAt);
+ expect(projected?.aggregation).toBeUndefined();
+ const markup = render( );
+ expect(markup).toContain("Current account usage");
+ expect(markup).toContain("70% used");
+ expect(markup).not.toContain("20% used");
+ expect(markup).not.toContain("Configured-weight pool estimate");
+});
+
+test("missing or malformed pool current data stays unknown instead of falling back to total capacity", () => {
+ for (const report of [poolReport(null), { quota: { weeklyPercent: 20 }, aggregation: { unexpected: true } }]) {
+ expect(accountQuotaFromReport(currentAccountQuotaReport(report))).toBeNull();
+ const markup = render( );
+ expect(markup).toContain("No quota data for this provider.");
+ expect(markup).not.toContain("20% used");
+ }
+});
+
+test("Overview and Usage place the same current-account section after usage statistics", () => {
+ const overview = render( );
+ const usage = render( );
+ for (const markup of [overview, usage]) {
+ expect(markup).toContain("Current account usage");
+ expect(markup).toContain("70% used");
+ expect(markup).not.toContain("20% used");
+ }
+ expect(overview.indexOf('pws-overview-sidebar')).toBeLessThan(overview.indexOf('aria-label="Current account usage"'));
+ expect(usage.indexOf('pws-usage-metrics')).toBeLessThan(usage.indexOf('aria-label="Current account usage"'));
+});
+
+test("an unobserved active passive row cannot inherit the previous account report", () => {
+ const markup = render( );
+ expect(markup).toContain('data-quota-state="unobserved"');
+ expect(markup).toContain("No usage observation yet.");
+ expect(markup).not.toContain("75%");
+});
+
+test("known current-account state overrides stale provider quota", () => {
+ const report = { quota: { weeklyPercent: 75, updatedAt: observedAt } };
+ for (const quotaMode of ["probe", "unsupported"] as const) {
+ const markup = render( );
+ expect(markup).not.toContain("75%");
+ }
+});
+
+test("all-account and current sections preserve zero and credit-only readings", () => {
+ const quota = { creditsUsd: { used: 12.5, limit: 50, remaining: 37.5, percent: 25 }, updatedAt: observedAt };
+ const views = [
+ ,
+ ,
+ ,
+ ,
+ ];
+ for (const view of views) expect(render(view)).toContain("US$37.50");
+ const zero = render( );
+ expect(zero).toContain("0% used");
+ expect(zero).toContain('data-quota-state="ready"');
+});
+
+test("unsupported, passive unobserved, explicit loading and failed last-good are distinct", () => {
+ const quota = { weeklyPercent: 12, updatedAt: observedAt };
+ const unsupported = render( true} />);
+ expect(unsupported).toContain("Quota lookup is not supported for this account.");
+ expect(unsupported).not.toContain("12%");
+ expect(unsupported).not.toContain("Refresh quotas");
+ expect(render( )).toContain('data-quota-state="unobserved"');
+ expect(render( )).toContain('data-quota-state="pending"');
+ const failed = render( );
+ expect(failed).toContain('data-quota-state="unavailable"');
+ expect(failed).toContain("12% used");
+ expect(failed).toContain("Quota updated");
+});
diff --git a/gui/tests/provider-quota-refresh-controls.test.tsx b/gui/tests/provider-quota-refresh-controls.test.tsx
index 7f218a66bc..31c65cfee5 100644
--- a/gui/tests/provider-quota-refresh-controls.test.tsx
+++ b/gui/tests/provider-quota-refresh-controls.test.tsx
@@ -85,10 +85,10 @@ test("the usage tab reports the real outcome, not the click", async () => {
// Still in flight: the copy says so and the control cannot be double-fired.
expect(host.textContent).toContain("Refreshing...");
expect(findButton("Refreshing...")?.disabled).toBe(true);
- expect(host.textContent).not.toContain("Quotas refreshed");
+ expect(host.textContent).not.toContain("Quota check completed");
await act(async () => { settle(true); await Promise.resolve(); });
- expect(host.textContent).toContain("Quotas refreshed");
+ expect(host.textContent).toContain("Quota check completed");
});
test("a failed read is reported as a failure", async () => {
@@ -99,13 +99,13 @@ test("a failed read is reported as a failure", async () => {
await act(async () => { settle(false); await Promise.resolve(); });
expect(host.textContent).toContain("Failed to refresh quotas");
- expect(host.textContent).not.toContain("Quotas refreshed");
+ expect(host.textContent).not.toContain("Quota check completed");
});
test("the usage control is offered even when there is no quota to show", async () => {
// "Nothing here" is exactly when an operator wants to retry.
await render( true} />);
- expect(host.textContent).toContain("Rate limits");
+ expect(host.textContent).toContain("Current account usage");
expect(findButton("Refresh quotas")).not.toBeNull();
});
@@ -161,7 +161,7 @@ test("the accounts surface offers the same control for a non-Codex provider", as
expect(findButton("Refreshing...")?.disabled).toBe(true);
await act(async () => { settle(true); await Promise.resolve(); });
- expect(host.textContent).toContain("Quotas refreshed");
+ expect(host.textContent).toContain("Quota check completed");
});
test("the accounts surface omits the control when the page cannot force a read", async () => {
@@ -170,3 +170,51 @@ test("the accounts surface omits the control when the page cannot force a read",
);
expect(findButton("Refresh quotas")).toBeNull();
});
+
+test("API-key rows use independent shared credit readings and the same awaited refresh control", async () => {
+ const { handler, settle } = deferredHandler();
+ const credits = (remaining: number) => ({ updatedAt: Date.now() - 60_000,
+ creditsUsd: { used: 50 - remaining, limit: 50, remaining, percent: (50 - remaining) * 2 },
+ });
+ await render( );
+ const rows = Array.from(host.querySelectorAll(".pwi-auth-acct"));
+ expect(rows).toHaveLength(2);
+ expect(rows[0].textContent).toContain("US$37.50");
+ expect(rows[0].textContent).not.toContain("US$12.50");
+ expect(rows[1].textContent).toContain("US$12.50");
+ expect(rows[1].querySelector('[data-quota-state="unavailable"]')).not.toBeNull();
+ await act(async () => { findButton("Refresh quotas")!.click(); });
+ expect(findButton("Refreshing...")?.disabled).toBe(true);
+ expect(host.textContent).not.toContain("Quota check completed");
+ await act(async () => { settle(false); });
+ expect(host.textContent).toContain("Failed to refresh quotas");
+});
+
+test("unsupported credentials omit refresh; passive absence is unobserved and only explicit probes are pending", async () => {
+ await render( true })} />);
+ expect(findButton("Refresh quotas")).toBeNull();
+ expect(host.querySelector('[data-quota-state="unsupported"]')).not.toBeNull();
+ await render( );
+ expect(host.querySelectorAll('[data-quota-state="unobserved"]')).toHaveLength(1);
+ expect(host.querySelectorAll('[data-quota-state="pending"]')).toHaveLength(1);
+});
+
+test("changing active account discards the previous refresh feedback", async () => {
+ const { handler, settle } = deferredHandler();
+ const handlers = authHandlers({ onRefreshQuota: handler });
+ await render( );
+ await act(async () => { findButton("Refresh quotas")!.click(); });
+ await render( );
+ await act(async () => { settle(true); });
+ expect(host.textContent).not.toContain("Quota check completed");
+ expect(findButton("Refresh quotas")?.disabled).toBe(false);
+});
diff --git a/gui/tests/provider-quota-refresh-settle.test.tsx b/gui/tests/provider-quota-refresh-settle.test.tsx
index 8b3f0a99cf..f0e4969713 100644
--- a/gui/tests/provider-quota-refresh-settle.test.tsx
+++ b/gui/tests/provider-quota-refresh-settle.test.tsx
@@ -130,3 +130,18 @@ test("a rejected fetch reports failure", async () => {
await mount(1, true, settled);
expect(settled).toEqual([false]);
});
+
+test("the shell preserves boolean first argument and reports its captured epoch second", async () => {
+ const calls: Array<[boolean, number]> = [];
+ let done!: () => void;
+ const settled = new Promise(resolve => { done = resolve; });
+ await act(async () => {
+ root = createRoot(host);
+ root.render( {}} onAddProvider={() => {}}
+ quotaRefreshEpoch={17} quotaForceRefresh onQuotaRefreshSettled={(ok, epoch) => { calls.push([ok, epoch]); done(); }}
+ /> );
+ });
+ await act(async () => { await settled; });
+ expect(calls).toEqual([[true, 17]]);
+});
diff --git a/gui/tests/provider-revalidation-policy.test.tsx b/gui/tests/provider-revalidation-policy.test.tsx
index 5bbbdb9f7e..df5a983d80 100644
--- a/gui/tests/provider-revalidation-policy.test.tsx
+++ b/gui/tests/provider-revalidation-policy.test.tsx
@@ -44,7 +44,7 @@ beforeEach(() => {
quotaCalls = [];
Object.defineProperty(globalThis, "fetch", {
configurable: true,
- value: async (input: string, init?: RequestInit) => {
+ value: async (input: string) => {
const url = String(input);
const ok = (body: unknown) => ({
ok: true,
@@ -69,7 +69,7 @@ beforeEach(() => {
const provider = new URL(url, "http://localhost").searchParams.get("provider") ?? "x";
const delay = (PROVIDERS.indexOf(provider) + 1) * 15;
await new Promise(r => setTimeout(r, delay));
- return ok({ activeAccountId: `${provider}-account-1`, accounts: [{ id: `${provider}-account-1` }] });
+ return ok({ activeAccountId: `${provider}-account-1`, accounts: [{ id: `${provider}-account-1`, quotaMode: "probe" }] });
}
if (url.includes("/api/providers/keys")) return ok({ keys: [] });
if (url.includes("/api/config")) {
@@ -155,3 +155,69 @@ test("the cheap account read still precedes the quota enrichment for every provi
expect(order.indexOf("enrich")).toBeGreaterThan(order.lastIndexOf("base") - 1);
expect(order[0]).toBe("base");
});
+
+for (const kind of ["oauth", "key", "codex"] as const) {
+ test(`the real Providers page refresh selects ${kind} and awaits account plus report`, async () => {
+ const name = kind === "codex" ? "openai" : `${kind}-fixture`;
+ const seen: string[] = [];
+ let finishReport!: (response: Response) => void;
+ let finishAccounts!: (response: Response) => void;
+ let reportStarted!: () => void;
+ const reportReady = new Promise(resolve => { reportStarted = resolve; });
+ const accountBody = kind === "codex"
+ ? { accounts: [{ id: "main", email: "fixture@example.test", isMain: true, priority: 0, hasCredential: true, quota: null }] }
+ : kind === "oauth"
+ ? { activeAccountId: "account", accounts: [{ id: "account", active: true, quotaMode: "probe" }] }
+ : { keys: [{ id: "key", masked: "masked", active: true, quotaMode: "probe" }] };
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => {
+ const url = new URL(String(input), "http://localhost");
+ seen.push(url.pathname + url.search);
+ if (url.pathname === "/api/config") return Response.json({ port: 10100, defaultProvider: name, providers: {
+ [name]: kind === "codex"
+ ? { adapter: "openai-responses", authMode: "forward", codexAccountMode: "pool", baseUrl: "https://chatgpt.com/backend-api/codex" }
+ : { adapter: "openai-chat", authMode: kind, hasApiKey: kind === "key", baseUrl: "https://fixture.test/v1" },
+ } });
+ if (url.pathname === "/api/oauth/providers") return Response.json({ providers: kind === "oauth" ? [name] : [] });
+ if (url.pathname === "/api/oauth/status") return Response.json({ loggedIn: true });
+ if (url.pathname === "/api/provider-quotas") {
+ if (!url.searchParams.has("refresh")) return Response.json({ reports: [] });
+ const result = new Promise(resolve => { finishReport = resolve; });
+ reportStarted();
+ return result;
+ }
+ if (url.pathname === "/api/oauth/accounts" || url.pathname === "/api/providers/keys"
+ || (kind === "codex" && url.pathname === "/api/codex-auth/accounts")) {
+ return url.searchParams.has("refresh")
+ ? new Promise(resolve => { finishAccounts = resolve; }) : Response.json(accountBody);
+ }
+ if (url.pathname === "/api/codex-auth/accounts") return Response.json({ accounts: [] });
+ if (url.pathname === "/api/codex-auth/active") return Response.json({ activeCodexAccountId: null, autoSwitchThreshold: 80, accountPoolStrategy: "round-robin", accountPoolStickyLimit: 1 });
+ if (url.pathname === "/api/selected-models") return Response.json({ models: {} });
+ if (url.pathname === "/api/usage") return Response.json({ providers: [], models: [] });
+ if (url.pathname === "/api/provider-presets") return Response.json({ providers: [] });
+ return Response.json({});
+ } });
+ await mount();
+ const provider = container.querySelector(".providers-workspace-rail-row");
+ expect(provider).not.toBeNull();
+ await act(async () => { provider!.click(); });
+ const refresh = Array.from(container.querySelectorAll("button"))
+ .find(button => button.textContent?.includes("Refresh quotas"));
+ expect(refresh).toBeDefined();
+ seen.length = 0;
+ await act(async () => { refresh!.click(); });
+ await act(async () => { await reportReady; });
+ const expected = kind === "codex" ? "/api/codex-auth/accounts?refresh=1"
+ : kind === "oauth" ? `/api/oauth/accounts?provider=${name}"a=1&refresh=1`
+ : `/api/providers/keys?name=${name}"a=1&refresh=1`;
+ expect(seen).toContain(expected);
+ if (kind !== "oauth") expect(seen.some(path => path.startsWith("/api/oauth/accounts"))).toBe(false);
+ if (kind === "oauth") expect(seen.some(path => path.startsWith("/api/providers/keys"))).toBe(false);
+ expect(container.textContent).toContain("Refreshing...");
+ await act(async () => { finishReport(Response.json({ reports: [] })); });
+ expect(container.textContent).not.toContain("Quota check completed");
+ expect(container.textContent).toContain("Refreshing...");
+ await act(async () => { finishAccounts(Response.json(accountBody)); });
+ expect(container.textContent).toContain("Quota check completed");
+ });
+}
diff --git a/gui/tests/providers-quota-coordinator.test.tsx b/gui/tests/providers-quota-coordinator.test.tsx
new file mode 100644
index 0000000000..5b95dd2938
--- /dev/null
+++ b/gui/tests/providers-quota-coordinator.test.tsx
@@ -0,0 +1,95 @@
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import { Window } from "happy-dom";
+import { act, useLayoutEffect } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { useQuotaRefreshCoordinator } from "../src/pages/Providers";
+
+const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const;
+let previous: Record<(typeof globals)[number], unknown>;
+let win: Window;
+let root: Root | null;
+let coordinator: ReturnType;
+
+function Harness() {
+ const currentCoordinator = useQuotaRefreshCoordinator("/coordinator");
+ useLayoutEffect(() => { coordinator = currentCoordinator; }, [currentCoordinator]);
+ return null;
+}
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise(done => { resolve = done; });
+ return { promise, resolve };
+}
+beforeEach(async () => {
+ previous = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previous;
+ win = new Window({ url: "http://localhost" });
+ Object.defineProperties(globalThis, {
+ document: { configurable: true, value: win.document }, window: { configurable: true, value: win },
+ navigator: { configurable: true, value: win.navigator }, IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true },
+ });
+ const host = win.document.createElement("div");
+ win.document.body.appendChild(host);
+ await act(async () => { root = createRoot(host as unknown as HTMLElement); root.render( ); });
+});
+afterEach(async () => {
+ if (root) await act(async () => { root!.unmount(); root = null; });
+ for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] });
+ await win.happyDOM.close();
+});
+
+test("production coordinator supersedes tickets and joins only matching report/account outcomes", async () => {
+ const firstAccounts = deferred();
+ const secondAccounts = deferred();
+ const firstReport = deferred();
+ const secondReport = deferred();
+ const results: Array<[string, boolean]> = [];
+ await act(async () => {
+ void coordinator.beginQuotaRefresh(() => firstAccounts.promise).then(ok => { results.push(["first", ok]); });
+ });
+ const firstEpoch = coordinator.quotaRefresh.epoch;
+ void firstReport.promise.then(ok => coordinator.settleQuotaRefresh(ok, firstEpoch));
+ await act(async () => {
+ void coordinator.beginQuotaRefresh(() => secondAccounts.promise).then(ok => { results.push(["second", ok]); });
+ });
+ const secondEpoch = coordinator.quotaRefresh.epoch;
+ void secondReport.promise.then(ok => coordinator.settleQuotaRefresh(ok, secondEpoch));
+ expect(secondEpoch).toBe(firstEpoch + 1);
+ expect(results).toEqual([["first", false]]);
+ // Reverse completion: a report success alone cannot settle even the current ticket.
+ await act(async () => { secondReport.resolve(true); });
+ expect(results).toEqual([["first", false]]);
+ await act(async () => { secondAccounts.resolve(true); });
+ expect(results).toEqual([["first", false], ["second", true]]);
+ await act(async () => { firstReport.resolve(true); firstAccounts.resolve(true); });
+ expect(results).toEqual([["first", false], ["second", true]]);
+});
+
+test("an older report cannot settle a newer ticket whose accounts already finished", async () => {
+ let first!: Promise;
+ let second!: Promise;
+ const results: boolean[] = [];
+ await act(async () => { first = coordinator.beginQuotaRefresh(); });
+ const oldEpoch = coordinator.quotaRefresh.epoch;
+ await act(async () => { second = coordinator.beginQuotaRefresh(async () => true); void second.then(ok => { results.push(ok); }); });
+ expect(await first).toBe(false);
+ await act(async () => { coordinator.settleQuotaRefresh(true, oldEpoch); });
+ expect(results).toEqual([]);
+ await act(async () => { coordinator.settleQuotaRefresh(false, coordinator.quotaRefresh.epoch); });
+ expect(await second).toBe(false);
+ expect(results).toEqual([false]);
+});
+
+test("account failure wins over successful report; mutation and unmount resolve superseded tickets false", async () => {
+ let result!: Promise;
+ await act(async () => { result = coordinator.beginQuotaRefresh(async () => false); });
+ await act(async () => { coordinator.settleQuotaRefresh(true, coordinator.quotaRefresh.epoch); });
+ expect(await result).toBe(false);
+ await act(async () => { result = coordinator.beginQuotaRefresh(); });
+ await act(async () => { coordinator.invalidateProviderQuotas(false); });
+ expect(await result).toBe(false);
+ const hanging = deferred();
+ await act(async () => { result = coordinator.beginQuotaRefresh(() => hanging.promise); });
+ await act(async () => { root!.unmount(); root = null; });
+ expect(await result).toBe(false);
+ hanging.resolve(true);
+});
diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json
index 2ecb36d56a..9bea518578 100644
--- a/scripts/test-layout/layout.json
+++ b/scripts/test-layout/layout.json
@@ -925,6 +925,7 @@
"process-state.test.ts": "service",
"project-config-warnings.test.ts": "codex-integration",
"provider-account-quota-persistence.test.ts": "providers",
+ "provider-account-quota-routes.test.ts": "server",
"provider-account-quota.test.ts": "providers",
"provider-api-keys.test.ts": "providers",
"provider-capacity.test.ts": "providers",
diff --git a/src/providers/api-keys.ts b/src/providers/api-keys.ts
index 6cf4c8e302..d138cdbf50 100644
--- a/src/providers/api-keys.ts
+++ b/src/providers/api-keys.ts
@@ -4,13 +4,14 @@
* `provider.apiKey` stays the single source of truth for routing — it always mirrors the
* ACTIVE pool entry, so the router/adapters never learn about the pool. The pool itself
* lives in `provider.apiKeyPool` in config.json (same file that already holds apiKey).
- * A provider with a legacy bare `apiKey` is seeded into a one-entry pool on first touch.
+ * A legacy bare `apiKey` is projected as one row on reads and seeded on first mutation.
*/
import { createHash } from "node:crypto";
import { saveConfigPreservingClaudeCode } from "../config";
import type { OcxConfig, OcxProviderConfig } from "../types";
+import type { AccountQuotaFields } from "./quota-types";
-export interface ProviderApiKeyInfo {
+export interface ProviderApiKeyInfo extends AccountQuotaFields {
id: string;
label?: string;
/** First/last 4 chars only; env references (`${VAR}`) are shown verbatim (not secrets). */
@@ -57,17 +58,14 @@ function ensurePool(provider: OcxProviderConfig): NonNullable e.key === provider.apiKey) ?? pool[0]!).id;
-}
-
export function listProviderApiKeys(config: OcxConfig, name: string): { activeId: string | null; keys: ProviderApiKeyInfo[] } {
const provider = config.providers[name];
if (!provider || !isKeyAuthProvider(provider)) return { activeId: null, keys: [] };
- const pool = ensurePool(provider);
- const activeId = activeEntryId(provider);
+ // A GET projects a legacy key without seeding/mutating live configuration.
+ const pool = provider.apiKeyPool?.length
+ ? provider.apiKeyPool
+ : provider.apiKey ? [{ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey }] : [];
+ const activeId = (pool.find(entry => entry.key === provider.apiKey) ?? pool[0])?.id ?? null;
return {
activeId,
keys: pool.map(entry => ({
diff --git a/src/providers/quota-key-accounts.ts b/src/providers/quota-key-accounts.ts
new file mode 100644
index 0000000000..7f406e0bed
--- /dev/null
+++ b/src/providers/quota-key-accounts.ts
@@ -0,0 +1,141 @@
+/** Isolated, process-local API-key quota rows. Never publishes provider/routing caches. */
+import { createHash } from "node:crypto";
+import type { OcxConfig, OcxProviderConfig } from "../types";
+import { apiKeyPoolEntryId } from "./api-keys";
+import { resolveProviderApiKey } from "./key-store";
+import type { ProviderQuota } from "./quota-types";
+import { ACCOUNT_QUOTA_TTL_MS } from "./quota-wire";
+
+const MAX_ENTRIES = 256;
+const LAST_GOOD_MS = 30 * 60_000;
+type Entry = { ts: number; quota: ProviderQuota | null; unavailable?: true };
+export type KeyQuotaProbeOutcome =
+ | { kind: "quota"; quota: ProviderQuota }
+ | { kind: "empty" | "terminal" | "unavailable" };
+export interface ProviderApiKeyQuota {
+ keyId: string;
+ quota: ProviderQuota | null;
+ unavailable?: true;
+ /** Server-only, non-enumerable; call immediately before the safe DTO join. */
+ isCurrent: () => boolean;
+}
+const cache = new Map();
+const flights = new Map>();
+let epoch = 0;
+
+export function clearProviderApiKeyQuotaCache(): void {
+ epoch += 1;
+ cache.clear();
+ flights.clear();
+}
+
+/** Four workers per roster, not a process-wide network limit. */
+export async function mapQuotaRoster(rows: readonly T[], read: (row: T) => Promise): Promise {
+ const out = new Array(rows.length);
+ let cursor = 0;
+ await Promise.all(Array.from({ length: Math.min(4, rows.length) }, async () => {
+ while (cursor < rows.length) {
+ const index = cursor++;
+ out[index] = await read(rows[index]!);
+ }
+ }));
+ return out;
+}
+
+function roster(provider: OcxProviderConfig) {
+ return provider.apiKeyPool?.length ? provider.apiKeyPool
+ : provider.apiKey ? [{ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey }] : [];
+}
+
+function identity(name: string, provider: OcxProviderConfig, id: string, key: string): string {
+ return createHash("sha256").update(JSON.stringify([
+ "quota-key", name, provider.adapter, provider.baseUrl, provider.authMode ?? "key",
+ provider.disabled === true, id, key,
+ ])).digest("hex");
+}
+
+function remember(key: string, entry: Entry): void {
+ cache.delete(key);
+ for (const [id, row] of cache) if (Date.now() - row.ts >= LAST_GOOD_MS) cache.delete(id);
+ while (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value!);
+ cache.set(key, entry);
+}
+
+async function readEntry(
+ key: string,
+ force: boolean,
+ isCurrent: () => boolean,
+ probe: () => Promise,
+): Promise {
+ const previous = cache.get(key);
+ if (!force && previous && Date.now() - previous.ts < ACCOUNT_QUOTA_TTL_MS
+ && (!previous.quota || Date.now() - previous.quota.updatedAt < LAST_GOOD_MS)) {
+ cache.delete(key);
+ cache.set(key, previous);
+ return previous;
+ }
+ const running = flights.get(key);
+ if (running) return running;
+ const lastGood = previous?.quota && Date.now() - previous.quota.updatedAt < LAST_GOOD_MS
+ ? previous.quota : null;
+ if (flights.size >= MAX_ENTRIES) return { ts: Date.now(), quota: lastGood, unavailable: true };
+ const flight = (async (): Promise => {
+ let result: KeyQuotaProbeOutcome;
+ try { result = await probe(); } catch { result = { kind: "unavailable" }; }
+ const entry: Entry = result.kind === "quota"
+ ? { ts: Date.now(), quota: result.quota }
+ : result.kind === "empty"
+ ? { ts: Date.now(), quota: null }
+ : {
+ ts: Date.now(),
+ quota: result.kind !== "terminal" && lastGood && Date.now() - lastGood.updatedAt < LAST_GOOD_MS ? lastGood : null,
+ unavailable: true,
+ };
+ if (isCurrent()) remember(key, entry);
+ return entry;
+ })().finally(() => { if (flights.get(key) === flight) flights.delete(key); });
+ flights.set(key, flight);
+ return flight;
+}
+
+export async function readProviderApiKeyQuotas(
+ config: OcxConfig,
+ name: string,
+ force: boolean,
+ probe: (provider: OcxProviderConfig, config: OcxConfig) => Promise,
+): Promise {
+ const liveProvider = config.providers[name];
+ if (!liveProvider) return [];
+ const providerSnapshot = { ...liveProvider };
+ const rows = roster(liveProvider).map(row => ({ ...row }));
+ const generation = epoch;
+ return mapQuotaRoster(rows, async row => {
+ let resolved: string | undefined;
+ try { resolved = resolveProviderApiKey(row.key)?.trim(); } catch { /* unavailable */ }
+ const key = resolved ? identity(name, providerSnapshot, row.id, resolved) : null;
+ const isCurrent = () => {
+ if (epoch !== generation || !key) return false;
+ const current = config.providers[name];
+ if (!current) return false;
+ const entry = roster(current).find(candidate => candidate.id === row.id);
+ if (!entry) return false;
+ try {
+ const currentKey = resolveProviderApiKey(entry.key)?.trim();
+ return !!currentKey && identity(name, current, row.id, currentKey) === key;
+ } catch { return false; }
+ };
+ const entry = key && resolved && isCurrent()
+ ? await readEntry(key, force, isCurrent, () => {
+ const isolatedProvider = { ...providerSnapshot, apiKey: resolved, apiKeyPool: undefined };
+ const isolatedConfig = { ...config, providers: { [name]: isolatedProvider } };
+ return probe(isolatedProvider, isolatedConfig);
+ })
+ : { ts: Date.now(), quota: null, unavailable: true as const };
+ const result = {
+ keyId: row.id,
+ quota: isCurrent() ? entry.quota : null,
+ ...(!isCurrent() || entry.unavailable ? { unavailable: true as const } : {}),
+ };
+ return Object.defineProperty(result, "isCurrent", { value: isCurrent }) as ProviderApiKeyQuota;
+ });
+}
diff --git a/src/providers/quota-types.ts b/src/providers/quota-types.ts
index d71b49ea4d..873eb30221 100644
--- a/src/providers/quota-types.ts
+++ b/src/providers/quota-types.ts
@@ -34,3 +34,12 @@ export interface ProviderQuota {
creditsUsd?: ProviderQuotaCreditsUsd;
updatedAt: number;
}
+
+export type AccountQuotaMode = "probe" | "passive" | "unsupported";
+
+/** Additive management-row fields; cheap lists emit only quotaMode. */
+export interface AccountQuotaFields {
+ quotaMode?: AccountQuotaMode;
+ quota?: ProviderQuota | null;
+ quotaUnavailable?: boolean;
+}
diff --git a/src/providers/quota.ts b/src/providers/quota.ts
index 49533336c0..bb35c7ead1 100644
--- a/src/providers/quota.ts
+++ b/src/providers/quota.ts
@@ -48,6 +48,7 @@ import {
type CodexCapacityQuota,
} from "./codex-capacity";
import type {
+ AccountQuotaMode,
ProviderQuota,
ProviderQuotaCreditsUsd,
ProviderQuotaWindow,
@@ -65,6 +66,7 @@ import {
readPersistedAccountQuotas,
schedulePersistAccountQuotas,
} from "./account-quota-disk";
+import { clearProviderApiKeyQuotaCache, mapQuotaRoster, readProviderApiKeyQuotas, type ProviderApiKeyQuota } from "./quota-key-accounts";
export type { ProviderQuota, ProviderQuotaCreditsUsd, ProviderQuotaWindow } from "./quota-types";
@@ -100,6 +102,7 @@ const XAI_CREDITS_URL = `${XAI_BILLING_URL}?format=credits`;
/** Keep a failed probe's previous row at most this long before dropping it. */
const LAST_GOOD_MAX_AGE_MS = CODEX_CAPACITY_MAX_QUOTA_AGE_MS;
const nativeMainReportGenerations = new WeakMap();
+const accountReportCurrent = new WeakMap boolean>();
let providerQuotaBeforePublishForTests: (() => void | Promise) | null = null;
/** Test-only seam for identity/config invalidation after probes but before publication. */
@@ -163,6 +166,7 @@ let invalidationEpoch = 0;
export function clearProviderQuotaCache(): void {
cache = null;
clearCachedProviderQuotas();
+ clearProviderApiKeyQuotaCache();
invalidationEpoch += 1;
}
@@ -1182,7 +1186,8 @@ function tagNativeMainReport(
function isProviderQuotaReportCurrent(value: ProviderQuotaReport): boolean {
const generation = nativeMainReportGenerations.get(value);
- return generation === undefined || isMainAccountIdentityGenerationLive(generation);
+ return (generation === undefined || isMainAccountIdentityGenerationLive(generation))
+ && (accountReportCurrent.get(value)?.() ?? true);
}
async function fetchChatGptForwardQuota(
@@ -1303,6 +1308,7 @@ export function parseXaiCreditsResponse(value: unknown): { percent: number; rese
async function fetchXaiWeeklyCredits(accessToken: string, userId: string): Promise {
try {
const response = await fetch(XAI_CREDITS_URL, {
+ redirect: "error",
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
@@ -1326,16 +1332,11 @@ async function fetchXaiWeeklyCredits(accessToken: string, userId: string): Promi
}
}
-async function fetchXaiQuota(provider: string): Promise {
- let accessToken: string;
- try {
- accessToken = await getValidAccessToken("xai");
- } catch {
- return null;
- }
+async function fetchXaiQuota(provider: string, context: { accessToken: string; upstreamAccountId?: string }): Promise {
+ const { accessToken } = context;
// Prefer the SuperGrok weekly credits window that actually gates prompting (#1283).
- const userId = getCredential("xai")?.accountId?.trim() || xaiUserIdFromAccessToken(accessToken);
+ const userId = context.upstreamAccountId?.trim() || xaiUserIdFromAccessToken(accessToken);
if (userId) {
const weekly = await fetchXaiWeeklyCredits(accessToken, userId);
if (weekly) return report(provider, "xai:grok-billing-credits", weekly);
@@ -1344,6 +1345,7 @@ async function fetchXaiQuota(provider: string): Promise boolean;
};
const accountQuotaCache = new Map();
+let explicitAccountEpoch = 0;
/**
* Seed the cache from the last run, once.
@@ -1591,11 +1597,21 @@ export interface ProviderAccountQuota {
quota: ProviderQuota | null;
/** Set when the probe could not reach upstream (expired login, 429, network). */
unavailable?: true;
+ isCurrent?: () => boolean;
}
/** Providers whose per-account quota can be probed. Extend as other OAuth APIs are covered. */
export function supportsPerAccountQuota(provider: string): boolean {
- return provider === "anthropic" || provider === "kiro" || provider === "google-antigravity";
+ return provider === "anthropic" || provider === "kiro" || provider === "google-antigravity"
+ || explicitAccountReader(provider);
+}
+
+function explicitAccountReader(provider: string): boolean {
+ return provider === "xai" || provider === "cursor" || provider === "kimi" || provider === "command-code";
+}
+
+export function providerOAuthAccountQuotaMode(provider: string): AccountQuotaMode {
+ return hasPassiveAccountQuota(provider) ? "passive" : supportsPerAccountQuota(provider) ? "probe" : "unsupported";
}
function accountCacheKey(provider: string, accountId: string): string {
@@ -1608,6 +1624,7 @@ function accountCacheKey(provider: string, accountId: string): string {
*/
export function getCachedProviderAccountQuota(provider: string, accountId: string): ProviderQuota | null {
const entry = accountQuotaCache.get(accountCacheKey(provider, accountId));
+ if (entry?.isCurrent && !entry.isCurrent()) return null;
return entry?.quota ?? null;
}
@@ -1629,11 +1646,8 @@ export function setCachedProviderAccountQuotaForTests(
* Providers whose per-account quota is OBSERVED in-band, never probed.
*
* Deliberately separate from `supportsPerAccountQuota` rather than folded into it. That
- * predicate gates `fetchAccountQuota`, whose fallback branch sends any
- * non-Kiro/non-Antigravity bearer to Anthropic's usage endpoint — so adding `meta-muse`
- * there without a dedicated branch would ship a Meta credential to Anthropic. And even
- * with a branch it would be the wrong predicate: it means "this provider can be probed",
- * and Meta publishes no quota endpoint to probe.
+ * predicate gates explicit upstream readers. Meta publishes no quota endpoint, so it
+ * remains a cache-only observation even when every probe reader is account-scoped.
*/
export function hasPassiveAccountQuota(provider: string): boolean {
return provider === "meta-muse";
@@ -1739,6 +1753,7 @@ export function resetProviderQuotaReconcileStateForTests(): void {
/** Drop cached per-account rows (all, or just one provider's). */
export function clearAccountQuotaCache(provider?: string): void {
+ explicitAccountEpoch += 1;
if (!provider) {
accountQuotaCache.clear();
accountQuotaInflight.clear();
@@ -1783,11 +1798,115 @@ async function getTokenForAccountQuotaProbe(provider: string, accountId: string)
return getValidAccessTokenForAccount(provider, accountId);
}
+function explicitQuotaConfig(provider: string, configured?: OcxProviderConfig): OcxProviderConfig | undefined {
+ if (configured) return configured;
+ const entry = getProviderRegistryEntry(provider);
+ return entry ? { adapter: entry.adapter, baseUrl: entry.baseUrl, authMode: "oauth" } : undefined;
+}
+
+function explicitQuotaIdentity(provider: string, accountId: string, configured?: OcxProviderConfig): string | undefined {
+ const credential = getAccountCredential(provider, accountId);
+ const target = explicitQuotaConfig(provider, configured);
+ if (!credential || !target) return undefined;
+ return createHash("sha256").update(JSON.stringify([
+ provider, accountId, credential.access, credential.refresh, credential.expires,
+ credential.accountId, credential.projectId, credential.source,
+ target.adapter, target.baseUrl, target.authMode, target.disabled === true,
+ ])).digest("hex");
+}
+
+function explicitQuotaDestination(provider: string, config: OcxProviderConfig): boolean {
+ if (config.disabled === true || config.authMode !== "oauth") return false;
+ if (provider === "kimi") return isCanonicalKimiCodeBaseUrl(config.baseUrl);
+ if (provider === "command-code") return isCanonicalCommandCodeBaseUrl(config.baseUrl);
+ // These readers use fixed canonical billing origins, never config.baseUrl.
+ return provider === "xai" || provider === "cursor";
+}
+
+async function readExplicitAccountQuota(provider: string, accountId: string, configured?: OcxProviderConfig): Promise<{
+ result: ProviderQuotaProbeResult;
+ identity: string | undefined;
+ isCurrent: () => boolean;
+} | null> {
+ const target = explicitQuotaConfig(provider, configured);
+ if (!target || !explicitQuotaDestination(provider, target)) return null;
+ const config = { ...target };
+ const epoch = explicitAccountEpoch;
+ const accessToken = await getTokenForAccountQuotaProbe(provider, accountId);
+ const credential = getAccountCredential(provider, accountId);
+ if (!credential || credential.access !== accessToken) return null;
+ // Pair the post-renewal credential with the destination captured before renewal.
+ const identity = explicitQuotaIdentity(provider, accountId, config);
+ const isCurrent = () => epoch === explicitAccountEpoch
+ && identity === explicitQuotaIdentity(provider, accountId, configured);
+ if (!isCurrent()) return null;
+ let result: ProviderQuotaProbeResult;
+ switch (provider) {
+ case "xai": result = await fetchXaiQuota(provider, { accessToken, upstreamAccountId: credential.accountId }); break;
+ case "cursor": result = await fetchCursorQuota(provider, accessToken); break;
+ case "kimi": result = await fetchKimiQuota(provider, config, accessToken); break;
+ case "command-code": result = await fetchCommandCodeQuota(provider, config, accessToken); break;
+ default: return null;
+ }
+ return { result, identity, isCurrent };
+}
+
+async function fetchExplicitAccountQuota(provider: string, accountId: string, force: boolean, configured?: OcxProviderConfig): Promise {
+ const key = accountCacheKey(provider, accountId);
+ const identity = explicitQuotaIdentity(provider, accountId, configured);
+ const previous = accountQuotaCache.get(key);
+ const cached = identity && previous?.identity === identity && previous.isCurrent?.() ? previous : undefined;
+ if (!force && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS
+ && (!cached.quota || Date.now() - cached.quota.updatedAt < LAST_GOOD_MAX_AGE_MS)) return cached;
+ const flightKey = `${key}\u0000${identity ?? "missing"}`;
+ const running = accountQuotaInflight.get(flightKey);
+ if (running) return running;
+ const epoch = explicitAccountEpoch;
+ const lastGood = cached?.quota && Date.now() - cached.quota.updatedAt < LAST_GOOD_MAX_AGE_MS ? cached.quota : null;
+ const flight = (async (): Promise => {
+ let read: Awaited> = null;
+ try { read = await readExplicitAccountQuota(provider, accountId, configured); } catch { /* unavailable */ }
+ const isCurrent = read?.isCurrent ?? (() => epoch === explicitAccountEpoch && !!identity
+ && identity === explicitQuotaIdentity(provider, accountId, configured));
+ const result = read?.result;
+ const current = epoch === explicitAccountEpoch && isCurrent();
+ const quota = current && result && typeof result !== "symbol" ? result.quota : null;
+ const empty = result === AUTHORITATIVE_EMPTY_QUOTA;
+ const entry: AccountQuotaCacheEntry = {
+ ts: Date.now(),
+ quota: quota ?? (current && result !== TERMINAL_QUOTA_FAILURE && !empty
+ && lastGood && Date.now() - lastGood.updatedAt < LAST_GOOD_MAX_AGE_MS ? lastGood : null),
+ ...(!current || (!quota && !empty) ? { unavailable: true as const } : {}),
+ identity: read?.identity ?? identity,
+ isCurrent: () => epoch === explicitAccountEpoch && isCurrent(),
+ };
+ if (entry.isCurrent?.()) accountQuotaCache.set(key, entry);
+ return entry;
+ })().finally(() => { if (accountQuotaInflight.get(flightKey) === flight) accountQuotaInflight.delete(flightKey); });
+ accountQuotaInflight.set(flightKey, flight);
+ return flight;
+}
+
+async function fetchExplicitCurrentQuota(provider: string, config: OcxProviderConfig, liveConfig: OcxConfig): Promise {
+ const id = getAccountSet(provider)?.activeAccountId;
+ if (!id) return null;
+ const read = await readExplicitAccountQuota(provider, id, config);
+ if (!read) return null;
+ const isCurrent = () => liveConfig.providers[provider] === config
+ && read.isCurrent() && getAccountSet(provider)?.activeAccountId === id;
+ if (!isCurrent()) return TERMINAL_QUOTA_FAILURE;
+ if (read.result && typeof read.result !== "symbol") accountReportCurrent.set(read.result, isCurrent);
+ return read.result;
+}
+
async function fetchAccountQuota(
provider: string,
accountId: string,
forceRefresh: boolean,
+ providerConfig?: OcxProviderConfig,
): Promise {
+ if (!supportsPerAccountQuota(provider)) return { ts: Date.now(), quota: null, unavailable: true };
+ if (explicitAccountReader(provider)) return fetchExplicitAccountQuota(provider, accountId, forceRefresh, providerConfig);
const key = accountCacheKey(provider, accountId);
const writerGeneration = captureConfigGeneration();
const cached = accountQuotaCache.get(key);
@@ -1817,8 +1936,10 @@ async function fetchAccountQuota(
const projectId = getAccountCredential(provider, accountId)?.projectId;
if (!projectId) throw new Error("antigravity account has no project id");
quota = await fetchAntigravityUsageQuota(token, projectId);
- } else {
+ } else if (provider === "anthropic") {
quota = await fetchAnthropicUsageQuota(token);
+ } else {
+ return { ts: Date.now(), quota: null, unavailable: true };
}
}
if (!quota) {
@@ -1871,18 +1992,27 @@ async function fetchAccountQuota(
export async function fetchProviderAccountQuotas(
provider: string,
forceRefresh = false,
+ providerConfig?: OcxProviderConfig,
): Promise {
if (!supportsPerAccountQuota(provider)) return [];
const set = getAccountSet(provider);
if (!set) return [];
- return await Promise.all(set.accounts.map(async account => {
- const entry = await fetchAccountQuota(provider, account.id, forceRefresh);
- return {
+ return mapQuotaRoster(set.accounts, async account => {
+ const entry = await fetchAccountQuota(provider, account.id, forceRefresh, providerConfig);
+ const result: ProviderAccountQuota = {
accountId: account.id,
quota: entry.quota,
...(entry.unavailable ? { unavailable: true as const } : {}),
};
- }));
+ if (!explicitAccountReader(provider)) return result;
+ const identity = entry.identity;
+ Object.defineProperty(result, "isCurrent", { value: () => {
+ if (entry.isCurrent) return entry.isCurrent();
+ const credential = getAccountCredential(provider, account.id);
+ return !!credential && (!identity || explicitQuotaIdentity(provider, account.id, providerConfig) === identity);
+ } });
+ return result;
+ });
}
function normalizedBaseUrl(value: string): string | null {
@@ -2001,10 +2131,10 @@ function parseKimiQuotaPayload(value: unknown): ProviderQuota | null {
return hasQuotaRows(quota) ? quota : null;
}
-async function resolveKimiQuotaBearer(config: OcxProviderConfig): Promise {
+async function resolveKimiQuotaBearer(config: OcxProviderConfig, accountId?: string): Promise {
if (config.authMode === "oauth") {
try {
- return await getValidAccessToken("kimi");
+ return accountId ? await getTokenForAccountQuotaProbe("kimi", accountId) : null;
} catch {
return null;
}
@@ -2016,13 +2146,13 @@ async function resolveKimiQuotaBearer(config: OcxProviderConfig): Promise {
+async function fetchKimiQuota(provider: string, config: OcxProviderConfig, accessToken: string): Promise {
// Never release credentials to a user-edited or lookalike provider host.
if (!isCanonicalKimiCodeBaseUrl(config.baseUrl)) return null;
- const accessToken = await resolveKimiQuotaBearer(config);
if (!accessToken) return null;
const response = await fetch(KIMI_CODE_USAGE_URL, {
headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` },
+ redirect: "error",
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) return null;
@@ -2109,10 +2239,10 @@ async function fetchCommandCodeSpend(
}
/** OAuth access token or ACTIVE Provider-API key for the Command Code quota probe. */
-async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig): Promise {
+async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig, accountId?: string): Promise {
if (config.authMode === "oauth") {
try {
- return await getValidAccessToken("command-code");
+ return accountId ? await getTokenForAccountQuotaProbe("command-code", accountId) : null;
} catch {
return null;
}
@@ -2127,10 +2257,9 @@ async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig): Promise
* usage view uses (windowLimits.fiveHour / windowLimits.weekly), plus soft
* whoami (team orgId scoping) and subscription-scoped spend for creditsUsd.
*/
-async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig): Promise {
+async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig, bearer: string): Promise {
// Never release credentials to a user-edited or lookalike provider host.
if (!isCanonicalCommandCodeBaseUrl(config.baseUrl)) return null;
- const bearer = await resolveCommandCodeQuotaBearer(config);
if (!bearer) return null;
const whoamiBody = await fetchCommandCodeJson(COMMAND_CODE_WHOAMI_URL, bearer);
const whoami = asRecord(whoamiBody?.data) ?? whoamiBody;
@@ -2170,13 +2299,7 @@ async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig
}
/** Cursor included usage via api2.cursor.sh (Bearer from OAuth) — unofficial, may change. */
-async function fetchCursorQuota(provider: string): Promise {
- let accessToken: string;
- try {
- accessToken = await getValidAccessToken("cursor");
- } catch {
- return null;
- }
+async function fetchCursorQuota(provider: string, accessToken: string): Promise {
const authHeaders = {
Accept: "application/json",
@@ -2189,6 +2312,7 @@ async function fetchCursorQuota(provider: string): Promise Promise;
+
+/** Same selector drives cheap capabilities and uncached reads; never resolves credentials. */
+function keyQuotaReaderForProvider(name: string, provider: OcxProviderConfig): KeyQuotaReader | null {
+ if (provider.disabled === true || (provider.authMode ?? "key") !== "key") return null;
+ if (isCanonicalKimiCodeBaseUrl(provider.baseUrl)) {
+ return async (id, config) => {
+ const bearer = await resolveKimiQuotaBearer(config);
+ return bearer ? fetchKimiQuota(id, config, bearer) : null;
+ };
+ }
+ if (name === "commandcode" && isCanonicalCommandCodeBaseUrl(provider.baseUrl)) {
+ return async (id, config) => {
+ const bearer = await resolveCommandCodeQuotaBearer(config);
+ return bearer ? fetchCommandCodeQuota(id, config, bearer) : null;
+ };
+ }
+ if (registryEntryForProviderDestination(provider)?.id === "opencode-go") return fetchOpenCodeGoQuota;
+ if (isCanonicalA6apiBaseUrl(provider.baseUrl)) return fetchA6apiQuota;
+ if (name === "openrouter" && isCanonicalOpenRouterBaseUrl(provider.baseUrl)) return fetchOpenRouterQuota;
+ if (name === "deepseek" && isCanonicalDeepSeekBaseUrl(provider.baseUrl)) return fetchDeepSeekQuota;
+ if (name === "cline-pass" && isCanonicalClineBaseUrl(provider.baseUrl)) return fetchClineQuota;
+ if (isCanonicalOllamaCloudBaseUrl(provider.baseUrl ?? getProviderRegistryEntry(name)?.baseUrl)) return fetchOllamaCloudQuota;
+ if (["zai", "glm", "glm-cn", "zhipu-bigmodel-coding"].includes(name) && isCanonicalZaiBaseUrl(provider.baseUrl)) return fetchZaiQuota;
+ if (["minimax", "minimax-cn"].includes(name) && isCanonicalMinimaxBaseUrl(provider.baseUrl)) return fetchMinimaxQuota;
+ if (name === "moonshot" && isCanonicalMoonshotBaseUrl(provider.baseUrl)) return fetchMoonshotQuota;
+ if (name === "venice" && isCanonicalVeniceBaseUrl(provider.baseUrl)) return fetchVeniceQuota;
+ if (name === "synthetic" && isCanonicalSyntheticBaseUrl(provider.baseUrl)) return fetchSyntheticQuota;
+ if (name === "deepinfra" && isCanonicalDeepInfraBaseUrl(provider.baseUrl)) return fetchDeepInfraQuota;
+ if (name === "neuralwatt" && isCanonicalNeuralwattBaseUrl(provider.baseUrl)) return fetchNeuralwattQuota;
+ return null;
+}
+
+export function providerApiKeyQuotaMode(name: string, provider: OcxProviderConfig): AccountQuotaMode {
+ return keyQuotaReaderForProvider(name, provider) ? "probe" : "unsupported";
+}
+
+export async function fetchProviderApiKeyQuotas(config: OcxConfig, name: string, forceRefresh = false): Promise {
+ const provider = config.providers[name];
+ if (!provider || !keyQuotaReaderForProvider(name, provider)) return [];
+ return readProviderApiKeyQuotas(config, name, forceRefresh, async (isolatedProvider, isolatedConfig) => {
+ const result = await maybeFetchProviderQuota(name, isolatedProvider, isolatedConfig, false);
+ if (result === TERMINAL_QUOTA_FAILURE) return { kind: "terminal" };
+ if (result === AUTHORITATIVE_EMPTY_QUOTA) return { kind: "empty" };
+ return result ? { kind: "quota", quota: result.quota } : { kind: "unavailable" };
+ });
+}
+
async function maybeFetchProviderQuota(
name: string,
provider: OcxProviderConfig,
@@ -2607,78 +2781,15 @@ async function maybeFetchProviderQuota(
if (isBuiltInChatGptForwardProvider(name, provider)) {
return fetchChatGptForwardQuota(config, name, provider, forceRefresh, prefetchedCodexSnapshot);
}
- if (provider.authMode === "oauth" && name === "xai") return fetchXaiQuota(name);
+ if (provider.authMode === "oauth" && explicitAccountReader(name)) return await fetchExplicitCurrentQuota(name, provider, config);
if (provider.authMode === "oauth" && name === "anthropic") return fetchAnthropicQuota(name);
- if (provider.authMode === "oauth" && name === "cursor") return fetchCursorQuota(name);
if (provider.authMode === "oauth" && name === "google-antigravity") return fetchAntigravityQuota(name, provider);
if (provider.authMode === "oauth" && name === "kiro") return fetchKiroQuota(name);
// Passive providers (meta-muse): Meta publishes no quota endpoint, so there is no
// probe to run — the row is the active account's last in-band observation.
if (provider.authMode === "oauth" && hasPassiveAccountQuota(name)) return fetchPassiveProviderQuota(name);
- // Kimi Code `/usages` accepts OAuth or coding-plan API keys, but only on the canonical
- // host and only for real key auth — forward/local modes carry no credential of ours.
- if (provider.authMode === "oauth" && name === "kimi") return fetchKimiQuota(name, provider);
- if (provider.authMode === "key" && isCanonicalKimiCodeBaseUrl(provider.baseUrl)) {
- return fetchKimiQuota(name, provider);
- }
- // OAuth account login or Provider-API key only; forward/local modes carry no
- // credential of ours on the canonical host.
- if (provider.authMode === "oauth" && name === "command-code") {
- return fetchCommandCodeQuota(name, provider);
- }
- if ((provider.authMode ?? "key") === "key" && name === "commandcode"
- && isCanonicalCommandCodeBaseUrl(provider.baseUrl)) {
- return fetchCommandCodeQuota(name, provider);
- }
- // Identify OpenCode Go by where it routes, not by what the row is called. Multi-account
- // setups keep the same destination under names like `opencode-go-2` (#1924), and those rows
- // silently had no quota panel and no `ocx provider quota --json` report while the literal
- // name was the gate. `registryEntryForProviderDestination` is the existing predicate for
- // exactly this question: normalized endpoint + adapter + key auth, so a canonical URL behind
- // a different adapter is still not OpenCode Go. The defensive URL check inside
- // `fetchOpenCodeGoQuota` stays — sending a key anywhere must not depend on this gate.
- if ((provider.authMode ?? "key") === "key" && registryEntryForProviderDestination(provider)?.id === "opencode-go") {
- return fetchOpenCodeGoQuota(name, provider);
- }
- if ((provider.authMode ?? "key") === "key" && isCanonicalA6apiBaseUrl(provider.baseUrl)) {
- return fetchA6apiQuota(name, provider);
- }
- if ((provider.authMode ?? "key") === "key" && name === "openrouter") {
- return fetchOpenRouterQuota(name, provider);
- }
- if ((provider.authMode ?? "key") === "key" && name === "deepseek") {
- return fetchDeepSeekQuota(name, provider);
- }
- if ((provider.authMode ?? "key") === "key" && name === "cline-pass") {
- return fetchClineQuota(name, provider);
- }
- if ((provider.authMode ?? "key") === "key"
- && (name === "ollama-cloud" || isCanonicalOllamaCloudBaseUrl(provider.baseUrl))) {
- return fetchOllamaCloudQuota(name, provider);
- }
- if ((provider.authMode ?? "key") === "key"
- && (name === "zai" || name === "glm" || name === "glm-cn" || name === "zhipu-bigmodel-coding")) {
- return fetchZaiQuota(name, provider);
- }
- if ((provider.authMode ?? "key") === "key" && (name === "minimax" || name === "minimax-cn")) {
- return fetchMinimaxQuota(name, provider);
- }
- if ((provider.authMode ?? "key") === "key" && name === "moonshot") {
- return fetchMoonshotQuota(name, provider);
- }
- if ((provider.authMode ?? "key") === "key" && name === "venice") {
- return fetchVeniceQuota(name, provider);
- }
- if ((provider.authMode ?? "key") === "key" && name === "synthetic") {
- return fetchSyntheticQuota(name, provider);
- }
- if ((provider.authMode ?? "key") === "key" && name === "deepinfra") {
- return fetchDeepInfraQuota(name, provider);
- }
- if ((provider.authMode ?? "key") === "key" && name === "neuralwatt") {
- return fetchNeuralwattQuota(name, provider);
- }
- return null;
+ const reader = keyQuotaReaderForProvider(name, provider);
+ return reader ? reader(name, provider) : null;
} catch {
return null;
}
diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts
index 763cbc0257..07884d5f4f 100644
--- a/src/server/management/oauth-account-routes.ts
+++ b/src/server/management/oauth-account-routes.ts
@@ -30,7 +30,7 @@ import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/ke
import { deriveProviderPresets } from "../../providers/derive";
import { providerCodexAccountMode } from "../../providers/registry";
import { routedSlug, slugEquals } from "../../providers/slug-codec";
-import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderQuotaReports, hasPassiveAccountQuota, readPassiveProviderAccountQuotas, supportsPerAccountQuota } from "../../providers/quota";
+import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderApiKeyQuotas, fetchProviderQuotaReports, providerOAuthAccountQuotaMode, providerApiKeyQuotaMode, readPassiveProviderAccountQuotas } from "../../providers/quota";
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
import { clearThreadAccountMap } from "../../codex/routing";
import {
@@ -254,7 +254,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
if (url.pathname === "/api/oauth/accounts" && req.method === "GET") {
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
- const status = getLoginStatus(provider);
+ const quotaMode = providerOAuthAccountQuotaMode(provider);
+ const quotaProvider = config.providers[provider];
const { getAccountSet } = await import("../../oauth/store");
const {
oauthAccountHealthFields,
@@ -274,26 +275,26 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
needsReauth: summary.needsReauth === true,
reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined,
});
- return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health) };
+ return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health), quotaMode };
}),
};
};
// Per-account rate limits: Anthropic reports usage per credential, so every logged-in
// account can show its own 5h/weekly bars (not just the active one). Opt-in via ?quota=1
// so the plain account list stays a cheap local read; ?refresh=1 bypasses the TTL.
- const wantQuota = url.searchParams.get("quota") === "1" && supportsPerAccountQuota(provider);
+ const wantQuota = url.searchParams.get("quota") === "1" && quotaMode === "probe";
// Meta publishes no quota endpoint: its usage is observed in-band on streaming turns
// and read back from the cache here. `?refresh=1` is accepted and ignored on this
// path rather than rejected -- the GUI sends it for every provider on a manual
// refresh, and a 400 would report an error for what is simply a no-op.
- const passiveQuota = url.searchParams.get("quota") === "1" && hasPassiveAccountQuota(provider);
+ const passiveQuota = url.searchParams.get("quota") === "1" && quotaMode === "passive";
if (!wantQuota && !passiveQuota) return jsonResponse(projectAccounts());
const forceRefresh = url.searchParams.get("refresh") === "1";
// Probing may refresh the active credential and mark needsReauth — project health
// from the post-probe store so the response is not stale.
const rows = passiveQuota
? readPassiveProviderAccountQuotas(provider)
- : await fetchProviderAccountQuotas(provider, forceRefresh);
+ : await fetchProviderAccountQuotas(provider, forceRefresh, quotaProvider);
const byId = new Map(rows.map(row => [row.accountId, row]));
const projected = projectAccounts();
return jsonResponse({
@@ -301,10 +302,13 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
accounts: projected.accounts.map(account => {
const row = byId.get(account.id);
if (!row) return account;
+ if (config.providers[provider] !== quotaProvider || row.isCurrent?.() === false) {
+ return { ...account, quota: null, quotaUnavailable: true };
+ }
return {
...account,
quota: row.quota,
- ...(row.unavailable ? { quotaUnavailable: true } : {}),
+ ...(quotaMode === "probe" ? { quotaUnavailable: row.unavailable === true } : {}),
};
}),
});
@@ -572,7 +576,29 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
const name = (url.searchParams.get("name") ?? "").trim();
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
const { listProviderApiKeys } = await import("../../providers/api-keys");
- return jsonResponse(listProviderApiKeys(config, name));
+ const projectKeys = () => {
+ const listed = listProviderApiKeys(config, name);
+ const provider = config.providers[name];
+ const quotaMode = provider ? providerApiKeyQuotaMode(name, provider) : "unsupported";
+ return { ...listed, keys: listed.keys.map(key => ({ ...key, quotaMode })) };
+ };
+ const initial = projectKeys();
+ if (url.searchParams.get("quota") !== "1" || !initial.keys.some(key => key.quotaMode === "probe")) {
+ return jsonResponse(initial);
+ }
+ const rows = await fetchProviderApiKeyQuotas(config, name, url.searchParams.get("refresh") === "1");
+ const byId = new Map(rows.map(row => [row.keyId, row]));
+ const current = projectKeys();
+ return jsonResponse({
+ activeId: current.activeId,
+ keys: current.keys.map(key => {
+ const row = byId.get(key.id);
+ if (!row || key.quotaMode !== "probe") return key;
+ if (!row.isCurrent()) return { ...key, quota: null, quotaUnavailable: true };
+ // Internal identity/epoch checks never enter the JSON DTO.
+ return { ...key, quota: row.quota, quotaUnavailable: row.unavailable === true };
+ }),
+ });
}
if (url.pathname === "/api/providers/keys" && req.method === "POST") {
const body = await readManagementJsonBodyOr(req, {}) as { name?: string; key?: string; label?: string };
diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md
index 94fdc6e148..7a38460fc4 100644
--- a/structure/05_gui-and-management-api.md
+++ b/structure/05_gui-and-management-api.md
@@ -341,6 +341,27 @@ keeps the saved state and renders fixed `ocx sync` guidance without server/accou
## Usage accounting
+Account quota discovery is capability-based. Cheap OAuth and provider-key lists include
+`quotaMode` (`probe`, `passive`, or `unsupported`) without contacting upstream quota APIs.
+`GET /api/oauth/accounts?provider=..."a=1` and
+`GET /api/providers/keys?name=..."a=1` enrich each supported credential separately;
+`refresh=1` bypasses settled quota cache while joining a current same-identity read.
+OAuth readers use the named stored account; key readers use isolated per-key configuration,
+never active-key mutation or the provider-wide cache. Response projection rechecks key identity
+and exposes only quota/availability fields, not its internal identity guard. Passive observations
+retain their original timestamp and never trigger inference or token renewal. Unsupported,
+unobserved, failed and measured-zero readings remain distinct; multiple keys are not summed
+because they may share one upstream balance.
+
+Provider details use one account-quota reading renderer for Overview, Usage and Accounts/API
+keys. Current-account usage sits below usage statistics; a known-mode active row is authoritative
+even when empty, so a newly selected passive account cannot inherit a previous account's cached
+report. Pool reports project only `aggregation.currentAccount.quota` with its own timestamp;
+missing or malformed aggregation stays unknown rather than using total capacity. Shared states
+include credits-only and measured-zero readings, unsupported, unobserved, explicit pending and
+unavailable-with-last-good. Forced account/key enrichment settles before its control reports a
+completed check, and provider-report waiters are bound to the exact refresh epoch.
+
`src/usage/log.ts` writes append-only JSONL to `~/.opencodex/usage.jsonl` with file mode `0o600`.
An opt-in shadow-call rewrite persists the bounded, redacted original helper model as
`shadowCallRewrittenFrom`, so helper traffic remains identifiable after restart without storing
diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json
index 5a0ade2c3a..18e85cd235 100644
--- a/tests/fixtures/test-layout-expected.json
+++ b/tests/fixtures/test-layout-expected.json
@@ -762,6 +762,7 @@
"process-state.test.ts": "service",
"project-config-warnings.test.ts": "codex-integration",
"provider-account-quota-persistence.test.ts": "providers",
+ "provider-account-quota-routes.test.ts": "server",
"provider-account-quota.test.ts": "providers",
"provider-api-keys.test.ts": "providers",
"provider-capacity.test.ts": "providers",
diff --git a/tests/providers/provider-account-quota.test.ts b/tests/providers/provider-account-quota.test.ts
index 0f0b242df6..1855e92d66 100644
--- a/tests/providers/provider-account-quota.test.ts
+++ b/tests/providers/provider-account-quota.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 { saveCredential } from "../../src/oauth/store";
+import { getAccountSet, saveCredential, setActiveAccount } from "../../src/oauth/store";
import type { OcxConfig } from "../../src/types";
import {
clearAccountQuotaCache,
@@ -13,6 +13,7 @@ import {
reconcileProviderAccountQuotaRows,
resetProviderQuotaReconcileStateForTests,
supportsPerAccountQuota,
+ providerOAuthAccountQuotaMode,
} from "../../src/providers/quota";
import { removeTreeWithRetry } from "../helpers/remove-tree";
@@ -204,13 +205,12 @@ describe("fetchProviderAccountQuotas", () => {
test("providers without a per-account usage API are skipped", async () => {
expect(supportsPerAccountQuota("anthropic")).toBe(true);
- // Kiro joined this list once it grew a usage reader; xAI has no per-account usage API,
- // so it now carries the "unsupported providers never reach the network" contract.
+ // Login capability alone must never select another provider's quota reader.
expect(supportsPerAccountQuota("kiro")).toBe(true);
- expect(supportsPerAccountQuota("xai")).toBe(false);
+ expect(supportsPerAccountQuota("github-copilot")).toBe(false);
let called = false;
globalThis.fetch = (async () => { called = true; return new Response("{}", { status: 200 }); }) as typeof fetch;
- expect(await fetchProviderAccountQuotas("xai")).toEqual([]);
+ expect(await fetchProviderAccountQuotas("github-copilot")).toEqual([]);
expect(called).toBe(false);
});
@@ -424,6 +424,228 @@ describe("fetchProviderAccountQuotas", () => {
});
});
+describe("explicit OAuth account quota readers", () => {
+ test("explicit OAuth probe failure drops last-good quota that expires during the upstream await", async () => {
+ const realDateNow = Date.now;
+ const observedAt = realDateNow();
+ await saveCredential("kimi", {
+ access: "quota-settlement-fixture", refresh: "refresh-settlement-fixture",
+ expires: observedAt + 60 * 60_000, accountId: "quota-settlement-user",
+ });
+ let now = observedAt;
+ let calls = 0;
+ globalThis.fetch = (async () => {
+ calls++;
+ if (calls === 1) return Response.json({ usage: { limit: 100, used: 25 } });
+ expect(now).toBe(observedAt + 29 * 60_000 + 59_000);
+ now = observedAt + 30 * 60_000;
+ return new Response("{}", { status: 429 });
+ }) as typeof fetch;
+ Date.now = () => now;
+ try {
+ expect((await fetchProviderAccountQuotas("kimi"))[0]?.quota?.updatedAt).toBe(observedAt);
+ now = observedAt + 29 * 60_000 + 59_000;
+ const [failed] = await fetchProviderAccountQuotas("kimi", true);
+ expect(calls).toBe(2);
+ expect(failed?.quota).toBeNull();
+ expect(failed?.unavailable).toBe(true);
+ } finally {
+ Date.now = realDateNow;
+ }
+ });
+
+ test("a recent failed explicit OAuth probe cannot extend a last-good measurement past thirty minutes", async () => {
+ const realDateNow = Date.now;
+ const observedAt = realDateNow();
+ await saveCredential("kimi", {
+ access: "quota-age-fixture", refresh: "refresh-age-fixture",
+ expires: observedAt + 60 * 60_000, accountId: "quota-age-user",
+ });
+ let now = observedAt;
+ let calls = 0;
+ globalThis.fetch = (async () => {
+ calls++;
+ return calls === 1 ? Response.json({ usage: { limit: 100, used: 25 } }) : new Response("{}", { status: 429 });
+ }) as typeof fetch;
+ Date.now = () => now;
+ try {
+ const [initial] = await fetchProviderAccountQuotas("kimi");
+ expect(initial?.quota?.updatedAt).toBe(observedAt);
+ now = observedAt + 29 * 60_000 + 59_000;
+ const [failed] = await fetchProviderAccountQuotas("kimi", true);
+ expect(failed?.unavailable).toBe(true);
+ expect(failed?.quota?.updatedAt).toBe(observedAt);
+ expect(calls).toBe(2);
+ now += 1_000;
+ const [expired] = await fetchProviderAccountQuotas("kimi");
+ expect(expired?.quota).toBeNull();
+ expect(expired?.unavailable).toBe(true);
+ expect(calls).toBe(3);
+ now += 1;
+ expect((await fetchProviderAccountQuotas("kimi"))[0]?.quota).toBeNull();
+ expect(calls).toBe(3);
+ } finally {
+ Date.now = realDateNow;
+ }
+ });
+
+ const cases = [
+ { provider: "xai", adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", field: "weeklyPercent" },
+ { provider: "cursor", adapter: "cursor", baseUrl: "https://api2.cursor.sh", field: "monthlyPercent" },
+ { provider: "kimi", adapter: "openai-chat", baseUrl: "https://api.kimi.com/coding/v1", field: "weeklyPercent" },
+ { provider: "command-code", adapter: "command-code", baseUrl: "https://api.commandcode.ai", field: "fiveHourPercent" },
+ ] as const;
+
+ test("capability distinguishes seven readers, passive observations and unsupported login providers", () => {
+ for (const provider of ["anthropic", "kiro", "google-antigravity", ...cases.map(row => row.provider)]) {
+ expect(providerOAuthAccountQuotaMode(provider)).toBe("probe");
+ }
+ expect(providerOAuthAccountQuotaMode("meta-muse")).toBe("passive");
+ expect(supportsPerAccountQuota("meta-muse")).toBe(false);
+ expect(providerOAuthAccountQuotaMode("github-copilot")).toBe("unsupported");
+ });
+
+ for (const fixture of cases) {
+ test(`${fixture.provider} reads both credentials without switching active selection`, async () => {
+ const expires = Date.now() + 60 * 60_000;
+ for (const name of ["first", "second"]) {
+ await saveCredential(fixture.provider, { access: `quota-${name}`, refresh: `refresh-${name}`, expires, accountId: `user-${name}`, email: `${name}@example.com` });
+ }
+ const active = getAccountSet(fixture.provider)!.activeAccountId;
+ const seen = new Set();
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const headers = new Headers(init?.headers);
+ const auth = headers.get("authorization")!;
+ const first = auth === "Bearer quota-first";
+ const amount = first ? 12 : 68;
+ const label = first ? "first" : "second";
+ seen.add(auth);
+ const url = String(input);
+ expect(init?.redirect).toBe("error");
+ expect(new URL(url).protocol).toBe("https:");
+ if (fixture.provider === "xai") {
+ expect(headers.get("x-userid")).toBe(`user-${label}`);
+ return Response.json({ config: { creditUsagePercent: amount, currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY" } } });
+ }
+ if (fixture.provider === "cursor") return Response.json({ planUsage: { totalPercentUsed: amount } });
+ if (fixture.provider === "kimi") return Response.json({ usage: { limit: 100, used: amount } });
+ if (url.endsWith("/alpha/whoami")) return Response.json({ org: { id: `org-${label}` } });
+ expect(new URL(url).searchParams.get("orgId")).toBe(`org-${label}`);
+ if (url.includes("subscriptions")) return Response.json({ currentPeriodStart: "2026-09-01T00:00:00Z" });
+ if (url.includes("usage/summary")) return Response.json({ totalCost: amount });
+ return Response.json({ credits: { monthlyCredits: 10 }, windowLimits: { fiveHour: { cap: 100, used: amount } } });
+ }) as typeof fetch;
+ const rows = await fetchProviderAccountQuotas(fixture.provider, false, { adapter: fixture.adapter, baseUrl: fixture.baseUrl, authMode: "oauth" });
+ expect(rows.map(row => row.quota?.[fixture.field]).sort((a, b) => (a ?? 0) - (b ?? 0))).toEqual([12, 68]);
+ expect(seen.size).toBe(2);
+ expect(getAccountSet(fixture.provider)!.activeAccountId).toBe(active);
+ expect(rows.every(row => row.isCurrent?.())).toBe(true);
+ expect(Object.keys(rows[0]!)).toEqual(["accountId", "quota"]);
+ expect(JSON.stringify(rows)).not.toContain("quota-first");
+ expect(JSON.stringify(rows)).not.toContain("identity");
+ clearAccountQuotaCache(fixture.provider);
+ expect(rows.every(row => row.isCurrent?.() === false)).toBe(true);
+ });
+ }
+
+ test("same-id credential replacement invalidates a pending row and cannot poison the cache", async () => {
+ const credential = { access: "cursor-before", refresh: "cursor-refresh", expires: Date.now() + 60 * 60_000, accountId: "same-user", email: "same@example.com" };
+ await saveCredential("cursor", credential);
+ let release!: () => void;
+ const gate = new Promise(resolve => { release = resolve; });
+ let started!: () => void;
+ const entered = new Promise(resolve => { started = resolve; });
+ globalThis.fetch = (async () => {
+ started(); await gate;
+ return Response.json({ planUsage: { totalPercentUsed: 95 } });
+ }) as typeof fetch;
+ const pending = fetchProviderAccountQuotas("cursor");
+ await entered;
+ await saveCredential("cursor", { ...credential, access: "cursor-after" });
+ release();
+ const [row] = await pending;
+ expect(row?.isCurrent?.()).toBe(false);
+ expect(row?.quota).toBeNull();
+ expect(getCachedProviderAccountQuota("cursor", row!.accountId)).toBeNull();
+ });
+
+ test("current provider report rejects an account switch during its explicit read", async () => {
+ for (const accountId of ["first-user", "second-user"]) {
+ await saveCredential("cursor", { access: accountId, refresh: `${accountId}-refresh`, expires: Date.now() + 60 * 60_000, accountId });
+ }
+ const set = getAccountSet("cursor")!;
+ const next = set.accounts.find(row => row.id !== set.activeAccountId)!;
+ let release!: () => void;
+ const gate = new Promise(resolve => { release = resolve; });
+ let started!: () => void;
+ const entered = new Promise(resolve => { started = resolve; });
+ globalThis.fetch = (async () => { started(); await gate; return Response.json({ planUsage: { totalPercentUsed: 90 } }); }) as typeof fetch;
+ const config = { defaultProvider: "cursor", providers: { cursor: { adapter: "cursor", authMode: "oauth", baseUrl: "https://api2.cursor.sh" } } } as OcxConfig;
+ const pending = fetchProviderQuotaReports(config, true);
+ await entered;
+ await setActiveAccount("cursor", next.id);
+ release();
+ expect((await pending).reports).toEqual([]);
+ });
+
+ test("invalid configured Kimi destination never resolves to the default quota host", async () => {
+ await saveCredential("kimi", { access: "kimi-fixture", refresh: "refresh", expires: Date.now() + 60 * 60_000, accountId: "kimi-user" });
+ let calls = 0;
+ globalThis.fetch = (async () => { calls++; return Response.json({}); }) as typeof fetch;
+ const rows = await fetchProviderAccountQuotas("kimi", true, { adapter: "openai-chat", authMode: "oauth", baseUrl: "https://kimi.example.invalid/v1" });
+ expect(rows[0]?.unavailable).toBe(true);
+ expect(rows[0]?.quota).toBeNull();
+ expect(calls).toBe(0);
+ });
+
+ test("OAuth roster uses four workers and force joins same-identity work", async () => {
+ for (let i = 0; i < 6; i++) {
+ await saveCredential("cursor", { access: `cursor-${i}`, refresh: `refresh-${i}`, expires: Date.now() + 60 * 60_000, accountId: `user-${i}` });
+ }
+ let release!: () => void;
+ const gate = new Promise(resolve => { release = resolve; });
+ let fourStarted!: () => void;
+ const entered = new Promise(resolve => { fourStarted = resolve; });
+ let calls = 0;
+ let active = 0;
+ let peak = 0;
+ globalThis.fetch = (async () => {
+ calls++; active++; peak = Math.max(peak, active);
+ if (calls === 4) fourStarted();
+ await gate;
+ active--;
+ return Response.json({ planUsage: { totalPercentUsed: 20 } });
+ }) as typeof fetch;
+ const pending = fetchProviderAccountQuotas("cursor");
+ await entered;
+ expect(calls).toBe(4);
+ release();
+ expect(await pending).toHaveLength(6);
+ expect(peak).toBe(4);
+ await fetchProviderAccountQuotas("cursor");
+ expect(calls).toBe(6);
+ await fetchProviderAccountQuotas("cursor", true);
+ expect(calls).toBe(12);
+ });
+
+ test("forced OAuth quota waits for an already-running same-identity probe", async () => {
+ await saveCredential("cursor", { access: "cursor-only", refresh: "refresh", expires: Date.now() + 60 * 60_000, accountId: "only-user" });
+ let release!: () => void;
+ const gate = new Promise(resolve => { release = resolve; });
+ let started!: () => void;
+ const entered = new Promise(resolve => { started = resolve; });
+ let calls = 0;
+ globalThis.fetch = (async () => { calls++; started(); await gate; return Response.json({ planUsage: { totalPercentUsed: 20 } }); }) as typeof fetch;
+ const ordinary = fetchProviderAccountQuotas("cursor");
+ await entered;
+ const force = fetchProviderAccountQuotas("cursor", true);
+ release();
+ const [a, b] = await Promise.all([ordinary, force]);
+ expect(calls).toBe(1);
+ expect(a[0]?.quota).toEqual(b[0]?.quota);
+ });
+});
+
describe("google-antigravity per-account quota (#1082)", () => {
const { setAntigravityAccountQuotaTransportForTests } = require("../../src/providers/quota") as typeof import("../../src/providers/quota");
const { getAccountSet } = require("../../src/oauth/store") as typeof import("../../src/oauth/store");
diff --git a/tests/providers/provider-api-keys.test.ts b/tests/providers/provider-api-keys.test.ts
index 88bb2393b7..919f9298b7 100644
--- a/tests/providers/provider-api-keys.test.ts
+++ b/tests/providers/provider-api-keys.test.ts
@@ -8,6 +8,10 @@ import { startServer } from "../../src/server";
import type { OcxConfig } from "../../src/types";
import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home";
import { removeTreeWithRetry } from "../helpers/remove-tree";
+import { listProviderApiKeys } from "../../src/providers/api-keys";
+import { clearProviderQuotaCache, fetchProviderApiKeyQuotas, fetchProviderQuotaReports, providerApiKeyQuotaMode } from "../../src/providers/quota";
+
+const originalUpstreamFetch = globalThis.fetch;
let testDir = "";
let previousHome: string | undefined;
@@ -25,6 +29,7 @@ function baseConfig(): OcxConfig {
}
beforeEach(() => {
+ clearProviderQuotaCache();
previousHome = process.env.OPENCODEX_HOME;
isolatedCodexHome = installIsolatedCodexHome("ocx-provider-keys-codex-");
testDir = mkdtempSync(join(tmpdir(), "ocx-provider-keys-"));
@@ -33,6 +38,8 @@ beforeEach(() => {
});
afterEach(() => {
+ globalThis.fetch = originalUpstreamFetch;
+ clearProviderQuotaCache();
if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
else process.env.OPENCODEX_HOME = previousHome;
isolatedCodexHome?.restore();
@@ -41,6 +48,19 @@ afterEach(() => {
});
describe("provider API key pool", () => {
+ test("cheap legacy projection never mutates even frozen config or writes config", () => {
+ const config = baseConfig();
+ const disk = readFileSync(join(testDir, "config.json"), "utf8");
+ Object.freeze(config.providers["opencode-go"]);
+ Object.freeze(config.providers);
+ Object.freeze(config);
+ const first = listProviderApiKeys(config, "opencode-go");
+ expect(listProviderApiKeys(config, "opencode-go")).toEqual(first);
+ expect(first.keys).toHaveLength(1);
+ expect(first.keys[0]?.active).toBe(true);
+ expect(config.providers["opencode-go"]?.apiKeyPool).toBeUndefined();
+ expect(readFileSync(join(testDir, "config.json"), "utf8")).toBe(disk);
+ });
test("GET seeds legacy bare apiKey into a one-entry pool with masked value", async () => {
const server = startServer(0);
try {
@@ -118,3 +138,289 @@ describe("provider API key pool", () => {
}
});
});
+
+function quotaKeyConfig(count = 2): OcxConfig {
+ return {
+ defaultProvider: "openrouter",
+ providers: { openrouter: {
+ adapter: "openai-chat", authMode: "key", baseUrl: "https://openrouter.ai/api/v1",
+ apiKey: "fixture-key-0", apiKeyPool: Array.from({ length: count }, (_, i) => ({ id: `slot-${i}`, key: `fixture-key-${i}` })),
+ } },
+ } as OcxConfig;
+}
+
+function keyQuotaResponse(percent: number): Response {
+ return Response.json({ data: { limit: 100, limit_remaining: 100 - percent } });
+}
+
+describe("credential-scoped key quota", () => {
+ test("the integrated Ollama Cloud reader supports each key without changing active configuration", async () => {
+ const provider = { adapter: "openai-chat", authMode: "key" as const, baseUrl: "https://ollama.com", apiKey: "fixture-first",
+ apiKeyPool: [{ id: "first", key: "fixture-first" }, { id: "second", key: "fixture-second" }] };
+ const config: OcxConfig = { port: 0, defaultProvider: "cloud-copy", providers: { "cloud-copy": provider } };
+ const before = JSON.stringify(config);
+ expect(providerApiKeyQuotaMode("cloud-copy", provider)).toBe("probe");
+ globalThis.fetch = (async (input, init) => {
+ expect(String(input)).toBe("https://ollama.com/api/usage");
+ expect(init?.redirect).toBe("error");
+ const first = new Headers(init?.headers).get("authorization") === "Bearer fixture-first";
+ return Response.json({ limits: { monthly: { usage: first ? 0.25 : 0.75 } } });
+ }) as typeof globalThis.fetch;
+ const rows = await fetchProviderApiKeyQuotas(config, "cloud-copy");
+ expect(rows.map(row => row.quota?.monthlyPercent)).toEqual([25, 75]);
+ expect(JSON.stringify(config)).toBe(before);
+ expect(providerApiKeyQuotaMode("cloud-copy", { ...provider, baseUrl: "https://ollama.example.invalid" })).toBe("unsupported");
+ });
+
+ test("canonical Kimi keys retain the default key auth mode when authMode is omitted", async () => {
+ const provider = { adapter: "openai-chat", baseUrl: "https://api.kimi.com/coding/v1", apiKey: "fixture-kimi-key" };
+ const config: OcxConfig = { port: 0, defaultProvider: "coding-alias", providers: { "coding-alias": provider } };
+ expect(providerApiKeyQuotaMode("coding-alias", provider)).toBe("probe");
+ let calls = 0;
+ globalThis.fetch = (async (input, init) => {
+ calls++;
+ expect(String(input)).toBe("https://api.kimi.com/coding/v1/usages");
+ expect(new Headers(init?.headers).get("authorization")).toBe("Bearer fixture-kimi-key");
+ return Response.json({ usage: { limit: 100, used: 25 } });
+ }) as typeof globalThis.fetch;
+ const [row] = await fetchProviderApiKeyQuotas(config, "coding-alias");
+ expect(row?.quota?.weeklyPercent).toBe(25);
+ expect(row?.isCurrent()).toBe(true);
+ expect(calls).toBe(1);
+ expect(providerApiKeyQuotaMode("coding-alias", { ...provider, authMode: "forward" })).toBe("unsupported");
+ });
+
+ test("key probe failure drops last-good quota that expires during the upstream await", async () => {
+ const config = quotaKeyConfig(1);
+ const realDateNow = Date.now;
+ const observedAt = realDateNow();
+ let now = observedAt;
+ let calls = 0;
+ globalThis.fetch = (async () => {
+ calls++;
+ if (calls === 1) return keyQuotaResponse(25);
+ expect(now).toBe(observedAt + 29 * 60_000 + 59_000);
+ now = observedAt + 30 * 60_000;
+ return new Response("{}", { status: 429 });
+ }) as typeof globalThis.fetch;
+ Date.now = () => now;
+ try {
+ expect((await fetchProviderApiKeyQuotas(config, "openrouter"))[0]?.quota?.updatedAt).toBe(observedAt);
+ now = observedAt + 29 * 60_000 + 59_000;
+ const [failed] = await fetchProviderApiKeyQuotas(config, "openrouter", true);
+ expect(calls).toBe(2);
+ expect(failed?.quota).toBeNull();
+ expect(failed?.unavailable).toBe(true);
+ } finally {
+ Date.now = realDateNow;
+ }
+ });
+
+ test("a recent failed key probe cannot extend a last-good measurement past thirty minutes", async () => {
+ const config = quotaKeyConfig(1);
+ const realDateNow = Date.now;
+ const observedAt = realDateNow();
+ let now = observedAt;
+ let calls = 0;
+ globalThis.fetch = (async () => {
+ calls++;
+ return calls === 1 ? keyQuotaResponse(25) : new Response("{}", { status: 429 });
+ }) as typeof globalThis.fetch;
+ Date.now = () => now;
+ try {
+ const [initial] = await fetchProviderApiKeyQuotas(config, "openrouter");
+ expect(initial?.quota?.updatedAt).toBe(observedAt);
+ now = observedAt + 29 * 60_000 + 59_000;
+ const [failed] = await fetchProviderApiKeyQuotas(config, "openrouter", true);
+ expect(failed?.unavailable).toBe(true);
+ expect(failed?.quota?.updatedAt).toBe(observedAt);
+ expect(calls).toBe(2);
+ // The attempt is only one second old, but the measurement has reached its bound.
+ now += 1_000;
+ const [expired] = await fetchProviderApiKeyQuotas(config, "openrouter");
+ expect(expired?.quota).toBeNull();
+ expect(expired?.unavailable).toBe(true);
+ expect(calls).toBe(3);
+ now += 1;
+ expect((await fetchProviderApiKeyQuotas(config, "openrouter"))[0]?.quota).toBeNull();
+ expect(calls).toBe(3); // Negative caching still works once the old measurement is gone.
+ } finally {
+ Date.now = realDateNow;
+ }
+ });
+
+ test("capability uses all existing key reader destinations without resolving secrets", () => {
+ const targets = [
+ ["coding-alias", "https://api.kimi.com/coding/v1"],
+ ["commandcode", "https://api.commandcode.ai/provider/v1"],
+ ["go-alias", "https://opencode.ai/zen/go/v1"],
+ ["a6-alias", "https://api.a6api.com/v1"],
+ ["openrouter", "https://openrouter.ai/api/v1"],
+ ["deepseek", "https://api.deepseek.com/v1"],
+ ["cline-pass", "https://api.cline.bot"],
+ ["glm-cn", "https://open.bigmodel.cn/api/coding/paas/v4"],
+ ["minimax", "https://api.minimax.io/v1"],
+ ["moonshot", "https://api.moonshot.ai/v1"],
+ ["venice", "https://api.venice.ai/api/v1"],
+ ["synthetic", "https://api.synthetic.new/v2"],
+ ["deepinfra", "https://api.deepinfra.com/v1/openai"],
+ ["neuralwatt", "https://api.neuralwatt.com/v1"],
+ ] as const;
+ for (const [name, baseUrl] of targets) {
+ expect(providerApiKeyQuotaMode(name, { baseUrl, adapter: "openai-chat", authMode: "key" })).toBe("probe");
+ expect(providerApiKeyQuotaMode(name, { baseUrl, adapter: "openai-chat", authMode: "key", disabled: true })).toBe("unsupported");
+ }
+ });
+
+ test("env-reference replacement cannot reuse a quota or fall back to the active key", async () => {
+ const envName = "OCX_QUOTA_KEY_FIXTURE";
+ const previous = process.env[envName];
+ const config = quotaKeyConfig(2);
+ config.providers.openrouter!.apiKeyPool![1]!.key = `$${envName}`;
+ const seen: string[] = [];
+ globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
+ seen.push(new Headers(init?.headers).get("authorization") ?? "");
+ return keyQuotaResponse(44);
+ }) as typeof globalThis.fetch;
+ try {
+ process.env[envName] = "env-first-fixture";
+ const first = await fetchProviderApiKeyQuotas(config, "openrouter");
+ process.env[envName] = "env-second-fixture";
+ expect(first[1]!.isCurrent()).toBe(false);
+ await fetchProviderApiKeyQuotas(config, "openrouter");
+ expect(seen).toEqual(["Bearer fixture-key-0", "Bearer env-first-fixture", "Bearer env-second-fixture"]);
+ delete process.env[envName];
+ const missing = await fetchProviderApiKeyQuotas(config, "openrouter");
+ expect(missing[1]?.quota).toBeNull();
+ expect(missing[1]?.unavailable).toBe(true);
+ expect(seen).toHaveLength(3);
+ } finally {
+ if (previous === undefined) delete process.env[envName];
+ else process.env[envName] = previous;
+ }
+ });
+
+ test("key quota cache is bounded and a different provider cannot reuse the same slot", async () => {
+ const config = quotaKeyConfig(257);
+ let calls = 0;
+ globalThis.fetch = (async () => { calls++; return keyQuotaResponse(11); }) as typeof globalThis.fetch;
+ await fetchProviderApiKeyQuotas(config, "openrouter");
+ expect(calls).toBe(257);
+ config.providers.openrouter!.apiKeyPool = [config.providers.openrouter!.apiKeyPool![0]!];
+ await fetchProviderApiKeyQuotas(config, "openrouter");
+ expect(calls).toBe(258);
+ config.providers.synthetic = { ...config.providers.openrouter!, baseUrl: "https://api.synthetic.new/v2" };
+ await fetchProviderApiKeyQuotas(config, "synthetic");
+ expect(calls).toBe(259);
+ });
+
+ test("isolates inactive keys and leaves provider reports, config and active key unchanged", async () => {
+ const config = quotaKeyConfig();
+ const before = JSON.stringify(config);
+ globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
+ const auth = new Headers(init?.headers).get("authorization");
+ return keyQuotaResponse(auth === "Bearer fixture-key-0" ? 10 : 75);
+ }) as typeof globalThis.fetch;
+ const current = await fetchProviderQuotaReports(config, true);
+ const rows = await fetchProviderApiKeyQuotas(config, "openrouter");
+ expect(rows.map(row => row.quota?.customWindows?.[0]?.percent)).toEqual([10, 75]);
+ expect(rows.every(row => row.isCurrent())).toBe(true);
+ expect(Object.keys(rows[0]!)).toEqual(["keyId", "quota"]);
+ expect(JSON.stringify(rows)).not.toContain("fixture-key");
+ expect(JSON.stringify(rows)).not.toContain("isCurrent");
+ expect(JSON.stringify(config)).toBe(before);
+ expect(await fetchProviderQuotaReports(config)).toEqual(current);
+ });
+
+ test("same-id replacement and clear invalidate nonenumerable current guards", async () => {
+ const config = quotaKeyConfig(1);
+ globalThis.fetch = (async () => keyQuotaResponse(25)) as typeof globalThis.fetch;
+ const [first] = await fetchProviderApiKeyQuotas(config, "openrouter");
+ expect(first!.isCurrent()).toBe(true);
+ config.providers.openrouter!.apiKeyPool![0]!.key = "replacement-fixture";
+ expect(first!.isCurrent()).toBe(false);
+ const [replacement] = await fetchProviderApiKeyQuotas(config, "openrouter");
+ expect(replacement!.isCurrent()).toBe(true);
+ clearProviderQuotaCache();
+ expect(replacement!.isCurrent()).toBe(false);
+ });
+
+ test("force bypasses settled success/failure TTL but joins same-identity in-flight work", async () => {
+ const config = quotaKeyConfig(1);
+ let release!: () => void;
+ const gate = new Promise(resolve => { release = resolve; });
+ let started!: () => void;
+ const entered = new Promise(resolve => { started = resolve; });
+ let calls = 0;
+ let status = 200;
+ globalThis.fetch = (async () => {
+ calls += 1;
+ started();
+ await gate;
+ return status === 200 ? keyQuotaResponse(10) : new Response("{}", { status });
+ }) as typeof globalThis.fetch;
+ const first = fetchProviderApiKeyQuotas(config, "openrouter");
+ await entered;
+ const forced = fetchProviderApiKeyQuotas(config, "openrouter", true);
+ release();
+ await Promise.all([first, forced]);
+ expect(calls).toBe(1);
+ await fetchProviderApiKeyQuotas(config, "openrouter");
+ expect(calls).toBe(1);
+ status = 429;
+ const [failed] = await fetchProviderApiKeyQuotas(config, "openrouter", true);
+ expect(failed?.unavailable).toBe(true);
+ expect(failed?.quota?.customWindows?.[0]?.percent).toBe(10);
+ await fetchProviderApiKeyQuotas(config, "openrouter");
+ expect(calls).toBe(2);
+ status = 401;
+ expect((await fetchProviderApiKeyQuotas(config, "openrouter", true))[0]?.quota).toBeNull();
+ status = 200;
+ expect((await fetchProviderApiKeyQuotas(config, "openrouter", true))[0]?.unavailable).toBeUndefined();
+ expect(calls).toBe(4);
+ });
+
+ test("four workers bound a key roster and late removed rows cannot publish", async () => {
+ const config = quotaKeyConfig(7);
+ let release!: () => void;
+ const gate = new Promise(resolve => { release = resolve; });
+ let fourStarted!: () => void;
+ const entered = new Promise(resolve => { fourStarted = resolve; });
+ let active = 0;
+ let peak = 0;
+ let calls = 0;
+ globalThis.fetch = (async () => {
+ active += 1; calls += 1; peak = Math.max(peak, active);
+ if (calls === 4) fourStarted();
+ await gate;
+ active -= 1;
+ return keyQuotaResponse(33);
+ }) as typeof globalThis.fetch;
+ const pending = fetchProviderApiKeyQuotas(config, "openrouter");
+ await entered;
+ config.providers.openrouter!.apiKeyPool = config.providers.openrouter!.apiKeyPool!.filter(row => row.id !== "slot-0");
+ release();
+ const rows = await pending;
+ expect(peak).toBe(4);
+ expect(rows).toHaveLength(7);
+ expect(rows[0]!.isCurrent()).toBe(false);
+ expect(rows[0]!.quota).toBeNull();
+ });
+
+ test("unsupported modes/destinations never fetch and capability does not need a key", async () => {
+ const config = quotaKeyConfig();
+ const provider = config.providers.openrouter!;
+ delete provider.apiKey;
+ expect(providerApiKeyQuotaMode("openrouter", provider)).toBe("probe");
+ globalThis.fetch = (async () => { throw new Error("unexpected upstream"); }) as typeof globalThis.fetch;
+ for (const authMode of ["oauth", "forward", "local"] as const) {
+ provider.authMode = authMode;
+ expect(providerApiKeyQuotaMode("openrouter", provider)).toBe("unsupported");
+ expect(await fetchProviderApiKeyQuotas(config, "openrouter")).toEqual([]);
+ }
+ provider.authMode = "key";
+ provider.baseUrl = "https://openrouter.example.invalid/api/v1";
+ expect(providerApiKeyQuotaMode("openrouter", provider)).toBe("unsupported");
+ expect(await fetchProviderApiKeyQuotas(config, "openrouter")).toEqual([]);
+ });
+});
diff --git a/tests/server/provider-account-quota-routes.test.ts b/tests/server/provider-account-quota-routes.test.ts
new file mode 100644
index 0000000000..f6bf9a2329
--- /dev/null
+++ b/tests/server/provider-account-quota-routes.test.ts
@@ -0,0 +1,216 @@
+import { afterEach, beforeEach, expect, spyOn, test } from "bun:test";
+import { mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { handleOauthAccountRoutes } from "../../src/server/management/oauth-account-routes";
+import { clearAccountQuotaCache, clearProviderQuotaCache } from "../../src/providers/quota";
+import * as quotaApi from "../../src/providers/quota";
+import { getAccountSet, saveCredential } from "../../src/oauth/store";
+import type { OcxConfig } from "../../src/types";
+import { removeTreeWithRetry } from "../helpers/remove-tree";
+
+const originalFetch = globalThis.fetch;
+const originalHome = process.env.OPENCODEX_HOME;
+const originalFixtureKey = process.env.OCX_QUOTA_ROW_FIXTURE;
+let home = "";
+beforeEach(() => {
+ home = mkdtempSync(join(tmpdir(), "ocx-quota-rows-"));
+ process.env.OPENCODEX_HOME = home;
+ clearAccountQuotaCache();
+ clearProviderQuotaCache();
+});
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ clearAccountQuotaCache();
+ clearProviderQuotaCache();
+ if (originalHome === undefined) delete process.env.OPENCODEX_HOME;
+ else process.env.OPENCODEX_HOME = originalHome;
+ if (originalFixtureKey === undefined) delete process.env.OCX_QUOTA_ROW_FIXTURE;
+ else process.env.OCX_QUOTA_ROW_FIXTURE = originalFixtureKey;
+ removeTreeWithRetry(home);
+});
+
+function keyConfig(): OcxConfig {
+ return { port: 0, defaultProvider: "openrouter", providers: { openrouter: {
+ adapter: "openai-chat", authMode: "key", baseUrl: "https://openrouter.ai/api/v1",
+ apiKey: "fixture-first", apiKeyPool: [
+ { id: "first", key: "fixture-first" }, { id: "second", key: "fixture-second" },
+ ],
+ } } };
+}
+
+async function read(path: string, config: OcxConfig) {
+ const req = new Request(`http://localhost${path}`);
+ const response = await handleOauthAccountRoutes({
+ req, url: new URL(req.url), config, version: "test", deps: {},
+ convergeCodexCatalog: async () => ({ status: "failed", reason: "disk" }),
+ syncClaudeAgentDefsBestEffort: async () => {},
+ });
+ if (!response) throw new Error("unhandled fixture route");
+ return response;
+}
+
+test("cheap and refresh-only key lists advertise capability without probing or changing config", async () => {
+ const config = keyConfig();
+ const before = JSON.stringify(config);
+ let probes = 0;
+ globalThis.fetch = (async () => { probes++; throw new Error("unexpected quota request"); }) as typeof fetch;
+ for (const suffix of ["", "&refresh=1"]) {
+ const response = await read(`/api/providers/keys?name=openrouter${suffix}`, config);
+ const body = await response.json();
+ expect(body.keys.map((row: { quotaMode: string }) => row.quotaMode)).toEqual(["probe", "probe"]);
+ expect(body.keys.every((row: { quota?: unknown }) => row.quota === undefined)).toBe(true);
+ }
+ expect(probes).toBe(0);
+ expect(JSON.stringify(config)).toBe(before);
+});
+
+test("enriched key rows retain per-key values without activating or exposing credentials", async () => {
+ const config = keyConfig();
+ const before = JSON.stringify(config);
+ const tokens: string[] = [];
+ globalThis.fetch = (async (input, init) => {
+ expect(String(input)).toBe("https://openrouter.ai/api/v1/key");
+ const token = new Headers(init?.headers).get("authorization")!;
+ tokens.push(token);
+ return Response.json({ data: { limit: 100, limit_remaining: token.endsWith("first") ? 80 : 40 } });
+ }) as typeof fetch;
+ const body = await (await read("/api/providers/keys?name=openrouter"a=1&refresh=1", config)).json();
+ expect(body.activeId).toBe("first");
+ expect(body.keys.map((row: { quota: { customWindows: { percent: number }[] } }) => row.quota.customWindows[0]?.percent)).toEqual([20, 60]);
+ expect(tokens.sort()).toEqual(["Bearer fixture-first", "Bearer fixture-second"]);
+ expect(body.keys.every((row: { quotaMode: string; quotaUnavailable: boolean }) => row.quotaMode === "probe" && row.quotaUnavailable === false)).toBe(true);
+ const wire = JSON.stringify(body);
+ expect(wire).not.toContain("fixture-first");
+ expect(wire).not.toContain("fixture-second");
+ expect(wire).not.toContain("isCurrent");
+ expect(JSON.stringify(config)).toBe(before);
+});
+
+test("a removed key cannot return in an in-flight enrichment response", async () => {
+ const config = keyConfig();
+ globalThis.fetch = (async (_input, init) => {
+ if (new Headers(init?.headers).get("authorization")?.endsWith("second")) {
+ config.providers.openrouter.apiKeyPool = config.providers.openrouter.apiKeyPool!.filter(row => row.id !== "second");
+ }
+ return Response.json({ data: { limit: 100, limit_remaining: 50 } });
+ }) as typeof fetch;
+ const body = await (await read("/api/providers/keys?name=openrouter"a=1", config)).json();
+ expect(body.keys.map((row: { id: string }) => row.id)).toEqual(["first"]);
+});
+
+test("unsupported key destinations do not resolve or probe credentials", async () => {
+ const config = keyConfig();
+ config.providers.openrouter.baseUrl = "https://quota-fixture.invalid/api/v1";
+ let probes = 0;
+ globalThis.fetch = (async () => { probes++; throw new Error("must not probe"); }) as typeof fetch;
+ const body = await (await read("/api/providers/keys?name=openrouter"a=1&refresh=1", config)).json();
+ expect(body.keys.every((row: { quotaMode: string; quota?: unknown }) => row.quotaMode === "unsupported" && row.quota === undefined)).toBe(true);
+ expect(probes).toBe(0);
+});
+
+test("same-id environment credential replacement cannot inherit a settled old quota", async () => {
+ const config = keyConfig();
+ process.env.OCX_QUOTA_ROW_FIXTURE = "fixture-old";
+ config.providers.openrouter.apiKey = "${OCX_QUOTA_ROW_FIXTURE}";
+ config.providers.openrouter.apiKeyPool = [{ id: "same-id", key: "${OCX_QUOTA_ROW_FIXTURE}" }];
+ globalThis.fetch = (async () => {
+ const response = Response.json({ data: { limit: 100, limit_remaining: 80 } });
+ process.env.OCX_QUOTA_ROW_FIXTURE = "fixture-new";
+ return response;
+ }) as typeof fetch;
+ const body = await (await read("/api/providers/keys?name=openrouter"a=1", config)).json();
+ expect(body.keys[0].id).toBe("same-id");
+ expect(body.keys[0].quota).toBeNull();
+ expect(body.keys[0].quotaUnavailable).toBe(true);
+});
+
+test("the final route projection checks private identity even for a non-null completed reading", async () => {
+ const config = keyConfig();
+ let checked = 0;
+ const probe = spyOn(quotaApi, "fetchProviderApiKeyQuotas").mockResolvedValue([{
+ keyId: "first", quota: { weeklyPercent: 77, updatedAt: Date.now() },
+ isCurrent: () => { checked++; return false; },
+ }]);
+ try {
+ const body = await (await read("/api/providers/keys?name=openrouter"a=1", config)).json();
+ expect(checked).toBe(1);
+ expect(body.keys[0].quota).toBeNull();
+ expect(body.keys[0].quotaUnavailable).toBe(true);
+ expect(JSON.stringify(body)).not.toContain("weeklyPercent");
+ expect(JSON.stringify(body)).not.toContain("isCurrent");
+ } finally {
+ probe.mockRestore();
+ }
+});
+
+test("OAuth cheap rows carry probe mode and enriched rows clear stale failure flags", async () => {
+ await saveCredential("anthropic", { access: "fixture-access", refresh: "fixture-refresh", expires: Date.now() + 3600000, accountId: "fixture-upstream" });
+ const config: OcxConfig = { port: 0, defaultProvider: "anthropic", providers: { anthropic: { adapter: "anthropic", authMode: "oauth", baseUrl: "https://api.anthropic.com" } } };
+ const before = getAccountSet("anthropic")?.activeAccountId;
+ let fail = true;
+ let probes = 0;
+ globalThis.fetch = (async () => {
+ probes++;
+ return fail ? new Response(null, { status: 503 }) : Response.json({ five_hour: { utilization: 0 }, seven_day: { utilization: 12 } });
+ }) as typeof fetch;
+ const plain = await (await read("/api/oauth/accounts?provider=anthropic", config)).json();
+ expect(plain.accounts[0].quotaMode).toBe("probe");
+ expect(probes).toBe(0);
+ const failed = await (await read("/api/oauth/accounts?provider=anthropic"a=1&refresh=1", config)).json();
+ expect(failed.accounts[0].quotaUnavailable).toBe(true);
+ fail = false;
+ const ok = await (await read("/api/oauth/accounts?provider=anthropic"a=1&refresh=1", config)).json();
+ expect(ok.accounts[0].quotaUnavailable).toBe(false);
+ expect(ok.accounts[0].quota.fiveHourPercent).toBe(0);
+ expect(getAccountSet("anthropic")?.activeAccountId).toBe(before);
+});
+
+test("OAuth final projection rejects quota from a replaced stored identity", async () => {
+ await saveCredential("anthropic", { access: "fixture-access", refresh: "fixture-refresh", expires: Date.now() + 3600000, accountId: "fixture-upstream" });
+ const accountId = getAccountSet("anthropic")!.activeAccountId;
+ const config: OcxConfig = { port: 0, defaultProvider: "anthropic", providers: { anthropic: { adapter: "anthropic", authMode: "oauth", baseUrl: "https://api.anthropic.com" } } };
+ let checked = 0;
+ const probe = spyOn(quotaApi, "fetchProviderAccountQuotas").mockResolvedValue([{
+ accountId, quota: { weeklyPercent: 77, updatedAt: Date.now() },
+ isCurrent: () => { checked++; return false; },
+ }]);
+ try {
+ const body = await (await read("/api/oauth/accounts?provider=anthropic"a=1", config)).json();
+ expect(checked).toBe(1);
+ expect(body.accounts[0].quota).toBeNull();
+ expect(body.accounts[0].quotaUnavailable).toBe(true);
+ } finally {
+ probe.mockRestore();
+ }
+});
+
+test("OAuth final projection rejects a replaced configured provider object", async () => {
+ await saveCredential("anthropic", { access: "fixture-access", refresh: "fixture-refresh", expires: Date.now() + 3600000, accountId: "fixture-upstream" });
+ const accountId = getAccountSet("anthropic")!.activeAccountId;
+ const config: OcxConfig = { port: 0, defaultProvider: "anthropic", providers: { anthropic: { adapter: "anthropic", authMode: "oauth", baseUrl: "https://api.anthropic.com" } } };
+ const probe = spyOn(quotaApi, "fetchProviderAccountQuotas").mockImplementation(async () => {
+ config.providers.anthropic = { ...config.providers.anthropic, disabled: true };
+ return [{ accountId, quota: { weeklyPercent: 77, updatedAt: Date.now() }, isCurrent: () => true }];
+ });
+ try {
+ const body = await (await read("/api/oauth/accounts?provider=anthropic"a=1", config)).json();
+ expect(body.accounts[0].quota).toBeNull();
+ expect(body.accounts[0].quotaUnavailable).toBe(true);
+ } finally {
+ probe.mockRestore();
+ }
+});
+
+test("passive account refresh reports unobserved without sending a quota request", async () => {
+ await saveCredential("meta-muse", { access: "fixture-passive", refresh: "", expires: Date.now() + 3600000, accountId: "fixture-passive-account" });
+ const config: OcxConfig = { port: 0, defaultProvider: "meta-muse", providers: { "meta-muse": { adapter: "openai-chat", authMode: "oauth", baseUrl: "https://example.invalid" } } };
+ let requests = 0;
+ globalThis.fetch = (async () => { requests++; throw new Error("passive readers must not probe"); }) as typeof fetch;
+ const body = await (await read("/api/oauth/accounts?provider=meta-muse"a=1&refresh=1", config)).json();
+ expect(body.accounts).toHaveLength(1);
+ expect(body.accounts[0].quotaMode).toBe("passive");
+ expect(body.accounts[0].quota).toBeUndefined();
+ expect(body.accounts[0].quotaUnavailable).toBeUndefined();
+ expect(requests).toBe(0);
+});