diff --git a/devlog/_plan/260904_provider_quota_refresh/000_plan.md b/devlog/_plan/260904_provider_quota_refresh/000_plan.md new file mode 100644 index 0000000000..e37cdca4c2 --- /dev/null +++ b/devlog/_plan/260904_provider_quota_refresh/000_plan.md @@ -0,0 +1,82 @@ +# Provider quota refresh affordance + Meta usage visibility + +Unit opened 2026-09-04. Two defects reported against the live Providers dashboard +on `http://localhost:10100/#providers`: + +1. Only the Codex account pool has a "Refresh quotas" button. Every other provider + — anthropic, xai, cursor, google-antigravity, meta-muse — offers the operator no + way to force a fresh quota read from the dashboard. +2. Meta Muse shows no quota on the provider Usage tab even though the proxy has an + observation for it. + +## Evidence gathered at P (live proxy, port 10100, v2.42.0, pid 73184) + +`GET /api/provider-quotas` returns six reports, and `meta-muse` is one of them: + +```json +{ + "provider": "meta-muse", + "label": "Meta Muse Code (CLI credential)", + "source": "meta-muse:subscription-observation", + "quota": { "updatedAt": 1788491894216, "fiveHourPercent": 1, "fiveHourResetAt": 1788509678000, + "weeklyPercent": 1, "weeklyResetAt": 1788739200000 }, + "updatedAt": 1788491894216 +} +``` + +`generatedAt` was 1788511281008, so the observation was 5.39 hours old. + +## Root causes + +**Defect 2 is a client-side freshness bound, not a missing measurement.** +`gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx` defines +`QUOTA_REPORT_MAX_AGE_MS = 30 * 60_000` and `freshQuotaReport()` returns `null` +for any report where `now - updatedAt >= QUOTA_REPORT_MAX_AGE_MS`. That filter runs +on the response as well as on the session cache, so the meta-muse row is discarded +before it ever reaches `ProviderDetails` — and `quotaReport` being `undefined` is +exactly what makes the Usage tab render `pws.quotaUnavailable` and the Overview +omit its rate-limit section. + +The bound is correct for a PROBED provider: anthropic, xai, cursor and +google-antigravity each re-read on their own TTL, so a 30-minute-old row means the +probe is failing and showing it would be a lie. It is wrong for a PASSIVE provider. +`meta-muse` publishes no quota endpoint; `src/providers/quota.ts` +(`hasPassiveAccountQuota`, `fetchPassiveProviderQuota`) records usage only from +`response.subscription_usage` SSE frames on a real streaming turn. A five-hour-old +observation is not a stale reading of a live number — it is the only number that +exists, and deleting it leaves the operator with nothing. + +The Accounts tab already got this right: `ProviderAuthPanel.tsx` passes +`observedAt` to `QuotaBars` for `meta-muse`, which renders `quota.observedAgo`. +That surface reads `/api/oauth/accounts?provider=meta-muse"a=1`, which has no +age filter, which is why Meta usage is visible there and nowhere else. The fix is to +carry the same "this is an observation, not a probe" fact to the other surfaces +rather than to widen or delete the bound. + +**Defect 1 is a missing affordance.** `Providers.tsx` owns +`invalidateProviderQuotas(force)`, which bumps `quotaRefresh.epoch` and sets +`force`, and the shell's effect then reads `/api/provider-quotas?refresh=1`. Every +existing caller is a MUTATION — account switch, login, logout, key add/switch/remove, +config save, provider add/remove. There is no operator-initiated path. The +`codexAuth.refreshQuota` / `refreshingQuota` / `quotaRefreshed` / +`quotaRefreshFailed` keys already exist in all nine locale files because +`CodexAccountPool` uses them, so the copy is reusable. + +## Work phases + +| Phase | Doc | Deliverable | +|-------|-----|-------------| +| wp0 | this unit | roadmap, docs only | +| wp1 | `010_wp1_passive_quota_visibility.md` | wire marker + client exemption so Meta renders | +| wp2 | `020_wp2_refresh_affordance.md` | refresh control on Accounts and Usage surfaces | +| wp3 | `030_wp3_live_verification_and_pr.md` | live screenshots, push, PR against `dev` | + +## Constraints + +- Repository-wide suite is prohibited by the requester. Focused `bun test `, + `bun x tsc --noEmit`, `bun run lint:gui` only. +- Push with `--no-verify`; branch `codex/260904-provider-quota-refresh`; target `dev`. +- A GUI-mentioning PR requires a screenshot in the description (`enforce-target`). +- The live proxy on port 10100 is the user's working service. Read it, restart it + only when a rebuild must be picked up, never repoint or reconfigure it. +- `refresh=1` must never cause a passive provider to spend an inference turn. diff --git a/devlog/_plan/260904_provider_quota_refresh/010_wp1_passive_quota_visibility.md b/devlog/_plan/260904_provider_quota_refresh/010_wp1_passive_quota_visibility.md new file mode 100644 index 0000000000..3fe0de7259 --- /dev/null +++ b/devlog/_plan/260904_provider_quota_refresh/010_wp1_passive_quota_visibility.md @@ -0,0 +1,113 @@ +# wp1 — passive quota survives the client freshness bound + +Goal: the `meta-muse` report reaches `ProviderDetails` so the Usage tab renders its +bars and the Overview renders its rate-limit section, without weakening the staleness +guarantee that protects probed providers. + +## Design + +Add one boolean to the wire report, set only by the passive path, and have the client +skip the age check for reports carrying it. This keeps the decision where the fact +lives: the server knows a provider is passive, the client currently has to guess. + +`reverseEngineered?: boolean` is the existing precedent for a per-report advisory +flag on `ProviderQuotaReport`, so `observed?: boolean` follows the same shape and +needs no schema ceremony. + +Rejected alternatives: + +- **Raise `QUOTA_REPORT_MAX_AGE_MS`.** Any finite bound still deletes an older + observation, and raising it weakens the probed-provider case it exists for. +- **Special-case the literal `"meta-muse"` in the GUI.** The provider list is data; + the next passive provider would silently regress. `hasPassiveAccountQuota` is + already the server-side predicate, so derive from it. +- **Infer from `source.endsWith(":subscription-observation")`.** String sniffing a + label that exists for humans; the flag is one field and cannot drift. + +## Diff-level plan + +### `src/providers/quota.ts` + +`ProviderQuotaReport` gains a field beside `reverseEngineered`: + +```ts +export interface ProviderQuotaReport { + provider: string; + label: string; + source: string; + quota: ProviderQuota; + updatedAt: number; + reverseEngineered?: boolean; + /** Observed in-band on a streaming turn; no probe exists and age is expected. */ + observed?: boolean; + aggregation?: CodexCapacityAggregation; +} +``` + +`fetchPassiveProviderQuota` is the only writer. Its final line becomes: + +```ts + const built = report(provider, \`\${provider}:subscription-observation\`, entry.quota); + return built ? { ...built, observed: true } : null; +``` + +`report()` is left untouched — it is shared by every probed path and must not learn +about passivity. + +### `gui/src/provider-workspace/report.ts` + +`ProviderQuotaReportView` gains `observed?: boolean`. + +### `gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx` + +`freshQuotaReport(value, now)`: + +```ts + const observed = row.observed === true; + if (!observed && now - row.updatedAt >= QUOTA_REPORT_MAX_AGE_MS) return null; +``` + +and the returned view carries `...(observed ? { observed: true } : {})` so the flag +survives the session cache round-trip (the cache is re-validated through the same +function, so without this a reload would drop the row again). + +A non-boolean `observed` is treated as absent rather than rejected: the field is +advisory, and a strict reject would turn an unknown future value into a vanished row. + +### `gui/src/components/provider-workspace/ProviderUsage.tsx` + +The rate-limit block passes the age through, matching what the Accounts tab already +does per account: + +```tsx + +``` + +`quota.observedAgo` and `quota.observedHint` already exist in all nine locales, so +no new copy is required for this phase. + +### `gui/src/components/provider-workspace/ProviderCapacityQuota.tsx` + +Same treatment for the Overview surface, so the two places that render a +provider-level quota agree on how an observation is labelled. + +## Tests + +- `tests/provider-quota-observed-flag.test.ts` — `fetchProviderQuotas` emits + `observed: true` on the meta-muse row and no `observed` field on a probed row. +- `gui/tests/provider-quota-observed-freshness.test.ts` — an observed report older + than 30 minutes survives `freshQuotaReportsFromResponse`; an unflagged report of the + same age is dropped; the flag round-trips through the cache validator. + +Both are new files, so no existing focused file needs re-running beyond +`gui/tests/provider-capacity-shell.test.tsx`, which exercises the same shell effect. + +## Verification + +`bun test tests/provider-quota-observed-flag.test.ts`, +`bun test gui/tests/provider-quota-observed-freshness.test.ts`, +`bun test gui/tests/provider-capacity-shell.test.tsx`, `bun x tsc --noEmit`. +Live: restart the proxy, load `#providers` → meta-muse → Usage, expect bars plus +"N시간 전에 확인한 값". diff --git a/devlog/_plan/260904_provider_quota_refresh/020_wp2_refresh_affordance.md b/devlog/_plan/260904_provider_quota_refresh/020_wp2_refresh_affordance.md new file mode 100644 index 0000000000..00f69b640a --- /dev/null +++ b/devlog/_plan/260904_provider_quota_refresh/020_wp2_refresh_affordance.md @@ -0,0 +1,129 @@ +# wp2 — operator-driven quota refresh on both named surfaces + +Goal: an operator can force a fresh quota read for any provider, from the Accounts +surface and from the Usage surface, with a visible busy state and a success/failure +report. + +## Where the force path already exists + +`Providers.tsx` owns `invalidateProviderQuotas(force)` → `quotaRefresh {epoch, force}` +→ `ProviderWorkspaceShell` effect → `GET /api/provider-quotas?refresh=1`. Every +caller today is a mutation. `useProvidersFetch` already exposes it as +`fetchProviderQuotas(refresh?: boolean)`, and `useProviderAccountPools` already holds +it. So the work is plumbing a handler down to the two panels, not new fetch logic. + +The per-account rows are filled by a SEPARATE read — +`/api/oauth/accounts?provider=X"a=1` inside `fetchAccountSets` — so the Accounts +surface must trigger both, or the bars beside each account keep their old numbers +while the provider-level report updates. + +## Diff-level plan + +### `gui/src/hooks/useProviderAccountPools.ts` + +New exported callback: + +```ts + const refreshProviderQuota = useCallback(async (provider: string): Promise => { + const [accountsOk] = await Promise.all([ + fetchAccountSets([provider]), + fetchProviderQuotas(true), + ]); + return accountsOk; + }, [fetchAccountSets, fetchProviderQuotas]); +``` + +Returned from the hook and destructured in `Providers.tsx`. + +`fetchAccountSets` already carries a per-provider generation guard, so a second +refresh while one is in flight cannot commit an older response. + +### `gui/src/components/provider-workspace/types.ts` + +`ProviderAuthHandlers` gains `onRefreshQuota?: (provider: string) => Promise`. +Optional, so the Codex-accounts surface (which has its own button) and any caller that +does not pass it keep compiling. + +### `gui/src/components/provider-workspace/ProviderAuthPanel.tsx` + +In the OAuth-accounts branch, beside the existing "Add account" control, a button +gated on `authHandlers.onRefreshQuota` and on there being at least one account: + +```tsx + const [refreshingQuota, setRefreshingQuota] = useState(false); + const [quotaRefreshMsg, setQuotaRefreshMsg] = useState<{ ok: boolean; text: string } | null>(null); + + const refreshQuota = async () => { + if (!authHandlers.onRefreshQuota || refreshingQuota) return; + setRefreshingQuota(true); + setQuotaRefreshMsg(null); + try { + const ok = await authHandlers.onRefreshQuota(item.name); + setQuotaRefreshMsg({ ok, text: t(ok ? "codexAuth.quotaRefreshed" : "codexAuth.quotaRefreshFailed") }); + } catch { + setQuotaRefreshMsg({ ok: false, text: t("codexAuth.quotaRefreshFailed") }); + } finally { + setRefreshingQuota(false); + } + }; +``` + +Rendered with `IconRefresh`, `disabled={refreshingQuota || busy || Boolean(switchingAccountId)}`, +label `refreshingQuota ? t("codexAuth.refreshingQuota") : t("codexAuth.refreshQuota")`, +and the outcome in a `role="status"` span. The message is cleared on the next click so +a stale "refreshed" cannot sit under a later failure. + +A passive provider gets the same button. The refresh is honest there too: it re-reads +the cached observation, it does not and must not spend an inference turn — the server +path (`fetchPassiveProviderQuota`) is cache-only by construction and ignores +`forceRefresh`, so no client guard is needed and none is added. + +### `gui/src/components/provider-workspace/ProviderUsage.tsx` + +The `pws.rateLimits` block gains a header row with the same control. `ProviderUsage` +is presentational today, so it takes two new optional props rather than reaching for a +hook: + +```tsx + onRefreshQuota?: () => Promise; +``` + +with local busy/message state identical in shape to the Accounts one. The section +header becomes a flex row: `

