Skip to content

feat(quota): surface credit balance and pool overview on dashboard#582

Open
icebear0828 wants to merge 3 commits into
devfrom
feat/plus-credit-tracking
Open

feat(quota): surface credit balance and pool overview on dashboard#582
icebear0828 wants to merge 3 commits into
devfrom
feat/plus-credit-tracking

Conversation

@icebear0828
Copy link
Copy Markdown
Owner

Summary

Phase 1 of the "Plus / Pro account spend visibility" track. Goal was originally "show Plus 订阅到期日 on the dashboard" — investigation (this same conversation) confirmed that data point is not exposed by any endpoint reachable from the Codex Desktop OAuth scope. JWT /auth only has chatgpt_plan_type, /codex/usage has only rate windows + credits, /backend-api/me carries profile but no subscription dates, /backend-api/wham/analytics/daily-workspace-usage-counts (the Codex Cloud Settings → Analytics endpoint) returns per-day/week credit usage but no renewal date.

What we can surface today, with zero new upstream traffic, is the credit accounting block that's already in /codex/usage warmup responses. The block was being typed as unknown and dropped on the floor. For Plus accounts it's all zeros (no credit pool) but for Pro / Pay-As-You-Go / Team accounts the balance is the only signal an operator has to track spend without leaving the dashboard.

Changes

Backend

  • src/proxy/codex-types.ts — type CodexUsageCredits, CodexUsageSpendControl, CodexUsageRateLimitReachedType (previously unknown). Re-exported through codex-api.ts.
  • src/auth/types.ts — new CodexQuotaCredits shape on CodexQuota.credits. balance is parsed into a number once at ingest; the rest of the proxy works in numeric.
  • src/auth/quota-utils.tstoQuota() carries credits through, with defensive null on malformed payloads (rather than producing NaN).
  • src/auth/account-registry.tsupdateCachedQuota() preserves previously known credits when the incoming quota lacks them. The passive header-driven path (rateLimitToQuota in proxy-rate-limit) doesn't carry credit info — without this merge every /codex/responses response would wipe the balance set by the warmup. Behavior-equivalent for paths that always set credits.
  • src/config-schema.ts — new usage_stats.credits_per_usd (default 25 ⇒ 1000 credits = $40, per the public rate card). Set to 0 to suppress USD rendering.

Frontend

  • shared/types.tsAccountQuota.credits mirrors backend shape.
  • shared/utils/format.tsformatCredits / creditsToUsd / formatUsd helpers.
  • shared/i18n/translations.ts — 8 new strings (zh + en): creditsBalance, creditsUnlimited, creditsOverageReached, poolOverview, poolActiveAccounts, poolExhaustedAccounts, poolTotalCredits, poolTopUsage.
  • web/src/components/AccountCard.tsx — new Credit Balance row rendered only when has_credits=true || unlimited=true. Plus accounts render exactly as before. Overage-limit flag goes red.
  • web/src/components/PoolOverview.tsx (new) — card above AccountList on the main page. Shows active vs. quota-exhausted counts, sum of credits + USD across accounts with a real credit pool, and the account with highest secondary used_percent + its reset time.
  • web/src/App.tsx — wire PoolOverview above AccountList on the default tab.

