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