-
Notifications
You must be signed in to change notification settings - Fork 1.1k
fix(gui): show passively observed quota and add an operator refresh control #3448
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
9ddeceb
docs(devlog): roadmap for provider quota refresh + Meta usage visibility
60bede6
fix(gui,providers): keep passively observed quota visible on every su…
934d2de
feat(gui): let an operator refresh provider quotas from the dashboard
a1de017
docs(devlog): live verification record and dashboard screenshots
25b48d6
docs(devlog): record the PR #3448 CI outcome
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <file>`, | ||
| `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. |
113 changes: 113 additions & 0 deletions
113
devlog/_plan/260904_provider_quota_refresh/010_wp1_passive_quota_visibility.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| <QuotaBars quota={quota} plan={null} threshold={80} t={t} layout="stacked" | ||
| {...(quotaReport?.observed && quotaReport.updatedAt !== undefined | ||
| ? { observedAt: quotaReport.updatedAt } : {})} /> | ||
| ``` | ||
|
|
||
| `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시간 전에 확인한 값". | ||
129 changes: 129 additions & 0 deletions
129
devlog/_plan/260904_provider_quota_refresh/020_wp2_refresh_affordance.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<boolean> => { | ||
| 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<boolean>`. | ||
| 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<boolean>; | ||
| ``` | ||
|
|
||
| with local busy/message state identical in shape to the Accounts one. The section | ||
| header becomes a flex row: `<h3>` 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`. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Correct the server test filename.
Line 98 and Line 109 reference
tests/provider-quota-observed-flag.test.ts, but the added test istests/provider-quota-observed-marker.test.ts. The documented verification command will not run the intended test.Proposed fix
Also applies to: 109-111
🤖 Prompt for AI Agents