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) {