Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ services:

**周末/体验套餐自动领取 (claim)** —— 默认开启。代理每 5 分钟探测一次官方的限量套餐活动页,上新瞬间自动帮你抢(`claim.enabled: false` 可关闭)。手动抢:`bun run src/index.ts claim`。

**额度显示 (quota)** —— 登录后面板会自动查一次当前套餐的各模型额度(剩余 / 总额、到期时间),之后按 <kbd>r</kbd> 手动刷新。也可以命令行直接查:`bun run src/index.ts quota`(对应 HTTP 接口 `GET /quota`)。注意上游计费网关对频繁查询有限速,所以面板不做定时轮询。

</details>

## 🧮 可用模型
Expand Down
2 changes: 2 additions & 0 deletions README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>r</kbd>. 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.

</details>

## 🧮 Available Models
Expand Down
48 changes: 48 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -399,6 +402,51 @@ async function claimCommand(args: string[]): Promise<void> {
}
}

/**
* `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<void> {
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<void> {
const provider = args[0] as ProviderId | undefined;
const importMode = args.includes("--import");
Expand Down
49 changes: 48 additions & 1 deletion src/tui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -74,6 +75,7 @@ export async function runTui(args: ServeArgs): Promise<void> {
serverError: "",
loginInFlight: false,
loginHint: "",
quota: null as QuotaState | null,
toast: null as { text: string; kind: "ok" | "err" | "info" } | null,
};

Expand Down Expand Up @@ -194,6 +196,7 @@ export async function runTui(args: ServeArgs): Promise<void> {
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,
Expand Down Expand Up @@ -237,9 +240,52 @@ export async function runTui(args: ServeArgs): Promise<void> {
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<void> {
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<void> {
if (state.serverStatus === "running" || state.serverStatus === "starting") return;
Expand Down Expand Up @@ -532,6 +578,7 @@ export async function runTui(args: ServeArgs): Promise<void> {
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;
Expand Down
117 changes: 117 additions & 0 deletions src/tui/frame.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function baseState(overrides: Partial<FrameState> = {}): FrameState {
modelCount: 6,
responsesEnabled: true,
claimAuto: false,
quota: null,
logTotal: 0,
logView: [],
logFollowing: true,
Expand Down Expand Up @@ -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);
}
});
});
Loading