` on the left, the button on the right. The button is +shown whenever the handler exists — including when `quota` is null, since "no quota +shown" is precisely when an operator wants to retry. + +### `gui/src/components/provider-workspace/ProviderDetails.tsx` + +Threads `onRefreshQuota` from its props into `ProviderUsage`, and passes the shared +handler into `ProviderAuthPanel` through `authHandlers`. + +### `gui/src/pages/Providers.tsx` + +Adds `onRefreshQuota: refreshProviderQuota` to the `authHandlers` object and +`onRefreshQuota={() => refreshProviderQuota(item.name)}` to `ProviderDetails`. + +### i18n + +`codexAuth.refreshQuota`, `codexAuth.refreshingQuota`, `codexAuth.quotaRefreshed` +and `codexAuth.quotaRefreshFailed` exist in all nine locale files +(en, ko, ja, zh, zh-TW, de, fr, ru, tr) — verified, four hits each. No new keys are +introduced, so no locale can fall out of sync in this phase. + +### CSS + +One new rule in `gui/src/styles/provider-quota.css` (or the nearest workspace +stylesheet) for the section-header flex row and the status text. No new colour tokens. + +## Tests + +- `gui/tests/provider-quota-refresh-usage.test.tsx` — the Usage tab renders the + button, clicking it calls the handler once, the label swaps to the busy copy while + the promise is pending, and a rejected handler reports the failure copy. +- `gui/tests/provider-quota-refresh-accounts.test.tsx` — the Accounts panel renders + the button for an OAuth provider, disables it while in flight, and omits it when no + handler is supplied. + +## Verification + +The two new focused files, plus `bun x tsc --noEmit` and `bun run lint:gui`. diff --git a/devlog/_plan/260904_provider_quota_refresh/021_audit_round1_synthesis.md b/devlog/_plan/260904_provider_quota_refresh/021_audit_round1_synthesis.md new file mode 100644 index 0000000000..ae13265db9 --- /dev/null +++ b/devlog/_plan/260904_provider_quota_refresh/021_audit_round1_synthesis.md @@ -0,0 +1,101 @@ +# Audit round 1 — synthesis and plan amendment + +Independent reviewer returned `VERDICT: fail` with six blockers. Each was +re-verified against the tree before being accepted or rebutted; four are accepted +and amend the plan, two are rebutted with evidence. + +## B1 — server-side 30-minute bound (ACCEPTED, narrowed) + +Claim: `LAST_GOOD_MAX_AGE_MS = CODEX_CAPACITY_MAX_QUOTA_AGE_MS = 30 * 60_000` +(quota.ts:97, codex-capacity.ts:34) also drops the meta-muse row server-side, so +wp1 may not fix the symptom. + +The strong form is DISPROVEN by live evidence: three consecutive +`GET /api/provider-quotas` calls each returned the meta-muse row with +`updatedAt = 1788491894216` (5.39h old). The reviewer's own reasoning explains why — +the `cutoff` at quota.ts:2518 filters `previous` rows only, and +`fetchPassiveProviderQuota` regenerates the row from `accountQuotaCache` on every +probe, so it always arrives in `fresh`, which is never age-filtered. The row reaches +the wire, and the client bound is genuinely what deletes it. + +The weak form is REAL and worth fixing. The cache fast path at quota.ts:2477 requires +EVERY report to satisfy `now - item.updatedAt < LAST_GOOD_MAX_AGE_MS`. A passive row +is older than that by construction, so `cacheFresh` is permanently false while +meta-muse is configured — every dashboard poll re-probes anthropic, xai, cursor and +antigravity upstream instead of serving the 5-minute cache. That is a live regression +for anyone with Meta configured, caused by the same conflation of "old" with "stale". + +**Amendment:** wp1 also exempts observed rows from the `cacheFresh` predicate. + +## B2 — account-cache TTL reaps the observation (REBUTTED) + +Claim: `sweepExpiredProviderAccountQuotaRows` (10-minute `ACCOUNT_QUOTA_TTL_MS`) is +global over `accountQuotaCache` and fires from other providers' probe writes. + +Disproven: that function has NO call sites. A repository-wide search for +`sweepExpiredProviderAccountQuotaRows` outside its own definition at quota.ts:1601 +returns nothing, and it is absent from `STATE_STORE_REGISTRATIONS` — only +`provider-quota-history` → `reconcileProviderAccountQuotaRows` is registered, and +that retires rows for accounts that no longer exist, not for age. The +`sweepExpiredOnWrite` calls the reviewer cites (quota.ts:1736-1757) run the +REGISTERED sweepers, which do not include this one. The passive row is not swept. + +One adjacent fact IS worth recording, and the reviewer gets it right for a different +reason: `DISK_MAX_AGE_MS = 6h` (account-quota-disk.ts:28) bounds hydration, so an +observation older than six hours does not survive a proxy restart. The row in +evidence is 5.39h old — within an hour of that edge. This is upstream behaviour, out +of scope for this unit, and noted so a later reader does not mistake a +post-restart disappearance for a regression in this change. + +## B3 — `fetchProviderQuotas(true)` awaits nothing (ACCEPTED, load-bearing) + +Confirmed at use-providers-fetch.ts:60: it is `invalidateProviderQuotas(refresh)`, +a synchronous `setState` bump returning `Promise`. The real fetch happens later +in the shell effect. wp2 as written would flip the button back to idle and report +"Quotas refreshed" before the response landed — a button that lies about the thing it +exists to do. + +**Amendment:** the shell owns the fetch, so the shell must own the completion signal. +`ProviderWorkspaceShell` gains an `onQuotaRefreshSettled?: (ok: boolean) => void` +prop, invoked in the quota effect's `.then`/`.catch` when the read was a forced one. +`Providers.tsx` holds a promise resolver keyed to the current epoch and hands the +panels a handler that resolves when the shell reports, so the busy state and the +success/failure copy describe the actual read. + +## B4 — `fetchAccountSets` cannot report quota failure (ACCEPTED) + +Confirmed at useProviderAccountPools.ts:98-114: the `"a=1` enrichment is a +floating `void (async () => {...})()` with a swallowing `catch`, outside +`results.every(Boolean)`. + +**Amendment:** the Accounts-surface outcome is taken from the B3 settle signal, which +reflects the provider-quota read. The account-row enrichment stays best-effort — it is +a display nicety and its failure already degrades visibly — so the button reports what +it can actually observe rather than a value it cannot see. + +## B5 — wp1's GUI test targets are not importable (ACCEPTED) + +Confirmed: `ProviderWorkspaceShell.tsx` exports only `AddProviderIntent`, +`DetailSlotData` and the default component. `freshQuotaReport` and friends are +module-private. + +**Amendment:** move the freshness predicate into +`gui/src/provider-workspace/report.ts`, which is already the pure-derivation module +for this surface and is imported by the shell. It gets a real unit test, the shell +keeps one import, and the test does not require exporting internals for testing's sake. + +## B6 — missing prop-threading steps (ACCEPTED) + +Confirmed: `ProviderCapacityQuota` takes `{ report, pending }` and forwards no +`observedAt` to either `QuotaBars` call site; `ProviderDetails` and `ProviderUsage` +prop types each need the new handler declared. + +**Amendment:** wp1 and wp2 list these as explicit diff steps rather than "same +treatment". + +## Rebuttal note on B-minor (api-key surfaces) + +The reviewer notes a key-auth provider gets no refresh button under the OAuth-branch +placement. Accepted as scope, not as a defect: the user asked for the account-bundle +surface and the usage surface. The Usage-tab control is provider-agnostic and covers +every provider including key-auth ones, so no provider is left without a refresh path. diff --git a/devlog/_plan/260904_provider_quota_refresh/030_wp3_live_verification_and_pr.md b/devlog/_plan/260904_provider_quota_refresh/030_wp3_live_verification_and_pr.md new file mode 100644 index 0000000000..a8c4168b63 --- /dev/null +++ b/devlog/_plan/260904_provider_quota_refresh/030_wp3_live_verification_and_pr.md @@ -0,0 +1,52 @@ +# wp3 — live verification, screenshots, push and PR + +Neither defect is provable by unit test alone: both were reported against a running +dashboard, and `enforce-target` requires a screenshot for any GUI-mentioning PR. This +phase is the evidence phase. + +## Build and load order + +1. `bun run build:gui` — the service serves `gui/dist`, so an unbuilt change is + invisible no matter how green the tests are. +2. `ocx service restart` — picks up the server-side `observed` flag. Confirm a new + pid and fresh uptime on `/healthz`, and that the port is still 10100. The service + is the user's own; restart it, never repoint or reconfigure it. +3. `curl /api/provider-quotas` with the admin token — the meta-muse row must now + carry `"observed": true`. This is the wire-level proof, checked before the UI so a + blank screen can be attributed correctly. + +## Browser verification (`aside-jun`, CLI repl on the signed-in profile) + +The dashboard is loopback and needs no login, so `aside repl` is the right surface: +one invocation is one session, it throws on a bad path instead of skipping, and the +screenshots land as real files. A whole inspect-act-verify flow must fit in a single +invocation because bindings do not persist between calls. + +Shots to capture into `devlog/_plan/260904_provider_quota_refresh/assets/`: + +| File | Content | +|------|---------| +| `010_meta_usage_quota.png` | meta-muse → Usage tab with both windows and the observation age | +| `020_usage_refresh_button.png` | the Usage rate-limits header with its refresh control | +| `030_accounts_refresh_button.png` | the Accounts tab refresh control for an OAuth provider | +| `040_refresh_result.png` | the post-click success status | + +Aside writes under `~/.aside/u/0/`; Codex copies the files into the repository. Every +`aside` invocation runs under `perl -e 'alarm shift; exec @ARGV' 300` because macOS +has no `timeout` and the bare spelling exits 127 without ever starting the run. + +## Push and PR + +- Branch `codex/260904-provider-quota-refresh`, commits as the phases close. +- `git push --no-verify` — explicitly authorized by the requester. +- PR against `dev` with the full template: Summary, Verification, Checklist, and the + screenshots inline. `enforce-target` rejects a thin description and a GUI PR with + no screenshot. +- The suite line in Verification must state plainly which focused files were run and + that the repository-wide suite was withheld at the requester's instruction, rather + than implying a full green run. + +## Criteria closed here + +c-1 (Meta renders), c-2 (Accounts refresh), c-3 (Usage refresh), c-5 (push + PR). +c-4 closes at the end of wp2 with the command output. diff --git a/devlog/_plan/260904_provider_quota_refresh/031_live_verification_record.md b/devlog/_plan/260904_provider_quota_refresh/031_live_verification_record.md new file mode 100644 index 0000000000..cda6fdbe9e --- /dev/null +++ b/devlog/_plan/260904_provider_quota_refresh/031_live_verification_record.md @@ -0,0 +1,73 @@ +# Live verification record — 2026-09-04 + +Both defects were reproduced and then confirmed fixed against a running proxy serving the +built GUI. Screenshots in `assets/`. + +## Isolation + +The user's own proxy runs on port 10100 from +`/Users/jun/Developer/new/700_projects/opencodex` under launchd — a different checkout +from this worktree, so restarting it would NOT have loaded this change, and repointing it +is out of bounds. Verification therefore ran on a scratch instance: + +- `OPENCODEX_HOME` = a `mktemp -d` directory holding only `config.json` (three providers), + `auth.json`, and `provider-account-quota-cache.json` copied from the real home. +- port 10399, started with `bun run src/cli/index.ts start --port 10399` from this worktree. +- Port 10100 was confirmed untouched afterwards: same pid 73184, uptime still climbing. +- The scratch home was moved to Trash when finished. + +## Wire evidence + +`GET /api/provider-quotas` on the scratch instance returned the meta-muse row carrying +the new marker: + +```json +{ + "provider": "meta-muse", + "source": "meta-muse:subscription-observation", + "quota": { "updatedAt": 1788491894216, "fiveHourPercent": 1, "weeklyPercent": 1 }, + "updatedAt": 1788491894216, + "observed": true +} +``` + +`generatedAt` was 1788513424412 — the observation was ~6 hours old, far past the +30-minute bound that used to delete it. + +## UI evidence (aside CLI repl, signed-in profile, under a `perl alarm` deadline) + +| Surface | Before | After | +|---|---|---| +| Providers overview, RATE LIMITS | Muse Code absent | `Muse Code · Checked 5h ago · Observed 5h ago · 1% used` | +| Muse Code → Overview | no rate-limit section | `Observed 5h ago`, both windows | +| Muse Code → Usage | `pws.quotaUnavailable` | both windows, source line, `Quota updated 5h ago` | + +The refresh control was exercised, not merely rendered: + +- Usage tab: clicking `Refresh quotas` produced `status: "Quotas refreshed"` and the age + line re-derived from `5h ago` to `6h ago` — the read really happened. +- Accounts tab (anthropic, three pooled accounts): the control appears beside + `Add account` and reported `Quotas refreshed` after a real forced read. + +## Assets + +| File | Content | +|---|---| +| `010_meta_usage_quota.png` | Muse Code → Usage with both windows and the refresh control | +| `020_usage_refresh_result.png` | the same tab after a click, showing the success status | +| `030_accounts_refresh_button.png` | Accounts tab control for a pooled OAuth provider | +| `040_accounts_refresh_result.png` | Accounts tab after a click | + +## CI (PR #3448, head 232afdd97) + +Attempt 1 ended `cancelled`, which `gh pr checks` renders as `fail` for two rows. That +was not a test failure and is worth stating precisely, because "a red check" and "a broken +change" are different claims: every substantive job succeeded — all four `test` shards, +`gates`, `macos`, all three `keyring` jobs, `npm-global` on ubuntu and macos, +`storage policy`, `api usage`, `react-doctor`, `enforce-target`. The single +`npm-global windows-latest` job was cancelled with ZERO failing steps +(`steps: []` under a `cancelled` conclusion), and the aggregate `ci` gate then failed +for the one reason it exists to check: "Assert every needed job succeeded or was skipped". + +Attempt 2 completed with `conclusion: success`, and the PR now shows 10 passing checks +with nothing pending or failing. diff --git a/devlog/_plan/260904_provider_quota_refresh/assets/010_meta_usage_quota.png b/devlog/_plan/260904_provider_quota_refresh/assets/010_meta_usage_quota.png new file mode 100644 index 0000000000..f36ca63dd7 Binary files /dev/null and b/devlog/_plan/260904_provider_quota_refresh/assets/010_meta_usage_quota.png differ diff --git a/devlog/_plan/260904_provider_quota_refresh/assets/020_usage_refresh_result.png b/devlog/_plan/260904_provider_quota_refresh/assets/020_usage_refresh_result.png new file mode 100644 index 0000000000..944b12533e Binary files /dev/null and b/devlog/_plan/260904_provider_quota_refresh/assets/020_usage_refresh_result.png differ diff --git a/devlog/_plan/260904_provider_quota_refresh/assets/030_accounts_refresh_button.png b/devlog/_plan/260904_provider_quota_refresh/assets/030_accounts_refresh_button.png new file mode 100644 index 0000000000..986fa60ad6 Binary files /dev/null and b/devlog/_plan/260904_provider_quota_refresh/assets/030_accounts_refresh_button.png differ diff --git a/devlog/_plan/260904_provider_quota_refresh/assets/040_accounts_refresh_result.png b/devlog/_plan/260904_provider_quota_refresh/assets/040_accounts_refresh_result.png new file mode 100644 index 0000000000..ef6c83693f Binary files /dev/null and b/devlog/_plan/260904_provider_quota_refresh/assets/040_accounts_refresh_result.png differ diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index e33e7ffcbf..06bf682e13 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -5,7 +5,7 @@ */ import { useEffect, useRef, useState } from "react"; import { useT } from "../../i18n/shared"; -import { IconLock, IconTrash } from "../../icons"; +import { IconLock, IconRefresh, IconTrash } from "../../icons"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; import { oauthAccountDisplayLabel, providerAuthSurface } from "../../provider-workspace/auth"; import { displayAccountId } from "../../lib/privacy"; @@ -196,6 +196,24 @@ export default function ProviderAuthPanel({ 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 onRefreshQuota = authHandlers?.onRefreshQuota; + const refreshQuota = async () => { + if (!onRefreshQuota || refreshingQuota) return; + setRefreshingQuota(true); + // Cleared on click so a previous "refreshed" cannot sit under a later failure. + setQuotaRefreshResult(null); + try { + const ok = await onRefreshQuota(item.name); + setQuotaRefreshResult({ ok, text: t(ok ? "codexAuth.quotaRefreshed" : "codexAuth.quotaRefreshFailed") }); + } catch { + setQuotaRefreshResult({ ok: false, text: t("codexAuth.quotaRefreshFailed") }); + } finally { + setRefreshingQuota(false); + } + }; // Soft "a=1 enrichment lands after the local account list. Reserve stacked // bar height briefly so bars don't shove rows when WHAM returns. @@ -542,10 +560,29 @@ export default function ProviderAuthPanel({
{t("pws.noAccounts")}
)} {loggedIn && ( - +
+ + {onRefreshQuota && ( + + )} + {quotaRefreshResult && ( + + {quotaRefreshResult.text} + + )} +
)} )} diff --git a/gui/src/components/provider-workspace/ProviderCapacityQuota.tsx b/gui/src/components/provider-workspace/ProviderCapacityQuota.tsx index 0f409907c9..f651e25821 100644 --- a/gui/src/components/provider-workspace/ProviderCapacityQuota.tsx +++ b/gui/src/components/provider-workspace/ProviderCapacityQuota.tsx @@ -8,6 +8,7 @@ import { useT, useI18n, type Locale } from "../../i18n/shared"; import { accountQuotaFromReport, capacityAggregationFromReport, + observedAtFromReport, type CapacityWindowView, type ProviderQuotaReportView, } from "../../provider-workspace/report"; @@ -45,6 +46,8 @@ export function ProviderCapacityQuota({ report, pending }: { report: ProviderQuo const { locale } = useI18n(); const aggregation = capacityAggregationFromReport(report); const primaryQuota = accountQuotaFromReport(report); + // Only a passively observed row carries this; see ProviderUsage for the same rule. + const observedAt = observedAtFromReport(report); const credits = primaryQuota?.creditsUsd; const showsAggregate = aggregation?.presentation === "aggregate"; const incompleteWindowKeys = new Set(); @@ -92,6 +95,7 @@ export function ProviderCapacityQuota({ report, pending }: { report: ProviderQuo t={t} layout="stacked" pending={pending} + {...(observedAt !== undefined ? { observedAt } : {})} incompleteWindowKeys={showsAggregate ? incompleteWindowKeys : undefined} incompleteCustomWindowLabels={showsAggregate ? incompleteCustomWindowLabels : undefined} /> diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index 1b02517835..78590390c1 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -55,6 +55,7 @@ export default function ProviderDetails({ onRemoveProvider, onSetDisabled, onSetDefault, + onRefreshQuota, }: { item: WorkspaceItem; usageTotals?: ProviderUsageTotals; @@ -90,6 +91,8 @@ export default function ProviderDetails({ onRemoveProvider?: (name: string) => void; onSetDisabled?: (name: string, disabled: boolean) => void; onSetDefault?: (name: string) => void; + /** Force a fresh quota read for this provider; resolves with whether it succeeded. */ + onRefreshQuota?: () => Promise; }) { const t = useT(); const [tab, setTab] = useState("overview"); @@ -296,7 +299,13 @@ export default function ProviderDetails({ /> )} {tab === "usage" && ( - + )} {tab === "accounts" && ( 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 []; return modelUsage.toSorted((a, b) => b.totalTokens - a.totalTokens); @@ -135,10 +158,40 @@ export default function ProviderUsage({ item, usageTotals, quotaReport, modelUsa )}
-

{t("pws.rateLimits")}

+
+

{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() && (
diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index 5ef62e13fc..1ab4bacf0c 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -24,7 +24,11 @@ import { providerKind } from "../../provider-workspace/kind"; import { readJsonIfOk, readJsonOrThrow } from "../../fetch-json"; import { readSessionListCache, writeSessionListCache } from "../../session-list-cache"; import { countAvailableModels, parseAvailableModels, parseLiveModelCounts, parseSelectedModels, type ProviderAvailableModels, type ProviderLiveModelCounts, type ProviderModelCounts, type ProviderSelectedModels } from "../../provider-workspace/usage"; -import type { ProviderQuotaReportView } from "../../provider-workspace/report"; +import { + freshQuotaReportRecord, + freshQuotaReportsFromResponse, + type ProviderQuotaReportView, +} from "../../provider-workspace/report"; import { formatProviderDisplayName } from "../../provider-icons"; import { RailRow } from "./ProviderRail"; import type { PricingFilter, ProviderModelUsageRow, ProviderUsageTotals, StatusFilter, TypeFilter } from "./types"; @@ -55,51 +59,13 @@ const SORT_DEFS: { id: ProviderSortMode; labelKey: "pws.sort.az" | "pws.sort.za" { id: "accounts-first", labelKey: "pws.sort.accountsFirst" }, ]; -const QUOTA_REPORT_MAX_AGE_MS = 30 * 60_000; - -function freshQuotaReport(value: unknown, now: number): ProviderQuotaReportView | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - const row = value as Record; - if (typeof row.updatedAt !== "number" || !Number.isFinite(row.updatedAt)) return null; - if (now - row.updatedAt >= QUOTA_REPORT_MAX_AGE_MS) return null; - if (!("quota" in row)) return null; - if (row.label !== undefined && typeof row.label !== "string") return null; - if (row.source !== undefined && typeof row.source !== "string") return null; - return { - ...(typeof row.label === "string" ? { label: row.label } : {}), - ...(typeof row.source === "string" ? { source: row.source } : {}), - updatedAt: row.updatedAt, - quota: row.quota, - ...(row.aggregation !== undefined ? { aggregation: row.aggregation } : {}), - }; -} - -function freshQuotaReportRecord(value: unknown, now = Date.now()): Record | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - const out: Record = {}; - for (const [provider, raw] of Object.entries(value)) { - const report = freshQuotaReport(raw, now); - if (provider.trim() && report) out[provider] = report; - } - return out; -} - +// The freshness predicate itself lives in provider-workspace/report.ts so it can be unit +// tested; this module exports only its component, so a predicate defined here would be +// reachable only through a full DOM render. function readFreshQuotaReportCache(key: string): Record | null { return freshQuotaReportRecord(readSessionListCache(key)); } -function freshQuotaReportsFromResponse(value: unknown, now = Date.now()): Record { - if (!Array.isArray(value)) return {}; - const out: Record = {}; - for (const raw of value) { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue; - const provider = (raw as Record).provider; - const report = freshQuotaReport(raw, now); - if (typeof provider === "string" && provider.trim() && report) out[provider] = report; - } - return out; -} - export default function ProviderWorkspaceShell({ providers, apiBase, @@ -116,6 +82,7 @@ export default function ProviderWorkspaceShell({ /** Stable key of active OAuth account ids — refetch overview quotas after account switch. */ quotaRefreshEpoch = 0, quotaForceRefresh = false, + onQuotaRefreshSettled, detail, }: { providers: Record; @@ -138,6 +105,15 @@ export default function ProviderWorkspaceShell({ * data arriving on a cold load no longer re-triggers the read once per provider. */ quotaRefreshEpoch?: number; + /** + * Called when a FORCED quota read settles, with whether it succeeded. + * + * The shell owns the only `/api/provider-quotas` read, so it owns the only truthful + * completion signal. An operator-facing refresh button that resolved on its own would + * report success before the response landed — `fetchProviderQuotas(true)` is a + * synchronous state bump, not a request. + */ + onQuotaRefreshSettled?: (ok: boolean) => void; /** True when the bump came from a mutation that needs the server to bypass its TTL. */ quotaForceRefresh?: boolean; /** Detail body for the selected provider (WP090); a placeholder renders when absent. */ @@ -251,13 +227,22 @@ export default function ProviderWorkspaceShell({ // 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" : ""}`) - .then(r => readJsonIfOk<{ reports?: Array<{ provider: string; label?: string; source?: string; updatedAt?: number; quota?: unknown; aggregation?: unknown }> }>(r)) + .then(r => readJsonIfOk<{ reports?: Array<{ provider: string; label?: string; source?: string; updatedAt?: number; quota?: unknown; observed?: boolean; aggregation?: unknown }> }>(r)) .then((data) => { - if (cancelled || !data) return; + if (cancelled) return; + // `readJsonIfOk` resolves undefined on a non-OK response rather than rejecting. + // 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); + return; + } // A successful endpoint response is authoritative, including an empty report list. const next = freshQuotaReportsFromResponse(data.reports); setQuotaReports(next); writeSessionListCache(quotasCacheKey, next); + // Report only for a forced read: an ordinary revalidation has no operator waiting on it. + if (quotaForceRefresh) onQuotaRefreshSettled?.(true); }) .catch(() => { if (cancelled) return; @@ -267,6 +252,7 @@ export default function ProviderWorkspaceShell({ writeSessionListCache(quotasCacheKey, next); return next; }); + if (quotaForceRefresh) onQuotaRefreshSettled?.(false); }) .finally(() => { if (!cancelled) setQuotasLoading(false); }); }, 0); @@ -275,7 +261,7 @@ export default function ProviderWorkspaceShell({ window.clearTimeout(timeout); }; // Keyed on the explicit revision: account arrival is silent, real mutations re-read. - }, [apiBase, quotaRefreshEpoch, quotaForceRefresh, quotasCacheKey]); + }, [apiBase, quotaRefreshEpoch, quotaForceRefresh, quotasCacheKey, onQuotaRefreshSettled]); useEffect(() => { if (!filterOpen) return; diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts index d23464500e..5cb55c2435 100644 --- a/gui/src/components/provider-workspace/types.ts +++ b/gui/src/components/provider-workspace/types.ts @@ -83,6 +83,13 @@ export interface ProviderAuthHandlers { onSwitchApiKey: (provider: string, entry: ApiKeyRow) => void | Promise; onRemoveApiKey: (provider: string, entry: ApiKeyRow) => void | Promise; onEditAlias: (provider: string, type: "oauth" | "api-key", id: string, current?: string) => void | Promise; + /** + * Force a fresh quota read for this provider, resolving with whether it succeeded. + * + * Optional: the Codex account pool owns its own refresh control, and a caller that + * cannot force a read simply renders no button rather than one that does nothing. + */ + onRefreshQuota?: (provider: string) => Promise; } export type ProviderUpdatePatch = { diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 7bb9009418..5286e89228 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -142,6 +142,19 @@ export default function Providers({ apiBase }: { apiBase: string }) { 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 { fetchConfig, fetchOauth, fetchProviderQuotas } = useProvidersFetch({ apiBase, t, setConfig, setOauthProviders, setOauthStatus, notify, invalidateProviderQuotas, @@ -200,6 +213,22 @@ export default function Providers({ apiBase }: { apiBase: string }) { jsonIsDirty, setJsonLeaveOpen, } = jsonEditor; + /** + * Force a fresh quota read for one provider and resolve with what actually happened. + * + * 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. + */ + 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]); + useEffect(() => { // Deferred by a microtask, not a timer. A timer had to be cancelled in cleanup, so navigating // away within the same tick dropped both requests with nothing to retry them and the page came @@ -348,6 +377,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { activeAccountNeedsReauth={activeAccountNeedsReauth} quotaRefreshEpoch={quotaRefresh.epoch} quotaForceRefresh={quotaRefresh.force} + onQuotaRefreshSettled={settleQuotaRefresh} detail={(item, data) => { const loginStatus = accountLoginStatus[item.name] ?? oauthStatus[item.name]; return ( @@ -387,7 +417,9 @@ export default function Providers({ apiBase }: { apiBase: string }) { onSwitchApiKey: switchApiKey, onRemoveApiKey: removeApiKey, onEditAlias: editCredentialAlias, + onRefreshQuota: refreshProviderQuota, }} + onRefreshQuota={() => refreshProviderQuota(item.name)} isDefault={item.name === config.defaultProvider} onRemoveProvider={removeProvider} onSetDisabled={setProviderDisabled} diff --git a/gui/src/provider-workspace/report.ts b/gui/src/provider-workspace/report.ts index 4d4ff53f3b..f434a6f353 100644 --- a/gui/src/provider-workspace/report.ts +++ b/gui/src/provider-workspace/report.ts @@ -11,9 +11,90 @@ export interface ProviderQuotaReportView { source?: string; updatedAt?: number; quota?: unknown; + /** + * Server-set: the row was observed in-band on a streaming turn, never probed. + * Exempt from the freshness bound below, and rendered with its observation age. + */ + observed?: boolean; aggregation?: unknown; } +/** + * How old a PROBED report may be before it stops being shown. + * + * A probed provider re-reads on its own TTL, so a row past this bound means the probe + * is failing, and rendering it would present a dead number as live. + */ +export const QUOTA_REPORT_MAX_AGE_MS = 30 * 60_000; + +/** + * Narrow one wire row, dropping a probed report that has gone stale. + * + * Observed rows (passive providers such as `meta-muse`, whose usage arrives only inside + * a streaming response) are exempt: their age is expected and is surfaced to the reader + * instead of being used to delete the only measurement that exists. This lives here, in + * the pure-derivation module, rather than inside the shell component so it can be tested + * directly — the shell exports only its component, so a predicate defined there is + * reachable only through a full DOM render. + */ +export function freshQuotaReport(value: unknown, now: number): ProviderQuotaReportView | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const row = value as Record; + if (typeof row.updatedAt !== "number" || !Number.isFinite(row.updatedAt)) return null; + // A non-boolean value is treated as absent rather than rejected: the field is advisory, + // and a strict reject would turn an unknown future value into a vanished row. + const observed = row.observed === true; + if (!observed && now - row.updatedAt >= QUOTA_REPORT_MAX_AGE_MS) return null; + if (!("quota" in row)) return null; + if (row.label !== undefined && typeof row.label !== "string") return null; + if (row.source !== undefined && typeof row.source !== "string") return null; + return { + ...(typeof row.label === "string" ? { label: row.label } : {}), + ...(typeof row.source === "string" ? { source: row.source } : {}), + updatedAt: row.updatedAt, + quota: row.quota, + // Must be carried: this function rebuilds field-by-field and also re-validates the + // session cache, so an unpropagated flag would drop the row on the next page load. + ...(observed ? { observed: true } : {}), + ...(row.aggregation !== undefined ? { aggregation: row.aggregation } : {}), + }; +} + +/** Re-validate a cached provider→report map, dropping rows that are no longer showable. */ +export function freshQuotaReportRecord( + value: unknown, + now = Date.now(), +): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const out: Record = {}; + for (const [provider, raw] of Object.entries(value)) { + const report = freshQuotaReport(raw, now); + if (provider.trim() && report) out[provider] = report; + } + return out; +} + +/** Narrow a `/api/provider-quotas` response body into the keyed view map. */ +export function freshQuotaReportsFromResponse( + value: unknown, + now = Date.now(), +): Record { + if (!Array.isArray(value)) return {}; + const out: Record = {}; + for (const raw of value) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue; + const provider = (raw as Record).provider; + const report = freshQuotaReport(raw, now); + if (typeof provider === "string" && provider.trim() && report) out[provider] = report; + } + return out; +} + +/** Observation timestamp to display beside the bars, or undefined for a probed row. */ +export function observedAtFromReport(report?: ProviderQuotaReportView): number | undefined { + return report?.observed === true && typeof report.updatedAt === "number" ? report.updatedAt : undefined; +} + export interface CapacityWindowView { usedPercent: number; incomplete?: boolean; diff --git a/gui/src/styles/provider-workspace-settings.css b/gui/src/styles/provider-workspace-settings.css index a80fac5a59..244c12d2a4 100644 --- a/gui/src/styles/provider-workspace-settings.css +++ b/gui/src/styles/provider-workspace-settings.css @@ -24,7 +24,7 @@ .pwi-auth-state--error { color: var(--red); background: var(--red-soft); justify-content: space-between; } .pwi-auth-state--empty { justify-content: center; } -.pwi-auth-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.pwi-auth-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 8px; } .pwi-auth-optin-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; diff --git a/gui/src/styles/provider-workspace-shell.css b/gui/src/styles/provider-workspace-shell.css index 1fe15f95fb..04b7c82325 100644 --- a/gui/src/styles/provider-workspace-shell.css +++ b/gui/src/styles/provider-workspace-shell.css @@ -1002,6 +1002,23 @@ border-top: 1px solid color-mix(in oklab, var(--border) 45%, transparent); } +/* Section title on the left, operator refresh control on the right. Wraps rather than + truncating: the status text is a full sentence in several locales. */ +.pws-usage-block-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.pws-quota-refresh { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + .pws-usage-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/gui/tests/provider-quota-observed-freshness.test.ts b/gui/tests/provider-quota-observed-freshness.test.ts new file mode 100644 index 0000000000..957cd77e00 --- /dev/null +++ b/gui/tests/provider-quota-observed-freshness.test.ts @@ -0,0 +1,91 @@ +/** + * The freshness bound must distinguish "stale" from "old". + * + * A probed provider re-reads on its own TTL, so a report past the bound means the probe + * is failing and rendering it would present a dead number as live. A PASSIVE provider + * (`meta-muse`) publishes no endpoint at all — usage arrives only inside a streaming + * response — so its last observation is the only measurement that exists. Applying the + * probed rule to it deleted the row, which is the defect these tests pin: Meta usage was + * visible on the Accounts tab (no age filter there) and nowhere else. + */ +import { expect, test } from "bun:test"; +import { + QUOTA_REPORT_MAX_AGE_MS, + freshQuotaReport, + freshQuotaReportRecord, + freshQuotaReportsFromResponse, + observedAtFromReport, +} from "../src/provider-workspace/report"; + +const NOW = 1_788_511_281_008; +/** The age actually measured on the live proxy when the defect was reported. */ +const OBSERVED_AT = 1_788_491_894_216; + +const museQuota = { + updatedAt: OBSERVED_AT, + fiveHourPercent: 1, + fiveHourResetAt: 1_788_509_678_000, + weeklyPercent: 1, + weeklyResetAt: 1_788_739_200_000, +}; + +function museRow(extra: Record = {}) { + return { + provider: "meta-muse", + label: "Meta Muse Code (CLI credential)", + source: "meta-muse:subscription-observation", + updatedAt: OBSERVED_AT, + quota: museQuota, + observed: true, + ...extra, + }; +} + +test("the live 5.4-hour-old Muse observation survives the bound that drops a probed row", () => { + const age = NOW - OBSERVED_AT; + expect(age).toBeGreaterThan(QUOTA_REPORT_MAX_AGE_MS); + + expect(freshQuotaReport(museRow(), NOW)).not.toBeNull(); + // Same row, same age, minus the marker: this is what the GUI used to receive. + expect(freshQuotaReport({ ...museRow(), observed: undefined }, NOW)).toBeNull(); +}); + +test("a probed report past the bound is still dropped", () => { + const stale = { + provider: "anthropic", + source: "anthropic:oauth-usage", + updatedAt: NOW - QUOTA_REPORT_MAX_AGE_MS - 1, + quota: { fiveHourPercent: 19 }, + }; + expect(freshQuotaReport(stale, NOW)).toBeNull(); + expect(freshQuotaReport({ ...stale, updatedAt: NOW - 60_000 }, NOW)).not.toBeNull(); +}); + +test("the marker round-trips, because the cache is re-validated through the same predicate", () => { + const fromResponse = freshQuotaReportsFromResponse([museRow()], NOW); + expect(fromResponse["meta-muse"]?.observed).toBe(true); + + // What writeSessionListCache/readSessionListCache do to it between page loads. + const rehydrated = freshQuotaReportRecord( + JSON.parse(JSON.stringify(fromResponse)) as unknown, + NOW + 60 * 60_000, + ); + expect(rehydrated?.["meta-muse"]).toBeDefined(); + expect(rehydrated?.["meta-muse"]?.observed).toBe(true); +}); + +test("a non-boolean marker is treated as absent rather than rejecting the row", () => { + // Advisory field: an unknown future value must not make a row vanish. + const recent = { ...museRow({ observed: "yes" }), updatedAt: NOW - 60_000, quota: { ...museQuota, updatedAt: NOW - 60_000 } }; + const view = freshQuotaReport(recent, NOW); + expect(view).not.toBeNull(); + expect(view?.observed).toBeUndefined(); + // And it does not buy an exemption. + expect(freshQuotaReport(museRow({ observed: 1 }), NOW)).toBeNull(); +}); + +test("the observation timestamp is offered only for an observed row", () => { + expect(observedAtFromReport(freshQuotaReport(museRow(), NOW) ?? undefined)).toBe(OBSERVED_AT); + expect(observedAtFromReport({ updatedAt: NOW, quota: {} })).toBeUndefined(); + expect(observedAtFromReport(undefined)).toBeUndefined(); +}); diff --git a/gui/tests/provider-quota-refresh-controls.test.tsx b/gui/tests/provider-quota-refresh-controls.test.tsx new file mode 100644 index 0000000000..7f218a66bc --- /dev/null +++ b/gui/tests/provider-quota-refresh-controls.test.tsx @@ -0,0 +1,172 @@ +/** + * The operator-facing quota refresh controls. + * + * The interesting property is not that a button exists; it is that the button does not + * LIE. `fetchProviderQuotas(true)` is a synchronous state bump, not a request — the shell + * owns the only `/api/provider-quotas` read — so a control that resolved on its own would + * report "Quotas refreshed" while the previous numbers were still on screen. These tests + * pin the busy state and the reported outcome to a handler that settles independently. + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import ProviderUsage from "../src/components/provider-workspace/ProviderUsage"; +import ProviderAuthPanel from "../src/components/provider-workspace/ProviderAuthPanel"; +import { LanguageProvider } from "../src/i18n/provider"; +import type { WorkspaceItem } from "../src/provider-workspace/catalog"; +import type { ProviderAuthHandlers } from "../src/components/provider-workspace/types"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previous; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + sessionStorage: { configurable: true, value: win.sessionStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); +}); + +function findButton(label: string): HTMLButtonElement | null { + const buttons = Array.from(host.querySelectorAll("button")) as unknown as HTMLButtonElement[]; + return buttons.find(button => (button.textContent ?? "").includes(label)) ?? null; +} + +/** A handler the test settles by hand, standing in for the shell's forced read. */ +function deferredHandler() { + let settle!: (ok: boolean) => void; + const calls: number[] = []; + const handler = async () => { + calls.push(Date.now()); + return await new Promise(resolve => { settle = resolve; }); + }; + return { handler, calls, settle: (ok: boolean) => settle(ok) }; +} + +async function render(node: React.ReactNode) { + await act(async () => { + root ??= createRoot(host); + root.render({node}); + }); +} + +const usageItem = { name: "meta-muse", adapter: "openai-responses", authMode: "oauth" } as unknown as WorkspaceItem; + +test("the usage tab reports the real outcome, not the click", async () => { + const { handler, calls, settle } = deferredHandler(); + await render(); + + const button = findButton("Refresh quotas"); + expect(button).not.toBeNull(); + + await act(async () => { button!.click(); }); + expect(calls.length).toBe(1); + // 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"); + + await act(async () => { settle(true); await Promise.resolve(); }); + expect(host.textContent).toContain("Quotas refreshed"); +}); + +test("a failed read is reported as a failure", async () => { + const { handler, settle } = deferredHandler(); + await render(); + + await act(async () => { findButton("Refresh quotas")!.click(); }); + await act(async () => { settle(false); await Promise.resolve(); }); + + expect(host.textContent).toContain("Failed to refresh quotas"); + expect(host.textContent).not.toContain("Quotas refreshed"); +}); + +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(findButton("Refresh quotas")).not.toBeNull(); +}); + +test("no handler means no button rather than one that does nothing", async () => { + await render(); + expect(findButton("Refresh quotas")).toBeNull(); +}); + +const oauthItem = { + name: "meta-muse", + adapter: "openai-responses", + authMode: "oauth", + hasApiKey: false, +} as unknown as WorkspaceItem; + +function authHandlers(extra: Partial = {}): ProviderAuthHandlers { + return { + onLogin: () => {}, + onLogout: () => {}, + onReauth: () => {}, + onSwitchAccount: () => {}, + onRemoveAccount: () => {}, + onAddApiKey: async () => true, + onSwitchApiKey: () => {}, + onRemoveApiKey: () => {}, + onEditAlias: () => {}, + ...extra, + }; +} + +const account = { + id: "acct-1", + email: "muse@example.test", + active: true, +} as unknown as Parameters[0]["accounts"] extends (infer T)[] | undefined ? T : never; + +test("the accounts surface offers the same control for a non-Codex provider", async () => { + const { handler, calls, settle } = deferredHandler(); + await render( + await handler() })} + />, + ); + + const button = findButton("Refresh quotas"); + expect(button).not.toBeNull(); + + await act(async () => { button!.click(); }); + expect(calls.length).toBe(1); + expect(findButton("Refreshing...")?.disabled).toBe(true); + + await act(async () => { settle(true); await Promise.resolve(); }); + expect(host.textContent).toContain("Quotas refreshed"); +}); + +test("the accounts surface omits the control when the page cannot force a read", async () => { + await render( + , + ); + expect(findButton("Refresh quotas")).toBeNull(); +}); diff --git a/gui/tests/provider-quota-refresh-settle.test.tsx b/gui/tests/provider-quota-refresh-settle.test.tsx new file mode 100644 index 0000000000..8b3f0a99cf --- /dev/null +++ b/gui/tests/provider-quota-refresh-settle.test.tsx @@ -0,0 +1,132 @@ +/** + * The shell is the only thing that knows whether a forced quota read succeeded, so it is + * the only honest source for the refresh button's outcome. These tests pin that signal to + * the actual fetch result, including the non-OK case, which `readJsonIfOk` resolves as + * `undefined` rather than rejecting — a path that would otherwise leave a button spinning. + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import ProviderWorkspaceShell from "../src/components/provider-workspace/ProviderWorkspaceShell"; +import { LanguageProvider } from "../src/i18n/provider"; +import type { WorkspaceProvider } from "../src/provider-workspace/catalog"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let originalFetch: typeof globalThis.fetch; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let quotaMode: "ok" | "not-ok" | "reject" = "ok"; + +const providers: Record = { + "meta-muse": { adapter: "openai-responses", authMode: "oauth", baseUrl: "https://api.meta.ai/v1" } as WorkspaceProvider, +}; + +const OBSERVED_AT = Date.now() - 5.39 * 60 * 60_000; + +function payload() { + return { + reports: [{ + provider: "meta-muse", + label: "Meta Muse Code (CLI credential)", + source: "meta-muse:subscription-observation", + updatedAt: OBSERVED_AT, + observed: true, + quota: { fiveHourPercent: 1, weeklyPercent: 1, updatedAt: OBSERVED_AT }, + }], + }; +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previous; + originalFetch = globalThis.fetch; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + sessionStorage: { configurable: true, value: win.sessionStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + quotaMode = "ok"; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: string) => { + const url = String(input); + if (!url.includes("/api/provider-quotas")) { + return { ok: true, status: 200, json: async () => ({}), text: async () => "{}" } as unknown as Response; + } + if (quotaMode === "reject") throw new Error("quota unavailable"); + if (quotaMode === "not-ok") { + return { ok: false, status: 503, json: async () => ({}), text: async () => "" } as unknown as Response; + } + const body = payload(); + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response; + }, + }); + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); +}); + +async function mount(epoch: number, force: boolean, settled: Array) { + await act(async () => { + root ??= createRoot(host); + root.render( + + {}} + onAddProvider={() => {}} + quotaRefreshEpoch={epoch} + quotaForceRefresh={force} + onQuotaRefreshSettled={ok => settled.push(ok)} + /> + , + ); + }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); }); +} + +test("an ordinary revalidation does not report an outcome", async () => { + const settled: boolean[] = []; + await mount(0, false, settled); + // Nobody is waiting on a background read; reporting one would resolve a stale promise. + expect(settled).toEqual([]); +}); + +test("a forced read reports success", async () => { + const settled: boolean[] = []; + await mount(1, true, settled); + expect(settled).toEqual([true]); +}); + +test("a non-OK response reports failure instead of silently hanging", async () => { + quotaMode = "not-ok"; + const settled: boolean[] = []; + await mount(1, true, settled); + expect(settled).toEqual([false]); +}); + +test("a rejected fetch reports failure", async () => { + quotaMode = "reject"; + const settled: boolean[] = []; + await mount(1, true, settled); + expect(settled).toEqual([false]); +}); diff --git a/src/providers/quota.ts b/src/providers/quota.ts index c080e74aa8..9cdaafe6b6 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -130,6 +130,18 @@ export interface ProviderQuotaReport { quota: ProviderQuota; updatedAt: number; reverseEngineered?: boolean; + /** + * The row was OBSERVED in-band on a streaming turn rather than probed. + * + * Age means something different for these. A probed provider re-reads on its own TTL, + * so a row older than the last-good bound means the probe is failing and showing it + * would misrepresent a live number. A passive provider publishes no endpoint at all + * (`hasPassiveAccountQuota`), so its last observation is not a stale reading of + * something fresher — it is the only measurement that exists, and dropping it leaves + * the operator with nothing. Consumers that enforce a freshness bound must exempt + * these and state the observation age instead. + */ + observed?: boolean; aggregation?: CodexCapacityAggregation; } @@ -1427,7 +1439,9 @@ async function fetchPassiveProviderQuota(provider: string): Promise - now - item.updatedAt < LAST_GOOD_MAX_AGE_MS && isProviderQuotaReportCurrent(item)); + (item.observed === true || now - item.updatedAt < LAST_GOOD_MAX_AGE_MS) + && isProviderQuotaReportCurrent(item)); if (!forceRefresh && cacheFresh) return cache!.response; const joinable = inflight.get(key); if (!forceRefresh && joinable && joinable.epoch === invalidationEpoch) return joinable.promise; @@ -2518,7 +2537,9 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh const byProvider = new Map(); const generationMismatchedProviders = new Set(); for (const item of previous) { - if (item.updatedAt < cutoff) continue; + // Same exemption as the fast path. A passive row reaching `previous` is not a probe + // that went quiet — there is no probe — so age cannot condemn it. + if (item.observed !== true && item.updatedAt < cutoff) continue; if (isProviderQuotaReportCurrent(item)) byProvider.set(item.provider, item); else generationMismatchedProviders.add(item.provider); } diff --git a/tests/provider-quota-observed-marker.test.ts b/tests/provider-quota-observed-marker.test.ts new file mode 100644 index 0000000000..4925acfebf --- /dev/null +++ b/tests/provider-quota-observed-marker.test.ts @@ -0,0 +1,120 @@ +/** + * A passively observed provider row must be distinguishable on the wire from a probed one. + * + * Both the GUI and this module apply a 30-minute last-good bound, which is correct for a + * PROBED provider: past it, the probe is failing and the number is dead. `meta-muse` + * publishes no quota endpoint at all — usage arrives only inside a streaming + * `response.subscription_usage` frame — so its last observation is the only measurement + * that exists, and applying the probed rule to it deletes the row instead of aging it out. + * The `observed` marker is what lets every consumer tell the two apart. + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveCredential } from "../src/oauth/store"; +import { + clearAccountQuotaCache, + clearProviderQuotaCache, + fetchProviderQuotaReports, + recordPassiveAccountQuota, +} from "../src/providers/quota"; +import { captureConfigGeneration } from "../src/lib/state-store-sweeper"; +import { removeTreeWithRetry } from "./helpers/remove-tree"; +import type { OcxConfig } from "../src/types"; + +const originalHome = process.env.OPENCODEX_HOME; +const originalFetch = globalThis.fetch; +let home: string; + +/** The exact age measured on the live proxy when the missing-Meta-usage defect was reported. */ +const OBSERVED_AGE_MS = 5.39 * 60 * 60_000; + +function config(): OcxConfig { + return { + defaultProvider: "meta-muse", + providers: { + "meta-muse": { + adapter: "openai-responses", + authMode: "oauth", + baseUrl: "https://api.meta.ai/v1", + }, + }, + } as unknown as OcxConfig; +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-observed-marker-")); + process.env.OPENCODEX_HOME = home; + clearProviderQuotaCache(); + clearAccountQuotaCache("meta-muse"); + // No probe may run for a passive provider; a call here is itself a failure. + globalThis.fetch = (async () => { + throw new Error("no upstream call may be made for a passive provider"); + }) as typeof globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearProviderQuotaCache(); + clearAccountQuotaCache("meta-muse"); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + removeTreeWithRetry(home); +}); + +async function seedObservation(ageMs: number): Promise { + await saveCredential("meta-muse", { + access: "access-muse", + refresh: "refresh-muse", + expires: Number.MAX_SAFE_INTEGER, + accountId: "muse-account", + email: "muse@example.test", + }); + const { getAccountSet } = await import("../src/oauth/store"); + const accountId = getAccountSet("meta-muse")!.accounts[0]!.id; + recordPassiveAccountQuota("meta-muse", accountId, { + fiveHourPercent: 1, + weeklyPercent: 1, + updatedAt: Date.now() - ageMs, + }, captureConfigGeneration()); +} + +test("a passive report is marked observed and keeps its observation timestamp", async () => { + await seedObservation(OBSERVED_AGE_MS); + const response = await fetchProviderQuotaReports(config()); + const row = response.reports.find(report => report.provider === "meta-muse"); + + expect(row).toBeDefined(); + expect(row?.observed).toBe(true); + expect(row?.source).toBe("meta-muse:subscription-observation"); + // The age is the point: it is reported, not hidden and not re-stamped as now. + expect(Date.now() - row!.updatedAt).toBeGreaterThan(30 * 60_000); +}); + +test("an observed row does not defeat the cache fast path for every other provider", async () => { + await seedObservation(OBSERVED_AGE_MS); + // The first call builds and commits the cache, returning the freshly built response + // rather than the committed copy. The fast path is what the SUBSEQUENT reads take. + await fetchProviderQuotaReports(config()); + const second = await fetchProviderQuotaReports(config()); + const third = await fetchProviderQuotaReports(config()); + + // Same object identity means the cached response was served rather than re-probed. + // Before the exemption, one configured passive provider made `cacheFresh` permanently + // false, so every dashboard poll re-probed every other provider upstream. + expect(third).toBe(second); + expect(third.generatedAt).toBe(second.generatedAt); + expect(third.reports.some(report => report.provider === "meta-muse")).toBe(true); +}); + +test("the row survives repeated reads instead of aging out of the merge", async () => { + await seedObservation(OBSERVED_AGE_MS); + await fetchProviderQuotaReports(config()); + // Forced reads bypass the cache and re-run the previous/fresh merge each time. + const forced = await fetchProviderQuotaReports(config(), true); + const again = await fetchProviderQuotaReports(config(), true); + + expect(forced.reports.some(report => report.provider === "meta-muse")).toBe(true); + expect(again.reports.some(report => report.provider === "meta-muse")).toBe(true); +});