From bc4f573bc3b4a6ce5543e078f9b25210db6dfb15 Mon Sep 17 00:00:00 2001 From: sakmist Date: Wed, 30 Sep 2026 00:24:28 +0800 Subject: [PATCH] feat(quota): show plan balances in the TUI panel and a `quota` CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /quota JSON endpoint existed but nothing surfaced it — neither the TUI panel nor the CLI showed remaining plan quota. Add a Quota card to the TUI (per-model remaining/total + expiry, `r` key or card button to refresh, fetched once automatically after login) and a `zcode-proxy quota` subcommand that prints balances, claimable plans and upstream errors. Both surfaces reuse collectQuotaSnapshot, so no new upstream request path is introduced. The card sizes its row count to the terminal height so it cannot push the Logs card or footer off-screen, and refreshes only on demand — the billing gateway rate-limits frequent queries. --- README.md | 2 + README_EN.md | 2 + src/index.ts | 48 +++++++++++++++++ src/tui/app.ts | 49 +++++++++++++++++- src/tui/frame.test.ts | 117 ++++++++++++++++++++++++++++++++++++++++++ src/tui/frame.ts | 97 +++++++++++++++++++++++++++++++++- 6 files changed, 313 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a3c2b8c..afad34e 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,8 @@ services: **周末/体验套餐自动领取 (claim)** —— 默认开启。代理每 5 分钟探测一次官方的限量套餐活动页,上新瞬间自动帮你抢(`claim.enabled: false` 可关闭)。手动抢:`bun run src/index.ts claim`。 +**额度显示 (quota)** —— 登录后面板会自动查一次当前套餐的各模型额度(剩余 / 总额、到期时间),之后按 r 手动刷新。也可以命令行直接查:`bun run src/index.ts quota`(对应 HTTP 接口 `GET /quota`)。注意上游计费网关对频繁查询有限速,所以面板不做定时轮询。 + ## 🧮 可用模型 diff --git a/README_EN.md b/README_EN.md index fd349c5..60e306b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -193,6 +193,8 @@ The plan type (`plan`: `coding-plan` personal / `start-plan` trial) can be toggl **Weekend/trial plan auto-claiming (claim)** — enabled by default. The proxy probes the official limited-plan campaign page every 5 minutes and grabs new drops for you the instant they appear (`claim.enabled: false` to disable). Manual run: `bun run src/index.ts claim`. +**Quota display (quota)** — after login the panel fetches the plan's per-model balances (remaining / total units, expiry) once automatically; refresh manually with r. CLI: `bun run src/index.ts quota` (HTTP: `GET /quota`). The upstream billing gateway rate-limits frequent queries, so the panel does not poll on a timer. + ## 🧮 Available Models diff --git a/src/index.ts b/src/index.ts index 7dae4d7..d945490 100644 --- a/src/index.ts +++ b/src/index.ts @@ -91,6 +91,8 @@ function dispatchCli(args: string[]): void { authCommand(args.slice(1)); } else if (cmd === "claim") { void claimCommand(args.slice(1)); + } else if (cmd === "quota") { + void quotaCommand(); } else if (cmd === "android") { // Explicit catch: an async startup failure (e.g. control port already // bound by an orphaned process) must exit non-zero deterministically, not @@ -148,6 +150,7 @@ Usage: zcode-proxy auth logout Clear stored credentials zcode-proxy auth status Show current authentication state zcode-proxy claim [list|now] List / claim weekend-plan trial packages + zcode-proxy quota Show plan quota (per-model remaining/total) zcode-proxy version Show version zcode-proxy help Show this help @@ -399,6 +402,51 @@ async function claimCommand(args: string[]): Promise { } } +/** + * `quota` subcommand — print the live plan balance snapshot (per-model + * remaining/total units + expiry) from the billing control plane. Reuses + * collectQuotaSnapshot (same path GET /quota serves). + */ +async function quotaCommand(): Promise { + const path = process.env.ZCODE_PROXY_CONFIG ?? "config.yaml"; + if (!existsSync(path)) { + console.error(`Config file not found: ${path} (run serve once or create it).`); + process.exit(1); + } + ensureDeviceMidInConfig(path); + const config = loadConfig(path); + try { + const { collectQuotaSnapshot } = await import("./server/routes-quota.js"); + const snap = await collectQuotaSnapshot(config); + if (snap.balances.length === 0) { + console.log("No balance windows reported by the billing endpoint."); + } + for (const b of snap.balances) { + const exp = b.expiresAt ? ` · expires ${fmtQuotaExpiry(b.expiresAt)}` : ""; + console.log(` ${b.showName || "(unnamed)"}: ${b.remainingUnits.toLocaleString("en-US")} / ${b.totalUnits.toLocaleString("en-US")} units${exp}`); + } + for (const plan of snap.claimablePlans) { + const grants = plan.entitlements + .map((e) => `${e.showName || plan.name}: ${(e.grantUnits ?? 0).toLocaleString("en-US")} ${e.unitType}`) + .join("; "); + console.log(` claimable: ${plan.name}${grants ? ` (${grants})` : ""}`); + } + for (const err of snap.errors) console.error(` ⚠ ${err}`); + if (snap.errors.length > 0) process.exitCode = 1; + } catch (err) { + console.error(`quota query failed: ${(err as Error).message}`); + process.exit(1); + } +} + +/** `YYYY-MM-DD HH:mm` local time; `expiresAt` may be seconds or milliseconds. */ +function fmtQuotaExpiry(expiresAt: number): string { + const d = new Date(expiresAt > 1e12 ? expiresAt : expiresAt * 1000); + if (Number.isNaN(d.getTime())) return String(expiresAt); + const pad = (n: number): string => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + async function authLogin(args: string[]): Promise { const provider = args[0] as ProviderId | undefined; const importMode = args.includes("--import"); diff --git a/src/tui/app.ts b/src/tui/app.ts index f3d9286..52a72cd 100644 --- a/src/tui/app.ts +++ b/src/tui/app.ts @@ -24,12 +24,13 @@ import { openBrowser } from "../runtime/open-browser.js"; import { pasteLoginInstructions, readPastedLine, boldIfTTY } from "../runtime/paste-login.js"; import { isGuestOriginError, describeGuestError } from "../runtime/guest-error.js"; import { ensureDeviceMidInConfig, VERSION, type ServeArgs } from "../index.js"; +import { collectQuotaSnapshot, type QuotaSnapshot } from "../server/routes-quota.js"; import { appendFileSync } from "node:fs"; import type { ProxyConfig } from "../config/types.js"; import type { ProviderId } from "../provider/types.js"; import { LogPane, type LogLevel } from "./log-pane.js"; import { KeyParser, type KeyAction } from "./keys.js"; -import { buildFrame, findRegion, type ClickAction, type ClickRegion, type Frame } from "./frame.js"; +import { buildFrame, findRegion, type ClickAction, type ClickRegion, type Frame, type QuotaState } from "./frame.js"; type PlanTier = "coding-plan" | "start-plan"; type ServerStatus = "stopped" | "starting" | "running" | "error"; @@ -74,6 +75,7 @@ export async function runTui(args: ServeArgs): Promise { serverError: "", loginInFlight: false, loginHint: "", + quota: null as QuotaState | null, toast: null as { text: string; kind: "ok" | "err" | "info" } | null, }; @@ -194,6 +196,7 @@ export async function runTui(args: ServeArgs): Promise { modelCount: config.models.length, responsesEnabled: config.responses.enabled, claimAuto: config.claim.enabled && config.claim.auto, + quota: state.quota, logTotal: view.total, logView: view.lines, logFollowing: pane.following, @@ -237,9 +240,52 @@ export async function runTui(args: ServeArgs): Promise { const cred = await loadCredential().catch(() => null); state.loggedIn = cred != null; state.apiKeyPreview = cred ? `${cred.apiKey.slice(0, 8)}…` : ""; + // Logout hides the quota card (the billing calls need the JWT). + if (!state.loggedIn) state.quota = null; + else if (!state.quota) void refreshQuota(); scheduleRender(); } + // --- quota (billing balance windows; manual refresh only — the billing + // gateway rate-limits frequent queries, so no polling timer) --------------- + let quotaFetchInFlight = false; + async function refreshQuota(): Promise { + if (quotaFetchInFlight) return; + quotaFetchInFlight = true; + const firstFetch = state.quota == null; + state.quota = { status: "loading", balances: [], errors: [], error: "", fetchedAt: Date.now() }; + scheduleRender(); + try { + const snap: QuotaSnapshot = await collectQuotaSnapshot(config); + state.quota = { + status: "ok", + balances: snap.balances.map((b) => ({ + showName: b.showName, + remainingUnits: b.remainingUnits, + totalUnits: b.totalUnits, + ...(b.expiresAt !== undefined ? { expiresAt: b.expiresAt } : {}), + })), + errors: snap.errors, + error: "", + fetchedAt: Date.now(), + }; + } catch (err) { + // Replace the card content with the error — stale balances shown next to + // a failure would read as current numbers. + state.quota = { + status: "error", + balances: [], + errors: [], + error: (err as Error).message, + fetchedAt: Date.now(), + }; + if (!firstFetch) setToast(`quota refresh failed: ${(err as Error).message}`, "err"); + } finally { + quotaFetchInFlight = false; + scheduleRender(); + } + } + // --- proxy lifecycle (mirrors the Android startProxy/stopProxy hooks) ---- async function startProxy(): Promise { if (state.serverStatus === "running" || state.serverStatus === "starting") return; @@ -532,6 +578,7 @@ export async function runTui(args: ServeArgs): Promise { case "s": toggleProxy(); return; case "l": void startLogin(); return; case "o": void logout(); return; + case "r": void refreshQuota(); return; case "p": switchProvider(); return; case "t": switchPlan(); return; case "c": pane.clear(); scheduleRender(); return; diff --git a/src/tui/frame.test.ts b/src/tui/frame.test.ts index cc8ffa0..33ac1e6 100644 --- a/src/tui/frame.test.ts +++ b/src/tui/frame.test.ts @@ -18,6 +18,7 @@ function baseState(overrides: Partial = {}): FrameState { modelCount: 6, responsesEnabled: true, claimAuto: false, + quota: null, logTotal: 0, logView: [], logFollowing: true, @@ -238,4 +239,120 @@ describe("buildFrame", () => { expect(frame).toContain("terminal too small"); expect(frame).not.toContain("╭"); }); + + test("quota card hidden when quota is null (logged out)", () => { + const text = plainLines(baseState({ quota: null })).join("\n"); + expect(text).not.toContain("Quota"); + }); + + test("quota card shows per-model remaining/total with expiry", () => { + const text = plainLines(baseState({ + quota: { + status: "ok", + balances: [ + { showName: "GLM-5.3", remainingUnits: 2000000, totalUnits: 3000000, expiresAt: 1767225600 }, + { showName: "GLM Coding", remainingUnits: 5, totalUnits: 100 }, + ], + errors: [], + error: "", + fetchedAt: new Date("2026-09-29T12:00:00").getTime(), + }, + })).join("\n"); + expect(text).toContain("Quota"); + expect(text).toContain("GLM-5.3"); + expect(text).toContain("2,000,000 / 3,000,000"); + expect(text).toContain("GLM Coding"); + expect(text).toContain("5 / 100"); + expect(text).toContain("12:00:00"); + expect(text).toContain(" Refresh "); + }); + + test("quota card caps balance rows to keep the Logs card room", () => { + const balances = Array.from({ length: 8 }, (_, i) => ({ + showName: `Model ${i}`, + remainingUnits: i, + totalUnits: 100, + })); + const text = plainLines(baseState({ + quota: { status: "ok", balances, errors: [], error: "", fetchedAt: Date.now() }, + })).join("\n"); + expect(text).toContain("Model 3"); + expect(text).not.toContain("Model 4"); + }); + + test("quota card shows error state and upstream warnings", () => { + const text = plainLines(baseState({ + quota: { + status: "error", + balances: [], + errors: [], + error: "not logged in — no JWT credential", + fetchedAt: Date.now(), + }, + })).join("\n"); + expect(text).toContain("unavailable"); + expect(text).toContain("not logged in"); + const warned = plainLines(baseState({ + quota: { status: "ok", balances: [], errors: ["balance: 3012 risk"], error: "", fetchedAt: Date.now() }, + })).join("\n"); + expect(warned).toContain("⚠"); + expect(warned).toContain("3012"); + }); + + test("quota Refresh button is clickable and footer advertises [r]", () => { + const f = buildFrame(baseState({ + quota: { status: "ok", balances: [], errors: [], error: "", fetchedAt: Date.now() }, + })); + const actions = f.regions.map((r) => r.action); + expect(actions).toContainEqual({ kind: "key", key: "r" }); + // The footer drops its tail on 80 columns (like [p]/[t]/[c]); [r] shows + // only on wider terminals. + const wide = buildFrame(baseState({ + width: 140, + quota: { status: "ok", balances: [], errors: [], error: "", fetchedAt: Date.now() }, + })); + const lines = wide.text.split("\n").map((l) => stripAnsi(l.replace(/\x1b\[K$/, ""))); + expect(lines.join("\n")).toContain("[r] quota refresh"); + }); + + test("quota card hides on short terminals so the Logs card keeps room", () => { + const quota = { status: "ok" as const, balances: [{ showName: "GLM-5.3", remainingUnits: 1, totalUnits: 2 }], errors: [], error: "", fetchedAt: Date.now() }; + const text = plainLines(baseState({ height: 21, quota })).join("\n"); + expect(text).not.toContain("Quota"); + const textTall = plainLines(baseState({ height: 22, quota })).join("\n"); + expect(textTall).toContain("Quota"); + }); + + test("quota card never pushes the frame past the terminal height", () => { + for (const height of [22, 24, 30, 40]) { + const state = baseState({ + height, + logTotal: 50, + logView: Array.from({ length: 50 }, (_, i) => ({ level: "info", text: `log ${i}` })), + quota: { + status: "ok", + balances: Array.from({ length: 6 }, (_, i) => ({ showName: `Model ${i}`, remainingUnits: i, totalUnits: 100 })), + errors: [], + error: "", + fetchedAt: Date.now(), + }, + }); + expect(plainLines(state).length).toBe(height); + } + }); + + test("no line exceeds the terminal width with a long quota showName", () => { + const state = baseState({ + quota: { + status: "ok", + balances: [{ showName: "超长模型名称测试超长模型名称测试超长模型名称", remainingUnits: 1234567890, totalUnits: 9876543210, expiresAt: 1735689600 }], + errors: [], + error: "", + fetchedAt: Date.now(), + }, + }); + for (const raw of buildFrame(state).text.split("\n")) { + expect(displayWidth(raw)).toBeLessThanOrEqual(state.width); + } + }); }); diff --git a/src/tui/frame.ts b/src/tui/frame.ts index 38ba94e..5aa72ee 100644 --- a/src/tui/frame.ts +++ b/src/tui/frame.ts @@ -43,6 +43,29 @@ export function findRegion(regions: readonly ClickRegion[], row: number, col: nu return null; } +/** One per-model balance window shown as a row in the Quota card. */ +export interface QuotaBalanceRow { + showName: string; + remainingUnits: number; + totalUnits: number; + expiresAt?: number; +} + +/** + * Quota card view-state; `null` hides the card entirely (logged out / never + * fetched). `status: "error"` carries `error` (the fetch threw before any + * snapshot existed); `errors` holds per-endpoint upstream failures that rode + * along on an otherwise-OK snapshot. + */ +export interface QuotaState { + status: "loading" | "ok" | "error"; + balances: QuotaBalanceRow[]; + errors: string[]; + error: string; + /** Epoch ms of the last fetch attempt — rendered as the card pill clock. */ + fetchedAt: number; +} + export interface FrameState { version: string; configPath: string; @@ -58,6 +81,7 @@ export interface FrameState { modelCount: number; responsesEnabled: boolean; claimAuto: boolean; + quota: QuotaState | null; logTotal: number; logView: ReadonlyArray<{ level: string; text: string }>; logFollowing: boolean; @@ -216,6 +240,28 @@ function logLineCode(level: string): string | undefined { return undefined; } +/** `1,234,567` — full precision; quota units are the contract, not noise. */ +function fmtUnits(n: number): string { + return n.toLocaleString("en-US"); +} + +/** Compact `MM-DD` (or `YYYY-MM-DD` in another year); `""` when unparseable. */ +function fmtExpiry(expiresAt: number): string { + const ms = expiresAt > 1e12 ? expiresAt : expiresAt * 1000; + const d = new Date(ms); + if (Number.isNaN(d.getTime())) return ""; + const mmdd = `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + return d.getFullYear() === new Date().getFullYear() ? mmdd : `${d.getFullYear()}-${mmdd}`; +} + +function fmtClock(ms: number): string { + const d = new Date(ms); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`; +} + +/** At most this many per-model balance rows — the card must stay bounded so the Logs card keeps room on short terminals. */ +const MAX_QUOTA_ROWS = 4; + export function buildFrame(s: FrameState): Frame { const w = s.width; const h = s.height; @@ -303,6 +349,55 @@ export function buildFrame(s: FrameState): Frame { emit(renderBottomBorder(w)); emit(""); + // --- Quota card (auto-hidden when logged out / terminal too short) -------- + // Height budget: everything above + the Logs card's borders/one row + the + // footer needs 21 rows, and each balance row costs one more — so on a 24-row + // terminal the card shows at most 3 models instead of pushing the footer off + // the screen. + const quotaRows = s.quota ? Math.min(MAX_QUOTA_ROWS, h - 21) : 0; + if (s.quota && quotaRows >= 1) { + const pill: Seg[] = + s.quota.status === "loading" + ? [{ t: "refreshing…", c: AMBER }] + : s.quota.status === "error" + ? [{ t: "unavailable", c: RED }] + : [{ t: fmtClock(s.quota.fetchedAt), c: DIM }]; + emit(renderTopBorder(w, [{ t: "Quota", c: BOLD }], pill)); + + if (s.quota.status === "loading") { + emit((composeRow(w, lines.length, "Balances", [{ t: "refreshing…", c: DIM }])).line); + } else if (s.quota.status === "error") { + emit((composeRow(w, lines.length, "Balances", [{ t: truncateToWidth(s.quota.error, w - 22), c: RED }])).line); + } else if (s.quota.balances.length === 0) { + emit((composeRow(w, lines.length, "Balances", [{ t: "(no balance windows — run 'zcode-proxy quota')", c: DIM }])).line); + } else { + for (const [i, b] of s.quota.balances.slice(0, quotaRows).entries()) { + const exp = b.expiresAt ? fmtExpiry(b.expiresAt) : ""; + const row = composeRow(w, lines.length, i === 0 ? "Balances" : "", [ + { t: truncateToWidth(b.showName || "(unnamed)", 16), c: CYAN }, + { t: " " }, + { t: `${fmtUnits(b.remainingUnits)} / ${fmtUnits(b.totalUnits)}`, c: GREEN }, + ...(exp ? [{ t: ` · exp ${exp}` as string, c: DIM }] : []), + ]); + emit(row.line); + regions.push(...row.regions); + } + } + + const hiddenModels = Math.max(0, s.quota.balances.length - quotaRows); + const refreshRow = composeRow(w, lines.length, "", [ + { t: " Refresh ", c: BTN_GRAY, action: { kind: "key", key: "r" } }, + ...(hiddenModels > 0 ? [{ t: ` +${hiddenModels} more (zcode-proxy quota)` as string, c: DIM }] : []), + ...(s.quota.errors.length > 0 + ? [{ t: ` ⚠ ${truncateToWidth(s.quota.errors[0] ?? "", Math.max(0, w - 30))}` as string, c: AMBER }] + : []), + ]); + emit(refreshRow.line); + regions.push(...refreshRow.regions); + emit(renderBottomBorder(w)); + emit(""); + } + // --- Proxy Server card -------------------------------------------------- emit(renderTopBorder(w, [{ t: "Proxy Server", c: BOLD }])); @@ -372,7 +467,7 @@ export function buildFrame(s: FrameState): Frame { // narrow terminals, so the primary actions must come first. const footerItems: Array<[string, string]> = [ ["s", "start/stop"], ["l", "login"], ["o", "logout"], ["g", "follow"], ["q", "quit"], - ["p", "provider"], ["t", "plan"], ["c", "clear"], + ["p", "provider"], ["t", "plan"], ["r", "quota refresh"], ["c", "clear"], ]; footerParts.push({ t: "↑↓ scroll", c: DIM }); for (const [key, label] of footerItems) {