From db0754e49d46b1239ae3efffcc4269f5536e5c99 Mon Sep 17 00:00:00 2001 From: icebear0828 Date: Sat, 16 May 2026 13:54:45 -0700 Subject: [PATCH 1/3] feat(quota): surface credit balance and pool overview on dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 1 + shared/i18n/translations.ts | 16 ++ shared/types.ts | 10 ++ shared/utils/__tests__/format-credits.test.ts | 61 ++++++++ shared/utils/format.ts | 27 ++++ src/auth/account-registry.ts | 10 +- src/auth/quota-utils.ts | 21 ++- src/auth/types.ts | 11 ++ src/config-schema.ts | 4 + src/proxy/codex-api.ts | 3 + src/proxy/codex-types.ts | 32 +++- tests/unit/auth/account-pool-quota.test.ts | 39 +++++ tests/unit/auth/quota-utils.test.ts | 58 ++++++++ tests/unit/config-schema.test.ts | 1 + tests/unit/web/pool-overview-stats.test.ts | 137 ++++++++++++++++++ web/src/App.tsx | 2 + web/src/components/AccountCard.tsx | 46 +++++- web/src/components/PoolOverview.tsx | 115 +++++++++++++++ 18 files changed, 588 insertions(+), 6 deletions(-) create mode 100644 shared/utils/__tests__/format-credits.test.ts create mode 100644 tests/unit/web/pool-overview-stats.test.ts create mode 100644 web/src/components/PoolOverview.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 26130d94..ecac5800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Added +- Dashboard 新增 Plus / Pro / PAYG 账号的 credit 余额可视化与账号池总览(Phase 1,零新增上游 traffic):`CodexUsageResponse.credits` 此前被打成 `unknown` 全量丢弃,现在 `toQuota()` 把 `has_credits / unlimited / overage_limit_reached / balance` 解析进新 `CodexQuota.credits` 槽,并把 `balance` 从 upstream 的十进制字符串转成 number(malformed payload 防御性返回 null 而不是 NaN);`AccountRegistry.updateCachedQuota` 在收到不带 credits 字段的 quota(passive header path)时保留之前已知的 credit 余额,防止每次 `/codex/responses` 响应抹掉初始 warmup 拿到的余额;新增 `usage_stats.credits_per_usd` 配置项(默认 25,即 1000 credits = $40),dashboard 端按此换算 USD 显示。前端 `AccountCard` 在 `has_credits=true` 或 `unlimited=true` 的账号上新增一行 Credit Balance(balance + USD 换算 或 "Unlimited" 标签 或 overage 红字提示),Plus 账号 has_credits=false 时整行不渲染;新增 `PoolOverview` 卡片在 `/` 主页 AccountList 上方汇总账号池:active / quota_exhausted 数量、所有携带 credits 的账号余额合计与 USD 折算、secondary used% 最高的账号 + 重置时间。新增 `formatCredits` / `creditsToUsd` / `formatUsd` 共享格式化工具,shared 类型 `AccountQuota.credits` 透传到前端。新增 `tests/unit/auth/quota-utils.test.ts` 5 个 credits 用例、`tests/unit/auth/account-pool-quota.test.ts` 2 个 updateCachedQuota credits-preserve 用例、`shared/utils/__tests__/format-credits.test.ts` 11 个格式化工具用例、`tests/unit/web/pool-overview-stats.test.ts` 7 个聚合算法用例、`tests/unit/config-schema.test.ts` 补 `credits_per_usd` 默认值断言。i18n 中英新增 8 个新键(`src/proxy/codex-types.ts`、`src/auth/types.ts`、`src/auth/quota-utils.ts`、`src/auth/account-registry.ts`、`src/config-schema.ts`、`src/proxy/codex-api.ts`、`shared/types.ts`、`shared/utils/format.ts`、`shared/i18n/translations.ts`、`web/src/App.tsx`、`web/src/components/AccountCard.tsx`、`web/src/components/PoolOverview.tsx`) - 支持 Dashboard 配置模型映射与本地自定义模型目录:`data/local.yaml` 可把客户端模型名映射到 Codex 模型、带 provider 前缀的第三方模型或已有 `model_routing` 目标;Dashboard → Settings → 模型映射可直接增删 alias 并热加载后端;`model.custom_models` 可把自定义 Codex-compatible ID 加入 `/v1/models/catalog` 并支持 `-fast` / `-high` 等后缀。ModelStore 会用本地 alias 覆盖静态 `config/models.yaml` alias,UpstreamRouter 会在内置 Claude/Gemini 自动路由前解析 alias,并在直连 provider 请求中把 outgoing `model` 改写为映射目标。新增 schema / model-store / upstream-router / route direct guard / Dashboard 组件测试覆盖配置默认值、静态 alias 覆盖、custom catalog、provider target 路由、四类直连接口(Chat / Messages / Responses / Gemini)的目标模型透传和 UI 持久化(`src/config-schema.ts`、`src/models/model-store.ts`、`src/proxy/upstream-router.ts`、`src/routes/admin/settings.ts`、`src/routes/chat.ts`、`src/routes/messages.ts`、`src/routes/responses.ts`、`src/routes/gemini.ts`、`web/src/components/ModelAliasSettings.tsx`、`tests/unit/config-schema.test.ts`、`tests/unit/models/model-store.test.ts`、`tests/unit/proxy/upstream-router.test.ts`、`tests/unit/routes/general-settings.test.ts`、`tests/unit/routes/upstream-auth-bypass.test.ts`、`web/src/components/ModelAliasSettings.test.tsx`) - Stream-close 事件结构化落盘到 Errors tab + 审计 log:`premature stream close` / `stream-client-abort` / `stream-client-disconnect` / `stream-error` 此前只走 `console.warn` 进 `dev-YYYY-MM-DD.log`,需要 grep 才能定位,且生产模式没有 tee;新增 `src/logs/stream-close-event.ts` 把这些事件同时写到 `data/error-log.jsonl`(Errors tab 按签名分组 + 角标计数)和 `logStore`(`/admin/logs` 审计流)。覆盖 7 个调用点:`proxy-handler.ts` 两处 client abort + 一处 `UpstreamPrematureCloseError`(带 eventCount / hadReasoning / responseId / variantHash)、`response-processor.ts` 两处(`client-write-failed` 带 writtenChunks/Bytes/lastSentEvent;`upstream-error` 带 upstreamStatus)、`responses.ts` 两处 `streamPassthrough` 内部 EOF(rid / accountEntryId / variantHash 通过 `FormatAdapter.streamTranslator` 的 `streamContext` option 由 `response-processor` 透传,其它 adapter 兼容性接收并忽略)。顺手修 `error-log.ts:readAppVersion` 在 config 未加载时崩溃(unit-test 路径会撞到),改为 try/catch 兜底回退 "unknown"。新增 `tests/unit/logs/stream-close-event.test.ts` 6 个单测覆盖 4 种 kind + 缺失 rid 兜底 + numeric upstreamStatus → audit status 透传 + direct upstream provider/path;Errors tab 展开分组时会显示 sample context。下次复现 premature close 直接看 Errors tab 按 `StreamUpstreamPrematureClose` 分组拉 rid + account + closeCode,不用再 grep dev 日志(`src/logs/stream-close-event.ts`、`src/logs/error-log.ts`、`src/routes/shared/proxy-handler.ts`、`src/routes/shared/response-processor.ts`、`src/routes/responses.ts`、`tests/unit/logs/stream-close-event.test.ts`) - Opt-in 上游请求/响应 dumper:新增 `src/utils/debug-dump.ts`,环境变量 `CODEX_PROXY_DEBUG_DUMP=1` 启用时把每次上游请求 + 流式 chunk + 终止状态 + 错误写入 `/tmp/codex-proxy-dump-.jsonl`(一行一事件);未启用时所有 hook 是 `if (debugDumpEnabled())` 守护下的纯 boolean check,零开销。在 `src/routes/shared/proxy-handler.ts` 加 1 个 hook(`request`,含 rid/tag/entryId/conv/implicitResumeActive/resumeReason/payload),在 `src/routes/shared/response-processor.ts` 加 3 个 hook(`upstream-chunk` 截断到 16KB、`stream-finish` 含 chunks/bytes/sawTerminal、`stream-error` 含 status/msg/body 截断到 4KB)。**privacy 警告**:dump 文件包含完整 request payload(含用户 prompt)和上游响应,路径在启动时打印一次提示 sensitive 性质。日常排查"账号轮换重试风暴" / "premature stream close" 等偶发错误时 opt-in 启用,问题复现后再 opt-out diff --git a/shared/i18n/translations.ts b/shared/i18n/translations.ts index 784759b0..450eed86 100644 --- a/shared/i18n/translations.ts +++ b/shared/i18n/translations.ts @@ -31,6 +31,14 @@ export const translations = { secondaryRateLimit: "Weekly Limit", reviewRateLimit: "Review Limit", additionalRateLimit: "Additional Limit", + creditsBalance: "Credit Balance", + creditsUnlimited: "Unlimited", + creditsOverageReached: "Overage limit reached", + poolOverview: "Pool Overview", + poolActiveAccounts: "Active", + poolExhaustedAccounts: "Quota Exhausted", + poolTotalCredits: "Total Credits", + poolTopUsage: "Highest Weekly Usage", limitReached: "Limit Reached", used: "Used", ok: "OK", @@ -434,6 +442,14 @@ export const translations = { secondaryRateLimit: "\u5468\u9650\u5236", reviewRateLimit: "Review \u989d\u5ea6", additionalRateLimit: "\u989d\u5916\u989d\u5ea6", + creditsBalance: "Credit \u4f59\u989d", + creditsUnlimited: "\u65e0\u9650", + creditsOverageReached: "\u8d85\u9650\u62cd\u540e\u5df2\u5230\u4e0a\u9650", + poolOverview: "\u8d26\u53f7\u6c60\u603b\u89c8", + poolActiveAccounts: "\u6d3b\u8dc3", + poolExhaustedAccounts: "\u989d\u5ea6\u8017\u5c3d", + poolTotalCredits: "Credit \u603b\u4f59\u989d", + poolTopUsage: "\u5468\u9650\u4f7f\u7528\u7387\u6700\u9ad8\u8d26\u53f7", limitReached: "\u5df2\u8fbe\u4e0a\u9650", used: "\u5df2\u4f7f\u7528", ok: "\u6b63\u5e38", diff --git a/shared/types.ts b/shared/types.ts index ac517bf8..dcfa249a 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -5,6 +5,14 @@ export interface AccountQuotaWindow { limit_window_seconds?: number | null; } +export interface AccountQuotaCredits { + has_credits: boolean; + unlimited: boolean; + overage_limit_reached: boolean; + /** Numeric balance parsed from upstream's decimal-string field. */ + balance: number; +} + export interface AccountQuota { rate_limit?: AccountQuotaWindow; secondary_rate_limit?: AccountQuotaWindow | null; @@ -15,6 +23,8 @@ export interface AccountQuota { allowed?: boolean; secondary_rate_limit?: AccountQuotaWindow | null; }> | null; + /** Credit accounting from /codex/usage. Null for Plus, present for Pro / PAYG. */ + credits?: AccountQuotaCredits | null; } export interface QuotaWarning { diff --git a/shared/utils/__tests__/format-credits.test.ts b/shared/utils/__tests__/format-credits.test.ts new file mode 100644 index 00000000..0680da36 --- /dev/null +++ b/shared/utils/__tests__/format-credits.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { formatCredits, creditsToUsd, formatUsd } from "../format"; + +describe("formatCredits", () => { + it("renders zero as plain '0'", () => { + expect(formatCredits(0)).toBe("0"); + }); + + it("strips trailing zeros for round numbers", () => { + expect(formatCredits(5)).toBe("5"); + expect(formatCredits(247.5)).toBe("247.5"); + }); + + it("rounds small decimals to two places", () => { + expect(formatCredits(12.345)).toBe("12.35"); + expect(formatCredits(0.05)).toBe("0.05"); + }); + + it("uses k suffix above 1000", () => { + expect(formatCredits(3196)).toBe("3.2k"); + expect(formatCredits(7000)).toBe("7k"); + }); + + it("returns '0' for non-finite input", () => { + expect(formatCredits(NaN)).toBe("0"); + expect(formatCredits(Infinity)).toBe("0"); + }); +}); + +describe("creditsToUsd", () => { + it("converts at the default rate (25 credits = $1)", () => { + expect(creditsToUsd(25, 25)).toBe(1); + expect(creditsToUsd(1000, 25)).toBe(40); + }); + + it("returns null when rate is zero or negative (USD display disabled)", () => { + expect(creditsToUsd(500, 0)).toBeNull(); + expect(creditsToUsd(500, -1)).toBeNull(); + }); + + it("returns null for non-finite inputs", () => { + expect(creditsToUsd(NaN, 25)).toBeNull(); + expect(creditsToUsd(100, NaN)).toBeNull(); + }); +}); + +describe("formatUsd", () => { + it("formats with $ sign and two decimals", () => { + expect(formatUsd(0)).toBe("$0.00"); + expect(formatUsd(12.345)).toBe("$12.35"); + }); + + it("uses k suffix above $1000", () => { + expect(formatUsd(1234.56)).toBe("$1.2k"); + expect(formatUsd(40000)).toBe("$40k"); + }); + + it("handles negatives", () => { + expect(formatUsd(-12.34)).toBe("-$12.34"); + }); +}); diff --git a/shared/utils/format.ts b/shared/utils/format.ts index b5ff9810..36178eee 100644 --- a/shared/utils/format.ts +++ b/shared/utils/format.ts @@ -4,6 +4,33 @@ export function formatNumber(n: number): string { return String(n); } +/** Format a Codex credit balance: "0", "12.34", "1.2k". Always strip trailing zeros. */ +export function formatCredits(credits: number): string { + if (!Number.isFinite(credits)) return "0"; + if (credits >= 1000) return (credits / 1000).toFixed(1).replace(/\.0$/, "") + "k"; + if (credits === 0) return "0"; + // Two decimals for small numbers, but trim trailing zeros so 5.00 → "5". + return credits.toFixed(2).replace(/\.?0+$/, ""); +} + +/** Convert credits to USD using the configured per-USD rate. + * Returns null when conversion is disabled (creditsPerUsd <= 0). */ +export function creditsToUsd(credits: number, creditsPerUsd: number): number | null { + if (!Number.isFinite(credits) || !Number.isFinite(creditsPerUsd) || creditsPerUsd <= 0) { + return null; + } + return credits / creditsPerUsd; +} + +/** Format a USD amount with $ sign and two decimals. "$12.34" / "$1.2k". */ +export function formatUsd(usd: number): string { + if (!Number.isFinite(usd)) return "$0"; + const sign = usd < 0 ? "-" : ""; + const abs = Math.abs(usd); + if (abs >= 1000) return sign + "$" + (abs / 1000).toFixed(1).replace(/\.0$/, "") + "k"; + return sign + "$" + abs.toFixed(2); +} + export function formatWindowDuration(seconds: number, isZh: boolean): string { if (seconds >= 86400) { const days = Math.floor(seconds / 86400); diff --git a/src/auth/account-registry.ts b/src/auth/account-registry.ts index 7f0c1f73..9907adca 100644 --- a/src/auth/account-registry.ts +++ b/src/auth/account-registry.ts @@ -450,7 +450,15 @@ export class AccountRegistry { updateCachedQuota(entryId: string, quota: CodexQuota): void { const entry = this.accounts.get(entryId); if (!entry) return; - entry.cachedQuota = quota; + // Preserve previously known credits when the incoming quota lacks them. + // The passive header-driven path (rateLimitToQuota in proxy-rate-limit.ts) + // does not carry credit balance — only /codex/usage body (toQuota) does. + // Without this merge, every /codex/responses call would wipe credits. + if (quota.credits === undefined && entry.cachedQuota?.credits != null) { + entry.cachedQuota = { ...quota, credits: entry.cachedQuota.credits }; + } else { + entry.cachedQuota = quota; + } entry.quotaFetchedAt = new Date().toISOString(); this.schedulePersist(); } diff --git a/src/auth/quota-utils.ts b/src/auth/quota-utils.ts index 47496f6b..57afe752 100644 --- a/src/auth/quota-utils.ts +++ b/src/auth/quota-utils.ts @@ -3,8 +3,24 @@ * Converts CodexUsageResponse (raw backend) → CodexQuota (normalized). */ -import type { CodexQuota } from "./types.js"; -import type { CodexUsageRateLimit, CodexUsageResponse } from "../proxy/codex-api.js"; +import type { CodexQuota, CodexQuotaCredits } from "./types.js"; +import type { CodexUsageCredits, CodexUsageRateLimit, CodexUsageResponse } from "../proxy/codex-api.js"; + +function normalizeCredits(raw: CodexUsageCredits | null | undefined): CodexQuotaCredits | null { + if (!raw) return null; + // balance must be parseable — upstream always sends a decimal string, + // but defensively reject malformed payloads so the dashboard never + // shows NaN credits. + if (typeof raw.balance !== "string") return null; + const balance = Number(raw.balance); + if (!Number.isFinite(balance)) return null; + return { + has_credits: Boolean(raw.has_credits), + unlimited: Boolean(raw.unlimited), + overage_limit_reached: Boolean(raw.overage_limit_reached), + balance, + }; +} function isReviewLimitId(value: string | null | undefined): boolean { const normalized = (value ?? "").trim().toLowerCase().replace(/[-\s]+/g, "_"); @@ -82,5 +98,6 @@ export function toQuota(usage: CodexUsageResponse): CodexQuota { rate_limits_by_limit_id: Object.keys(rateLimitsByLimitId).length > 0 ? rateLimitsByLimitId : null, + credits: normalizeCredits(usage.credits), }; } diff --git a/src/auth/types.ts b/src/auth/types.ts index 41b3b759..9285cf81 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -98,6 +98,15 @@ export interface CodexQuotaWindow { limit_window_seconds: number | null; } +/** Normalized credit accounting for an account. */ +export interface CodexQuotaCredits { + has_credits: boolean; + unlimited: boolean; + overage_limit_reached: boolean; + /** Numeric balance parsed from the upstream decimal-string field. */ + balance: number; +} + /** Official Codex quota from /backend-api/codex/usage */ export interface CodexQuota { plan_type: string; @@ -116,6 +125,8 @@ export interface CodexQuota { reset_at: number | null; limit_window_seconds: number | null; } | null; + /** Credit accounting (Pro / PAYG only — null for Plus). */ + credits?: CodexQuotaCredits | null; /** All metered quota buckets returned by Codex app's /wham/usage additional_rate_limits. */ rate_limits_by_limit_id?: Record { // Should not throw pool.updateCachedQuota("nonexistent", makeQuota()); }); + + it("preserves existing credits when new quota lacks them (header-driven passive update)", () => { + const id = pool.addAccount(createValidJwt({ accountId: "credits-1", planType: "pro" })); + // First write: full quota WITH credits (from /codex/usage body, toQuota path). + pool.updateCachedQuota(id, makeQuota({ + credits: { has_credits: true, unlimited: false, overage_limit_reached: false, balance: 99.5 }, + })); + // Second write: header-driven quota without credits (rateLimitToQuota path). + // Must preserve the previously known balance, not wipe it. + pool.updateCachedQuota(id, makeQuota({ + rate_limit: { + allowed: true, + limit_reached: false, + used_percent: 60, + reset_at: Math.floor(Date.now() / 1000) + 1800, + limit_window_seconds: 3600, + }, + })); + const entry = pool.getEntry(id); + expect(entry?.cachedQuota?.credits).toEqual({ + has_credits: true, + unlimited: false, + overage_limit_reached: false, + balance: 99.5, + }); + expect(entry?.cachedQuota?.rate_limit.used_percent).toBe(60); + }); + + it("overwrites credits when new quota explicitly provides them", () => { + const id = pool.addAccount(createValidJwt({ accountId: "credits-2", planType: "pro" })); + pool.updateCachedQuota(id, makeQuota({ + credits: { has_credits: true, unlimited: false, overage_limit_reached: false, balance: 100 }, + })); + pool.updateCachedQuota(id, makeQuota({ + credits: { has_credits: true, unlimited: false, overage_limit_reached: false, balance: 42 }, + })); + const entry = pool.getEntry(id); + expect(entry?.cachedQuota?.credits?.balance).toBe(42); + }); }); describe("applyRateLimit429 (replaces markQuotaExhausted)", () => { diff --git a/tests/unit/auth/quota-utils.test.ts b/tests/unit/auth/quota-utils.test.ts index 44617608..c5169e3f 100644 --- a/tests/unit/auth/quota-utils.test.ts +++ b/tests/unit/auth/quota-utils.test.ts @@ -228,4 +228,62 @@ describe("toQuota", () => { expect(quota.rate_limit.reset_at).toBeNull(); expect(quota.rate_limit.limit_window_seconds).toBeNull(); }); + + describe("credits", () => { + it("carries credits block through when present (Plus shape: has_credits=false, balance=0)", () => { + const quota = toQuota(makeUsageResponse({ + credits: { + has_credits: false, + unlimited: false, + overage_limit_reached: false, + balance: "0", + }, + })); + expect(quota.credits).toEqual({ + has_credits: false, + unlimited: false, + overage_limit_reached: false, + balance: 0, + }); + }); + + it("parses decimal-string balance into number for Pro / PAYG accounts", () => { + const quota = toQuota(makeUsageResponse({ + credits: { + has_credits: true, + unlimited: false, + overage_limit_reached: false, + balance: "247.50", + }, + })); + expect(quota.credits?.has_credits).toBe(true); + expect(quota.credits?.balance).toBe(247.5); + }); + + it("emits null credits when upstream omits the block", () => { + const quota = toQuota(makeUsageResponse({ credits: null })); + expect(quota.credits).toBeNull(); + }); + + it("emits null credits when upstream sends a malformed block (defensive)", () => { + const quota = toQuota(makeUsageResponse({ + // Simulate a future upstream schema where balance is missing entirely + credits: { has_credits: true } as unknown as NonNullable, + })); + expect(quota.credits).toBeNull(); + }); + + it("passes unlimited and overage_limit_reached flags through", () => { + const quota = toQuota(makeUsageResponse({ + credits: { + has_credits: true, + unlimited: true, + overage_limit_reached: true, + balance: "0", + }, + })); + expect(quota.credits?.unlimited).toBe(true); + expect(quota.credits?.overage_limit_reached).toBe(true); + }); + }); }); diff --git a/tests/unit/config-schema.test.ts b/tests/unit/config-schema.test.ts index 7e80dae1..668a0739 100644 --- a/tests/unit/config-schema.test.ts +++ b/tests/unit/config-schema.test.ts @@ -43,6 +43,7 @@ describe("ConfigSchema", () => { expect(result.tls.force_http11).toBe(false); expect(result.usage_stats.snapshot_interval_minutes).toBe(5); expect(result.usage_stats.history_retention_days).toBeNull(); + expect(result.usage_stats.credits_per_usd).toBe(25); expect(result.quota.refresh_interval_minutes).toBe(5); expect(result.quota.warning_thresholds.primary).toEqual([80, 90]); expect(result.quota.skip_exhausted).toBe(true); diff --git a/tests/unit/web/pool-overview-stats.test.ts b/tests/unit/web/pool-overview-stats.test.ts new file mode 100644 index 00000000..83f8b73d --- /dev/null +++ b/tests/unit/web/pool-overview-stats.test.ts @@ -0,0 +1,137 @@ +// Pure-logic tests for the PoolOverview aggregation function. +// +// Imports the component module by string path because vitest's include +// pattern currently doesn't cover web .tsx files (jsdom devDep missing). +// Once the renderer environment is set up in a follow-up PR, a renderer +// test can live next to the component instead. +import { describe, it, expect } from "vitest"; +import { computePoolStats } from "../../../web/src/components/PoolOverview"; +import type { Account } from "../../../shared/types"; + +function plus(overrides: Partial = {}): Account { + return { + id: "p" + (overrides.id ?? Math.random().toString(36).slice(2, 6)), + email: overrides.email ?? "p@example.com", + status: "active", + planType: "plus", + ...overrides, + } as Account; +} + +function pro(balance: number, overrides: Partial = {}): Account { + return { + id: "pro-" + (overrides.id ?? Math.random().toString(36).slice(2, 6)), + email: overrides.email ?? "pro@example.com", + status: "active", + planType: "pro", + quota: { + credits: { + has_credits: true, + unlimited: false, + overage_limit_reached: false, + balance, + }, + }, + ...overrides, + } as Account; +} + +describe("computePoolStats", () => { + it("returns zero counts and no top-usage for an empty pool", () => { + const stats = computePoolStats([]); + expect(stats.active).toBe(0); + expect(stats.exhausted).toBe(0); + expect(stats.totalCredits).toBe(0); + expect(stats.totalUsd).toBeNull(); + expect(stats.hasAnyCredits).toBe(false); + expect(stats.topUsage).toBeNull(); + }); + + it("counts active vs quota-exhausted via derivedStatus", () => { + const stats = computePoolStats([ + plus({ id: "a", status: "active" }), + plus({ + id: "b", + status: "active", + quota: { + secondary_rate_limit: { + used_percent: 100, + limit_reached: true, + reset_at: Math.floor(Date.now() / 1000) + 3600, + }, + }, + }), + plus({ id: "c", status: "disabled" }), + ]); + expect(stats.active).toBe(1); + expect(stats.exhausted).toBe(1); + }); + + it("sums credit balance across accounts with has_credits=true (skips Plus)", () => { + const stats = computePoolStats([ + plus(), // Plus — ignored + pro(247.5, { id: "p1" }), + pro(100, { id: "p2" }), + ]); + expect(stats.hasAnyCredits).toBe(true); + expect(stats.totalCredits).toBe(347.5); + // 347.5 credits / 25 USD = $13.9 + expect(stats.totalUsd).toBeCloseTo(13.9); + }); + + it("treats unlimited accounts as 'has credits' but excludes their balance from the sum", () => { + const stats = computePoolStats([ + pro(50, { id: "p1" }), + pro(0, { + id: "p2", + quota: { + credits: { has_credits: true, unlimited: true, overage_limit_reached: false, balance: 999 }, + }, + }), + ]); + expect(stats.hasAnyCredits).toBe(true); + expect(stats.totalCredits).toBe(50); + }); + + it("hasAnyCredits stays false when only Plus accounts (has_credits=false) are present", () => { + const stats = computePoolStats([plus({ id: "a" }), plus({ id: "b" })]); + expect(stats.hasAnyCredits).toBe(false); + expect(stats.totalUsd).toBeNull(); + }); + + it("picks the account with highest secondary used_percent for topUsage", () => { + const accounts = [ + plus({ + id: "low", + email: "low@x.com", + quota: { secondary_rate_limit: { used_percent: 30, limit_reached: false, reset_at: 1700000000 } }, + }), + plus({ + id: "high", + email: "high@x.com", + quota: { secondary_rate_limit: { used_percent: 92, limit_reached: false, reset_at: 1700050000 } }, + }), + plus({ + id: "mid", + email: "mid@x.com", + quota: { secondary_rate_limit: { used_percent: 65, limit_reached: false, reset_at: 1700020000 } }, + }), + ]; + const stats = computePoolStats(accounts); + expect(stats.topUsage).not.toBeNull(); + expect(stats.topUsage!.account.email).toBe("high@x.com"); + expect(stats.topUsage!.pct).toBe(92); + expect(stats.topUsage!.resetAt).toBe(1700050000); + }); + + it("treats limit_reached as 100% for topUsage even when used_percent is null", () => { + const stats = computePoolStats([ + plus({ + id: "capped", + email: "capped@x.com", + quota: { secondary_rate_limit: { limit_reached: true, used_percent: null, reset_at: 1700000000 } }, + }), + ]); + expect(stats.topUsage?.pct).toBe(100); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index e416eedd..81f2d48b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,6 +7,7 @@ import { Header } from "./components/Header"; import { UpdateModal } from "./components/UpdateModal"; import { AddAccount } from "./components/AddAccount"; import { AccountList } from "./components/AccountList"; +import { PoolOverview } from "./components/PoolOverview"; import { SettingsTab } from "./components/SettingsTab"; import { ProxyPool } from "./components/ProxyPool"; import { Footer } from "./components/Footer"; @@ -162,6 +163,7 @@ function Dashboard() { {activeTab === "" && (
+ limitLabel(a).localeCompare(limitLabel(b))); + // Credits — only render for accounts that actually carry a credit pool + // (Pro / PAYG / Team with explicit balance). Plus accounts have + // has_credits=false and a "0" balance that conveys no useful info. + const creditsInfo = q?.credits; + const showCredits = !!creditsInfo && (creditsInfo.has_credits || creditsInfo.unlimited); + const creditUsd = showCredits && !creditsInfo!.unlimited + ? creditsToUsd(creditsInfo!.balance, DEFAULT_CREDITS_PER_USD) + : null; + const [quotaRefreshing, setQuotaRefreshing] = useState(false); const handleRefreshQuota = useCallback(async () => { @@ -465,6 +485,30 @@ export function AccountCard({ account, index, onDelete, proxies, onProxyChange,
)} + {/* Credit balance (Pro / PAYG / Team — accounts with has_credits=true). */} + {showCredits && ( +
+ + {t("creditsBalance")} + + {creditsInfo!.unlimited ? ( + {t("creditsUnlimited")} + ) : ( + + {formatCredits(creditsInfo!.balance)} + {creditUsd != null && ( + + ({formatUsd(creditUsd)}) + + )} + {creditsInfo!.overage_limit_reached && ( + · {t("creditsOverageReached")} + )} + + )} +
+ )} + {/* Review quota window */} {rrl && (
diff --git a/web/src/components/PoolOverview.tsx b/web/src/components/PoolOverview.tsx new file mode 100644 index 00000000..fe1d4be7 --- /dev/null +++ b/web/src/components/PoolOverview.tsx @@ -0,0 +1,115 @@ +import { useMemo } from "preact/hooks"; +import { useT, useI18n } from "../../../shared/i18n/context"; +import { creditsToUsd, formatCredits, formatResetTime, formatUsd } from "../../../shared/utils/format"; +import type { Account } from "../../../shared/types"; +import { derivedStatus } from "../lib/accountStatus"; + +/** Default credit→USD rate matching the config schema default. */ +const DEFAULT_CREDITS_PER_USD = 25; + +interface PoolOverviewProps { + accounts: Account[]; +} + +export interface PoolStats { + active: number; + exhausted: number; + totalCredits: number; + totalUsd: number | null; + hasAnyCredits: boolean; + topUsage: { account: Account; pct: number; resetAt: number | null } | null; +} + +export function computePoolStats(accounts: Account[]): PoolStats { + let active = 0; + let exhausted = 0; + let totalCredits = 0; + let hasAnyCredits = false; + let topUsage: PoolStats["topUsage"] = null; + + for (const account of accounts) { + const status = derivedStatus(account); + if (status === "active") active += 1; + if (status === "quota_exhausted" || status === "rate_limited") exhausted += 1; + + const credits = account.quota?.credits; + if (credits?.has_credits || credits?.unlimited) { + hasAnyCredits = true; + if (!credits.unlimited && Number.isFinite(credits.balance)) { + totalCredits += credits.balance; + } + } + + const srl = account.quota?.secondary_rate_limit; + const pct = srl?.limit_reached + ? 100 + : srl?.used_percent != null + ? Math.round(srl.used_percent) + : null; + if (pct != null && (topUsage == null || pct > topUsage.pct)) { + topUsage = { account, pct, resetAt: srl?.reset_at ?? null }; + } + } + + const totalUsd = hasAnyCredits ? creditsToUsd(totalCredits, DEFAULT_CREDITS_PER_USD) : null; + return { active, exhausted, totalCredits, totalUsd, hasAnyCredits, topUsage }; +} + +export function PoolOverview({ accounts }: PoolOverviewProps) { + const t = useT(); + const { lang } = useI18n(); + const stats = useMemo(() => computePoolStats(accounts), [accounts]); + + if (accounts.length === 0) return null; + + return ( +
+
+

{t("poolOverview")}

+ + {accounts.length} + +
+ +
+
+
{t("poolActiveAccounts")}
+
{stats.active}
+
+
+
{t("poolExhaustedAccounts")}
+
0 ? "text-amber-600 dark:text-amber-500" : "text-slate-400 dark:text-text-dim"}`}> + {stats.exhausted} +
+
+ {stats.hasAnyCredits && ( +
+
{t("poolTotalCredits")}
+
+ {formatCredits(stats.totalCredits)} + {stats.totalUsd != null && ( + + ({formatUsd(stats.totalUsd)}) + + )} +
+
+ )} + {stats.topUsage && ( +
+
{t("poolTopUsage")}
+
+ {stats.topUsage.account.email || stats.topUsage.account.id} +
+
+ {stats.topUsage.pct}%{stats.topUsage.resetAt ? ` · ${formatResetTime(stats.topUsage.resetAt, lang === "zh")}` : ""} +
+
+ )} +
+
+ ); +} From 380331be617c32d994c62740235cab067dfe285f Mon Sep 17 00:00:00 2001 From: icebear0828 Date: Sat, 16 May 2026 14:33:04 -0700 Subject: [PATCH 2/3] fix(accounts): persist fresh quota on GET /quota and chain dashboard refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/routes/accounts.ts | 9 ++++++++- web/src/components/AccountList.tsx | 11 ++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/routes/accounts.ts b/src/routes/accounts.ts index 3326e269..40fd4422 100644 --- a/src/routes/accounts.ts +++ b/src/routes/accounts.ts @@ -187,7 +187,14 @@ export function createAccountRoutes(pool: AccountPool, scheduler: RefreshSchedul if (entry.status !== "active") { c.status(409); return c.json({ error: `Account is ${entry.status}, cannot query quota` }); } try { const usage = await new CodexApi(entry.token, entry.accountId, cookieJar, id, proxyPool?.resolveProxyUrl(id)).getUsage(); - return c.json({ quota: toQuota(usage), raw: usage }); + const quota = toQuota(usage); + // Persist the fresh quota so the dashboard reflects upstream reality + // (especially after OpenAI does a window reset / promo refresh) without + // waiting for the next proxied /codex/responses request to passively + // refill cachedQuota via response headers. Also the only path that + // carries the credits block — the header path doesn't include it. + pool.updateCachedQuota(id, quota); + return c.json({ quota, raw: usage }); } catch (err) { // Auto-mark invalidated/banned accounts if (isTokenInvalidError(err)) { diff --git a/web/src/components/AccountList.tsx b/web/src/components/AccountList.tsx index eb043e09..21a66304 100644 --- a/web/src/components/AccountList.tsx +++ b/web/src/components/AccountList.tsx @@ -389,7 +389,16 @@ export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing
) : ( displayAccounts.slice(0, visibleCount).map((acct, i) => ( - { await fetch(`/auth/accounts/${encodeURIComponent(id)}/refresh`, { method: "POST" }); onRefresh(); }} onToggleStatus={onToggleStatus} onUpdateLabel={onUpdateLabel} /> + { + // Two-step refresh: probe (token + status) then quota (cachedQuota write-back). + // The /quota endpoint is what surfaces upstream window resets and the + // credits block on Pro / PAYG accounts — without it, "Refresh" only + // re-validates the access token and the on-screen quota stays stale. + const encoded = encodeURIComponent(id); + await fetch(`/auth/accounts/${encoded}/refresh`, { method: "POST" }); + await fetch(`/auth/accounts/${encoded}/quota`).catch(() => undefined); + onRefresh(); + }} onToggleStatus={onToggleStatus} onUpdateLabel={onUpdateLabel} /> )) )} From 4311a9c8825660e3be18ef1230d70994c8a28e89 Mon Sep 17 00:00:00 2001 From: icebear0828 Date: Sat, 16 May 2026 14:34:27 -0700 Subject: [PATCH 3/3] docs(changelog): note dashboard refresh quota write-back fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecac5800..26085c23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ ### Fixed +- Dashboard 的"刷新"按钮现在真正刷新账号 quota:`GET /auth/accounts/:id/quota` 此前只把 `/codex/usage` 结果返回给 caller、不写回 `pool.cachedQuota`;且前端 AccountCard 的 Refresh 按钮只 POST 了 `/auth/accounts/:id/refresh`(仅刷新 access token,不动 quota)。所以当 OpenAI 做 promo / window grant 把 `secondary_window.used_percent` 重置为 0% 时,proxy 仍然显示重置前的 98–100%,要等下一次真实 `/codex/responses` 请求才被动靠 `x-codex-*` 响应头追上。同一条死路也让 Pro / PAYG 账号 `credits` 块永远进不了 cachedQuota(header path 根本不带 credits)。修复:`/auth/accounts/:id/quota` 拉取上游后立刻 `pool.updateCachedQuota(id, toQuota(usage))` 写回;前端 `AccountList.onRefreshQuota` 在 `/refresh` 后串联 `GET /quota`,UI 点击一次 Refresh 就把 token 和 quota 同时拉新(`src/routes/accounts.ts`、`web/src/components/AccountList.tsx`) - `/v1/responses` passthrough streaming / non-streaming paths now collect `function_call.call_id` from `response.output_item.done` and forward it through response metadata so implicit resume can validate following `function_call_output` turns instead of falling back to full-history replay. Oversized missing-tool-call replays are guarded with 413, and regression coverage now proves the issue red/green across the Responses format adapter (`src/routes/responses.ts`, `src/routes/shared/proxy-handler.ts`, `tests/unit/routes/responses-passthrough-metadata.test.ts`, `tests/integration/proxy-handler.test.ts`). - Release bump workflows now require runtime file changes in addition to meaningful commit subjects before tagging a beta or stable build. This prevents squash-promotion history divergence from re-counting old dev commits, and prevents workflow/docs/test-only fixes from producing empty Electron releases (`.github/workflows/bump-electron.yml`, `.github/workflows/bump-electron-beta.yml`, `tests/unit/ci/package-boundary.test.ts`). - Release bump workflows now skip the release-notes workflow hotfix subject itself, so promoting the stable-notes CI fix to `master` does not create an empty desktop release on the next scheduled bump (`.github/workflows/bump-electron.yml`, `.github/workflows/bump-electron-beta.yml`, `tests/unit/ci/package-boundary.test.ts`).