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 c316a91814..ae7e9abd88 100644 --- a/devlog/_plan/260905_provider_usage_quota_parity/000_plan.md +++ b/devlog/_plan/260905_provider_usage_quota_parity/000_plan.md @@ -59,6 +59,12 @@ jobs are still mandatory at landing, not claimed green. Next quota-api P reverif 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; @@ -69,5 +75,5 @@ CI result checks. This supersedes the earlier local-static/build verification al - 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/014_recovery_fixture_isolation.md b/devlog/_plan/260905_provider_usage_quota_parity/014_recovery_fixture_isolation.md new file mode 100644 index 0000000000..4d2c96ff2e --- /dev/null +++ b/devlog/_plan/260905_provider_usage_quota_parity/014_recovery_fixture_isolation.md @@ -0,0 +1,23 @@ +# Recovery fixture home isolation + +The recovery fixture changed HOME and OPENCODEX_HOME but inherited the test runner's +explicit CODEX_HOME. The production home resolver prioritizes that explicit value, so +the detached child could share the parent test's Codex namespace. This is a confirmed +fixture-isolation defect, not proof of the earlier intermittent startup failure's cause. + +`tests/update/update-stop-first.test.ts` now reuses `createIsolatedTestEnvironment` for +the case root and child environment. A negative-inheritance test checks its private Codex +directory, preserved real-home guard, unchanged parent input and absent service state. +The actual child environment is also checked with the production Codex home resolver. + +Runtime selection, runtime overrides, bundled dependency, all timeouts, diagnostics and +reap-before-removal ordering are unchanged. No production code or port-probe code changes. +The port-probe investigation is separately owned by the coordinating work. + +Independent plan and implementation review: Kant PASS, read-only. Initial preparation +commit `b37841448816107c856171277dff0464032d282e` was local-only and left the three +existing PR heads unchanged. The user later ended cross-task coordination. This follow-up +now forms the fourth layer of the independently integrated stack described in040, and +requires its own new exact-head remote CI before merge. No local test, typecheck, build, +lint or scan was run. Integration does not change the reviewed test patch or prove the +cause of any historical startup failure. 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 92c1d786cc..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. 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/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index f58b717660..7a38460fc4 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -353,6 +353,15 @@ retain their original timestamp and never trigger inference or token renewal. Un 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/update/update-stop-first.test.ts b/tests/update/update-stop-first.test.ts index cb02c25a6e..f52c53a008 100644 --- a/tests/update/update-stop-first.test.ts +++ b/tests/update/update-stop-first.test.ts @@ -4,7 +4,9 @@ import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { runNpmCachePreflight } from "../../src/update/npm-cache-preflight.mjs"; +import { resolveCodexHomeDir } from "../../src/codex/home"; import { isProcessAlive, killProxy } from "../../src/lib/process-control"; +import { createIsolatedTestEnvironment } from "../../scripts/test"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoRoot as resolveRepoRoot } from "../helpers/repo-root"; @@ -493,6 +495,21 @@ describe("update stops the running proxy before replacing files", () => { expect(isProcessAlive(auditedRecoveryPid)).toBe(false); }); + test("recovery sandbox replaces an inherited Codex home without claiming a managed service", () => { + const parentEnv = { CODEX_HOME: "/synthetic-parent-codex", OCX_REAL_HOME: "/synthetic-real-home", FIXTURE: "unchanged" }; + const isolated = createIsolatedTestEnvironment(parentEnv); + try { + expect(resolveCodexHomeDir({ env: isolated.env })).toBe(join(isolated.root, ".codex")); + expect(existsSync(join(isolated.root, ".codex"))).toBe(true); + expect(isolated.env.OCX_REAL_HOME).toBe("/synthetic-real-home"); + expect(isolated.env.FIXTURE).toBe("unchanged"); + expect(existsSync(join(isolated.root, ".opencodex", "service-state.json"))).toBe(false); + expect(parentEnv).toEqual({ CODEX_HOME: "/synthetic-parent-codex", OCX_REAL_HOME: "/synthetic-real-home", FIXTURE: "unchanged" }); + } finally { + isolated.cleanup(); + } + }); + test("a failed cache pre-flight aborts before the stop callback can run", () => { let stopped = false; const malformedSpawn = (() => ({ status: 0, signal: null, stdout: "not-json", stderr: "" })) as never; @@ -592,26 +609,26 @@ describe("update stops the running proxy before replacing files", () => { test.skipIf(process.platform === "win32")( "npm launcher restarts the stopped runtime after a staged update failure", async () => { - const root = mkdtempSync(join(tmpdir(), "ocx-update-recovery-")); + const isolated = createIsolatedTestEnvironment(); + const root = isolated.root; const packageRoot = join(root, "node_modules", "@bitkyc08", "opencodex"); const launcher = join(packageRoot, "bin", "ocx.mjs"); - const opencodexHome = join(root, "opencodex-home"); + const opencodexHome = isolated.env.OPENCODEX_HOME!; const fakeBin = join(root, "fake-bin"); const fakeNpm = join(fakeBin, "npm"); const cache = join(root, "npm-cache"); const diagnostics = join(root, "recovery-diagnostics"); const bundledBun = join(repoRoot, "node_modules", "bun"); const env = { - ...process.env, - HOME: root, - USERPROFILE: root, - OPENCODEX_HOME: opencodexHome, + ...isolated.env, OCX_FAKE_NPM_CACHE: cache, - PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + PATH: `${fakeBin}:${isolated.env.PATH ?? ""}`, }; let recoveredPid: number | undefined; try { + // Bind the actual child environment, not merely HOME, to this case. + expect(resolveCodexHomeDir({ env })).toBe(join(root, ".codex")); const port = await freePort(); expect(existsSync(bundledBun)).toBe(true); mkdirSync(dirname(launcher), { recursive: true });