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 && ( +
+
+ +
))} 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")} + } + +
} +
+ + {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} - - )} - -
- )} -
- {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); +});