diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bbae11f..a5325b35 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`) - 第三方 API key 增加 capability 分层与 OpenAI-compatible embeddings 代理:旧 key 自动按 `chat` 迁移,新增/导入/导出支持 `capabilities`,`/v1/embeddings` 只使用显式标记 `embeddings` 的 OpenAI/OpenRouter/custom key 并直连上游 `/embeddings`;Dashboard API Keys 表单新增 Chat / Embeddings 复选框和手动模型输入,避免静态 catalog 卡住 embedding/custom model。启动时无持久 key 也会创建 runtime router,后续在面板添加的 key 无需重启即可参与 chat 直连;chat 直连模型在配置 `proxy_api_key` 时补齐代理 key 校验。新增单元、前端表单和真实 OpenRouter embeddings 3 连 E2E 验证(`src/auth/api-key-pool.ts`、`src/routes/api-keys.ts`、`src/routes/embeddings.ts`、`src/proxy/upstream-router-bootstrap.ts`、`src/routes/chat.ts`、`web/src/components/ApiKeyManager.tsx`、`tests/unit/routes/embeddings.test.ts`、`web/src/components/ApiKeyManager.test.tsx`) - Account transfer compatibility with Cockpit Tools / Sub2API / CPA: normalized quota windows now include `remaining_percent` while preserving `used_percent`; successful `GET /auth/accounts/:id/quota` calls update cached quota; account import accepts Cockpit Tools portable objects, direct `accessToken` / `refresh_token`, Sub2API exports, raw arrays, and `text/plain` token lines; account export adds `format=cockpit_tools|sub2api|cpa` alongside existing `full|minimal`; Dashboard import/export now sends JSON or token-line files without forcing `{ accounts: [...] }` and exposes the export format selector. Docs now record the `/backend-api/wham/usage` active quota path and compatibility formats (`src/auth/quota-utils.ts`, `src/proxy/rate-limit-headers.ts`, `src/routes/accounts.ts`, `src/services/account-transfer-formats.ts`, `shared/account-transfer-client.ts`, `shared/hooks/use-accounts.ts`, `web/src/components/AccountImportExport.tsx`, `docs/architecture/auth/quota.md`, `docs/architecture/auth/import-export.md`, `docs/todo/cockpit-tools-quota-import-export.md`). - 支持 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`) @@ -21,6 +22,7 @@ ### Fixed +- Dashboard 的"刷新配额"按钮现在真正刷新账号 quota:`GET /auth/accounts/:id/quota` 此前只把 `/codex/usage` 结果返回给 caller、不写回 `pool.cachedQuota`。所以当 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` 直接调用单账号 `GET /quota`,失败时输出 warning 后刷新列表缓存(`src/routes/accounts.ts`、`web/src/components/AccountList.tsx`) - `AccountRegistry.isAuthenticated()` 现在尊重 `quota.skip_exhausted` 配置:此前不论该开关如何配置,`isAuthenticated` 都会把 `cachedQuota` 已耗尽的活跃账号一律视作不可用,导致 `quota.skip_exhausted=false` 的部署在所有号都 `limit_reached=true` 但仍可被 `AccountLifecycle.acquire()` 取走的情况下,被 `accountPool.isAuthenticated()` 守门的路由(`/v1/chat/completions` / `/v1/messages` / `/v1/responses` / model 列表 / health)全部 401。现在 `isAuthenticated` 与 `hasAvailableAccounts` 用同一套规则:`skipExhausted ? !hasReachedCachedQuota(entry) : true`。配置默认值不变(默认仍跳过)。新增 5 个单测覆盖空池 / 默认 skip / 默认非 skip / `skip_exhausted=false` 配额耗尽路径 / disabled 账号 0 容忍(`src/auth/account-registry.ts`、`tests/unit/auth/account-pool-has-available.test.ts`) - Claude Code 2.1.84 计费头 strip 行为新增 rotation 变体回归覆盖:实测 Claude Code 把 `x-anthropic-billing-header: cc_version=...; cc_entrypoint=cli; cch=<5hex>;` 作为 `system[0]` 独立块下发,`cc_version` 后缀和 `cch` 每请求 reroll;新增 `it.each` 覆盖 5 个真实 `cc_version` 后缀(c8e / 76b / f51 / 5b4 / 4f3)与"两次不同 cch 产出同一份 `instructions`"的不变性断言,防止后续改 strip(如改 `startsWith` → 正则、或加 inline 清洗)意外让 `cch` 漏进 `instructions` 污染上游 prompt cache(`tests/unit/translation/anthropic-to-codex.test.ts`) - Dashboard Errors tab now has a real clear action: `DELETE /admin/error-logs` removes current + backup JSONL files and the read cursor so repeated `StreamUpstreamPrematureClose` groups can be cleared from the page instead of only marked read. Anthropic setup defaults now map Opus 4.7 → `gpt-5.5` and Sonnet 4.6 → `gpt-5.4`, and the built-in Anthropic API-key catalog lists Claude Opus 4.7 (`src/logs/error-log.ts`, `src/routes/admin/error-logs.ts`, `shared/hooks/use-error-logs.ts`, `web/src/pages/ErrorsPage.tsx`, `web/src/components/AnthropicSetup.tsx`, `src/auth/api-key-catalog.ts`). diff --git a/shared/i18n/translations.ts b/shared/i18n/translations.ts index aead73b5..4bbddf02 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", @@ -440,6 +448,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 11c250c4..29cff77c 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -6,6 +6,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 { plan_type?: string; rate_limit?: AccountQuotaWindow; @@ -17,6 +25,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 a4f904e7..1276bb59 100644 --- a/src/auth/account-registry.ts +++ b/src/auth/account-registry.ts @@ -460,7 +460,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 == null && 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 1d7b5006..fc033c69 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 remainingPercent(used: number | null | undefined): number | null { if (typeof used !== "number" || !Number.isFinite(used)) return null; @@ -86,5 +102,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 b20f444e..a2def7b7 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -99,6 +99,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/wham/usage or /backend-api/codex/usage. */ export interface CodexQuota { plan_type: string; @@ -118,6 +127,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("preserves existing credits when new quota explicitly carries null credits", () => { + const id = pool.addAccount(createValidJwt({ accountId: "credits-null", planType: "pro" })); + pool.updateCachedQuota(id, makeQuota({ + credits: { has_credits: true, unlimited: false, overage_limit_reached: false, balance: 99.5 }, + })); + pool.updateCachedQuota(id, makeQuota({ + credits: null, + rate_limit: { + allowed: true, + limit_reached: false, + used_percent: 70, + 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(70); + }); + + 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 19cd749a..3b14b5f0 100644 --- a/tests/unit/auth/quota-utils.test.ts +++ b/tests/unit/auth/quota-utils.test.ts @@ -235,4 +235,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 f105a64c..6566384f 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/account-list-quota-refresh.test.ts b/tests/unit/web/account-list-quota-refresh.test.ts index 6e8e9d3c..1a13d170 100644 --- a/tests/unit/web/account-list-quota-refresh.test.ts +++ b/tests/unit/web/account-list-quota-refresh.test.ts @@ -9,7 +9,18 @@ describe("AccountList quota refresh", () => { "utf-8", ); - expect(source).toContain("`/auth/accounts/${encodeURIComponent(id)}/quota`"); - expect(source).not.toContain("`/auth/accounts/${encodeURIComponent(id)}/refresh`"); + expect(source).toContain("`/auth/accounts/${encoded}/quota`"); + expect(source).toContain("console.warn"); + expect(source).not.toContain("`/auth/accounts/${encoded}/refresh`"); + }); + + it("does not request bulk fresh quota from the account list hook", () => { + const source = readFileSync( + resolve(__dirname, "../../../shared/hooks/use-accounts.ts"), + "utf-8", + ); + + expect(source).toContain("\"/auth/accounts?quota=true\""); + expect(source).not.toContain("quota=fresh"); }); }); 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/AccountList.tsx b/web/src/components/AccountList.tsx index 4f4ef133..881ee0a6 100644 --- a/web/src/components/AccountList.tsx +++ b/web/src/components/AccountList.tsx @@ -390,7 +390,14 @@ export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing
) : ( displayAccounts.slice(0, visibleCount).map((acct, i) => ( - { await fetch(`/auth/accounts/${encodeURIComponent(id)}/quota`); onRefresh(); }} onToggleStatus={onToggleStatus} onUpdateLabel={onUpdateLabel} /> + { + const encoded = encodeURIComponent(id); + const resp = await fetch(`/auth/accounts/${encoded}/quota`); + if (!resp.ok) { + console.warn(`[AccountList] Failed to refresh quota for account ${id}: ${resp.status}`); + } + onRefresh(); + }} onToggleStatus={onToggleStatus} onUpdateLabel={onUpdateLabel} /> )) )} 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")}` : ""} +
+
+ )} +
+
+ ); +}