Known follow-ups (separate PRs)

  • credits_per_usd is currently hard-coded in the dashboard (DEFAULT_CREDITS_PER_USD = 25 in AccountCard.tsx and PoolOverview.tsx) matching the schema default. Wiring it through admin/general-settings so an operator's data/local.yaml override actually takes effect on the UI is a follow-up.
  • web/src/components/*.test.tsx files are currently dead because jsdom isn't installed; adding the devDep + restoring the tests is out of scope here. Pool aggregation logic is covered by tests/unit/web/pool-overview-stats.test.ts instead (pure function, runs under node).
  • Phase 2 (per-account daily / weekly credit history via the wham/analytics/daily-workspace-usage-counts endpoint, with bar charts on a dedicated drawer) is intentionally not included. The endpoint works (probed + confirmed in the same conversation) but is poll-only — each call is ~1 extra /backend-api request per account. Will be opt-in / on-demand in a follow-up to keep risk profile clean.

Test Plan

  • npx vitest run tests/unit/auth/quota-utils.test.ts — 13 pass (5 new for credits)
  • npx vitest run tests/unit/auth/account-pool-quota.test.ts — 21 pass (2 new for credits-preserve merge)
  • npx vitest run shared/utils/__tests__/format-credits.test.ts — 11 pass (new file)
  • npx vitest run tests/unit/web/pool-overview-stats.test.ts — 7 pass (new file)
  • npx vitest run tests/unit/config-schema.test.ts — 15 pass (1 new for credits_per_usd default)
  • npx vitest run — 2279 pass, 1 skipped, 0 failures across full suite
  • npx tsc --noEmit — clean
  • npm run build — clean web bundle, 267 kB → 66 kB gzipped (+8 kB vs base)
  • Pre-push hook validates the branch
  • Manual browser verification on http://localhost:8080/: confirm PoolOverview card renders above the account list (since current pool has only Plus accounts with has_credits=false, the Credit Balance row and "Total Credits" tile correctly stay hidden; active/exhausted counts and "Highest Weekly Usage" should appear)

Notes

The cache-related concern about Phase 1 changing upstream traffic: this PR ships zero new upstream calls. Credits flow through the existing /codex/usage warmup that runs at import / on manual "refresh" button click. No background poll, no new endpoint, no new auth scope. The cache hit rate sampling we did earlier in the same investigation showed instructions are already stable (cch strip works), so the existing cache hit rate (~97% steady-state) is unaffected.

CodexUsageResponse.credits was previously typed as `unknown` and
dropped on the floor by toQuota(). For Plus accounts that doesn't
matter (has_credits=false, balance=0), but for Pro / Pay-As-You-Go
and team accounts the balance is the only signal the operator has
to track spend without leaving the dashboard.

Phase 1 surfaces the data that's already on the wire from the
existing /codex/usage warmup polls — no new upstream traffic, no
new auth scope, no risk to account standing:

* Type CodexUsageResponse.credits / spend_control /
  rate_limit_reached_type properly and carry credits through
  toQuota() into a new CodexQuota.credits slot. balance is parsed
  from upstream's decimal string into a number; malformed payloads
  return null defensively rather than NaN.

* updateCachedQuota() now preserves previously known credits when
  the incoming quota lacks them. The passive header-driven path
  (rateLimitToQuota in proxy-rate-limit) doesn't carry credit info
  — without this merge every /codex/responses response would wipe
  the balance set by the warmup.

* Config schema adds usage_stats.credits_per_usd (default 25 ⇒
  1000 credits = $40 per the public rate card). Set to 0 to
  suppress USD rendering. Currently consumed by the dashboard as
  a hard-coded constant matching the default; a follow-up will
  wire it through admin/general-settings.

* AccountCard renders a Credit Balance row only for accounts where
  has_credits=true or unlimited=true. Plus accounts render exactly
  as before. Overage-limit-reached accounts get a red flag.

* PoolOverview is a new card above AccountList on the main page,
  showing active vs. quota-exhausted counts, the sum of credits +
  USD across accounts with a real credit pool, and the account
  with the highest secondary used_percent + its reset time.

Tests cover the credits parser (5), the credits-preserve merge
(2), the format helpers (11), the pool aggregation logic (7), and
the new config default (1). No new tests for the React tree
itself — the project's web .test.tsx files are dead today because
jsdom isn't installed; restoring that lives in a follow-up.

i18n: 8 new strings in zh + en.
…refresh

GET /auth/accounts/:id/quota called /codex/usage on every invocation
but returned the result without touching pool.cachedQuota. As a
result the dashboard's per-account "Refresh" button (which POSTed
/refresh — token refresh only — and never hit /quota) had no path
to surface upstream window resets. After OpenAI does a promo /
window grant that resets secondary_window.used_percent to 0, the
proxy keeps showing the pre-reset percentage until a real proxied
/codex/responses request comes in and passively updates cachedQuota
via x-codex-* headers.

That same path also never surfaces the credits block (Pro / PAYG
accounts), since x-codex-credits-* headers are not parsed today —
only /codex/usage body carries credits, and only via toQuota().

Persist toQuota(usage) into pool.updateCachedQuota right after the
upstream fetch in the route handler, and chain the dashboard's
"Refresh" button so it hits /refresh (token + status) followed by
/quota (cachedQuota write-back). Result: clicking Refresh on a
card after a quota reset now immediately reflects the upstream
state on screen, and Credit Balance rows populate for Pro / PAYG
accounts on first refresh without waiting for traffic.

Discovered while validating PR #582 — three Plus accounts that the
proxy was reporting at 98–100% secondary_used were actually at 0%
upstream after a recent promo refresh. After this fix, /quota on
each restored the truth.
@icebear0828
Copy link
Copy Markdown
Owner Author

type 修复 + credits 保留合并思路对路,三点小修建议:

  • M1 web/src/components/AccountCard.tsx:17 / PoolOverview.tsx:9DEFAULT_CREDITS_PER_USD = 25 硬编码两份。config-schema.ts 加的 usage_stats.credits_per_usd 字段目前是死的,operator 改 data/local.yaml 不生效。PR 自承 follow-up,这里只提一下方便后续。
  • M2 src/auth/account-registry.ts:453-457 credits 保留只判 === undefined,但 toQuota 在 malformed payload 时返回 credits: null,这条 case 旧 credits 仍会被抹掉。建议改 quota.credits == null 或显式两条分支,顺手加个 corner-case 测试。
  • M3 web/src/components/AccountList.tsx:392-401 双步 refresh 第二步 .catch(() => undefined) 静默吞错,/refresh 成功但 /quota 失败时 UI 完全没反馈。建议至少 console.warn 或 toast。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant