diff --git a/README.md b/README.md
index afad34e..4fb84ae 100644
--- a/README.md
+++ b/README.md
@@ -192,7 +192,7 @@ services:
**周末/体验套餐自动领取 (claim)** —— 默认开启。代理每 5 分钟探测一次官方的限量套餐活动页,上新瞬间自动帮你抢(`claim.enabled: false` 可关闭)。手动抢:`bun run src/index.ts claim`。
-**额度显示 (quota)** —— 登录后面板会自动查一次当前套餐的各模型额度(剩余 / 总额、到期时间),之后按 r 手动刷新。也可以命令行直接查:`bun run src/index.ts quota`(对应 HTTP 接口 `GET /quota`)。注意上游计费网关对频繁查询有限速,所以面板不做定时轮询。
+**额度显示 (quota)** —— 登录后面板会自动查一次额度,之后按 r 手动刷新。数据来自上游两个额度平面:体验/积分制套餐的积分桶(`billing/balance`,剩余 / 总额、到期时间),以及个人编码套餐的用量窗口(`/api/monitor/usage/quota/limit`,与官方用量面板同源,5 小时 / 周窗口的剩余 / 总量与重置时间)。命令行直接查:`bun run src/index.ts quota`(对应 HTTP 接口 `GET /quota`)。注意上游网关对频繁查询有限速,所以面板不做定时轮询。
diff --git a/README_EN.md b/README_EN.md
index 60e306b..c1e7e5c 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -193,7 +193,7 @@ 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.
+**Quota display (quota)** — after login the panel fetches quota once automatically; refresh manually with r. Data comes from two upstream planes: trial/credits-plan buckets (`billing/balance`, remaining / total units, expiry) and individual coding-plan usage windows (`/api/monitor/usage/quota/limit`, same endpoint the official usage panel reads — 5-hour / weekly window remaining / total and reset time). CLI: `bun run src/index.ts quota` (HTTP: `GET /quota`). The upstream gateways rate-limit frequent queries, so the panel does not poll on a timer.
diff --git a/src/index.ts b/src/index.ts
index d945490..ff6126f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -403,8 +403,8 @@ 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
+ * `quota` subcommand — print the live quota snapshot: per-model credit buckets
+ * (billing plane) and coding-plan usage windows (monitor plane). Reuses
* collectQuotaSnapshot (same path GET /quota serves).
*/
async function quotaCommand(): Promise {
@@ -425,6 +425,23 @@ async function quotaCommand(): Promise {
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}`);
}
+ if (snap.codingPlan) {
+ const { level, limits } = snap.codingPlan;
+ if (limits.length === 0) {
+ console.log("No coding-plan usage windows reported by the monitor endpoint.");
+ }
+ for (const l of limits) {
+ const tier = level ? ` (${level})` : "";
+ // Mirror of the official panel: remaining only — upstream `number` is
+ // not a comparable total (live TIME_LIMIT row: remaining=3894, number=1).
+ const amount =
+ l.remaining !== undefined
+ ? `${l.remaining.toLocaleString("en-US")}${l.unit ? ` ${l.unit}` : ""} remaining`
+ : "no usage numbers reported";
+ const reset = l.nextResetTime !== undefined ? ` · resets ${fmtQuotaExpiry(l.nextResetTime)}` : "";
+ console.log(` coding-plan${tier}: [${l.type}] ${amount}${reset}`);
+ }
+ }
for (const plan of snap.claimablePlans) {
const grants = plan.entitlements
.map((e) => `${e.showName || plan.name}: ${(e.grantUnits ?? 0).toLocaleString("en-US")} ${e.unitType}`)
diff --git a/src/server/routes-quota.test.ts b/src/server/routes-quota.test.ts
index c7cbd9c..88b126f 100644
--- a/src/server/routes-quota.test.ts
+++ b/src/server/routes-quota.test.ts
@@ -66,13 +66,30 @@ interface BillingCall {
headers: Record;
}
-/** Mock fetch that records billing calls and answers both endpoints. */
-function makeBillingFetch(opts: { code?: number; body?: unknown } = {}): { fetchImpl: typeof fetch; calls: BillingCall[] } {
+/** Mock fetch that records billing/monitor calls and answers all three endpoints. */
+function makeBillingFetch(opts: {
+ code?: number;
+ body?: unknown;
+ /** Monitor-plane (coding) envelope data; defaults to an empty limits list. */
+ codingBody?: unknown;
+ codingCode?: number;
+ /** Simulate monitor-endpoint network/HTTP failure. */
+ codingFail?: boolean;
+} = {}): { fetchImpl: typeof fetch; calls: BillingCall[] } {
const calls: BillingCall[] = [];
+ const record = (u: string, init?: RequestInit): void => {
+ calls.push({ url: u, headers: { ...((init?.headers as Record) ?? {}) } });
+ };
const fetchImpl = (async (url: string | URL | Request, init?: RequestInit): Promise => {
const u = typeof url === "string" ? url : url.toString();
+ if (u.includes("/api/monitor/usage/quota/limit")) {
+ record(u, init);
+ if (opts.codingFail) return new Response("upstream exploded", { status: 500 });
+ const code = opts.codingCode ?? 0;
+ return new Response(JSON.stringify({ code, msg: "ok", data: opts.codingBody ?? { level: "max", limits: [] } }), { status: 200 });
+ }
if (u.includes("/api/v1/zcode-plan/billing/")) {
- calls.push({ url: u, headers: { ...((init?.headers as Record) ?? {}) } });
+ record(u, init);
const code = opts.code ?? 0;
return new Response(JSON.stringify({ code, msg: "ok", data: opts.body ?? { server_time: 1720000000, balances: [], plans: [] } }), { status: 200 });
}
@@ -81,6 +98,11 @@ function makeBillingFetch(opts: { code?: number; body?: unknown } = {}): { fetch
return { fetchImpl, calls };
}
+/** Calls to one plane, in request order. */
+function planeCalls(calls: BillingCall[], plane: "billing" | "monitor"): BillingCall[] {
+ return calls.filter((c) => c.url.includes(plane === "billing" ? "/api/v1/zcode-plan/billing/" : "/api/monitor/usage/quota/limit"));
+}
+
/** Set/restore identity env overrides around a test body. */
async function withEnv(overrides: Record, fn: () => Promise): Promise {
const saved: Record = {};
@@ -100,29 +122,38 @@ async function withEnv(overrides: Record, fn: () =>
}
describe("collectQuotaSnapshot fingerprint", () => {
- it("no overrides → real platform/arch, never undefined-undefined", async () => {
+ it("no overrides → real platform/arch on billing calls, raw key auth on coding call", async () => {
await withEnv({ ZCODE_IDENTITY_PLATFORM: undefined, ZCODE_IDENTITY_ARCH: undefined }, async () => {
const { fetchImpl, calls } = makeBillingFetch();
const snap = await collectQuotaSnapshot(makeConfig(), fetchImpl, loadFake);
- expect(calls.length).toBe(2);
+ const billing = planeCalls(calls, "billing");
+ const monitor = planeCalls(calls, "monitor");
+ expect(billing.length).toBe(2);
+ expect(monitor.length).toBe(1);
const expected = `${process.platform}-${os.arch()}`;
expect(snap.errors).toEqual([]);
- for (const c of calls) {
+ for (const c of billing) {
const url = new URL(c.url);
expect(url.searchParams.get("platform")).toBe(expected);
expect(url.searchParams.get("app_version")).toBe("test-1.0.0");
expect(c.headers["X-Platform"]).toBe(expected);
}
- expect(calls[0].url).toContain("/billing/balance?");
- expect(calls[1].url).toContain("/billing/preview?");
+ expect(billing[0].url).toContain("/billing/balance?");
+ expect(billing[1].url).toContain("/billing/preview?");
+ // Coding plane mirror: bundle `wK` origin + `md` single raw-key header.
+ expect(monitor[0].url).toBe("https://api.z.ai/api/monitor/usage/quota/limit");
+ expect(monitor[0].headers["authorization"]).toBe("key-x.secret-y");
+ expect(monitor[0].headers["accept"]).toBe("application/json");
+ expect(monitor[0].headers["X-Platform"]).toBeUndefined();
+ expect(snap.codingPlan).toEqual({ level: "max", limits: [] });
});
});
- it("valid overrides → both billing calls use the overridden fingerprint", async () => {
+ it("valid overrides → billing calls use the overridden fingerprint (coding plane unaffected)", async () => {
await withEnv({ ZCODE_IDENTITY_PLATFORM: "linux", ZCODE_IDENTITY_ARCH: "x64" }, async () => {
const { fetchImpl, calls } = makeBillingFetch();
await collectQuotaSnapshot(makeConfig(), fetchImpl, loadFake);
- for (const c of calls) {
+ for (const c of planeCalls(calls, "billing")) {
const url = new URL(c.url);
expect(url.searchParams.get("platform")).toBe("linux-x64");
expect(c.headers["X-Platform"]).toBe("linux-x64");
@@ -135,7 +166,7 @@ describe("collectQuotaSnapshot fingerprint", () => {
const { fetchImpl, calls } = makeBillingFetch();
await collectQuotaSnapshot(makeConfig(), fetchImpl, loadFake);
const expected = `${process.platform}-${os.arch()}`;
- for (const c of calls) {
+ for (const c of planeCalls(calls, "billing")) {
expect(new URL(c.url).searchParams.get("platform")).toBe(expected);
}
});
@@ -200,3 +231,91 @@ describe("collectQuotaSnapshot response mapping", () => {
expect(snap.balances[1].expiresAt).toBeUndefined();
});
});
+
+describe("collectQuotaSnapshot coding plane", () => {
+ const bigmodelCred: Credential = { apiKey: "bm-key-123", provider: "bigmodel", jwt: makeJwt() };
+ const loadBigmodel = async (): Promise => bigmodelCred;
+ const jwtLessCred: Credential = { apiKey: "key-x.secret-y", provider: "zai" };
+ const loadJwtLess = async (): Promise => jwtLessCred;
+
+ it("maps level + limits (numeric coercion, snake alias, rows without numbers dropped)", async () => {
+ const { fetchImpl } = makeBillingFetch({
+ codingBody: {
+ level: "max",
+ limits: [
+ { type: "TIME_LIMIT", number: 120, usage: 84, remaining: 36, percentage: 70, next_reset_time: "1720000000", unit: "prompt" },
+ { type: "no-numbers" }, // no numeric fields → display noise, dropped
+ { number: 5 }, // no type → dropped (bundle `E_` keeps typed rows only)
+ { type: "WEEK_LIMIT", remaining: 500 },
+ ],
+ },
+ });
+ const snap = await collectQuotaSnapshot(makeConfig(), fetchImpl, loadFake);
+ expect(snap.codingPlan).toEqual({
+ level: "max",
+ limits: [
+ { type: "TIME_LIMIT", unit: "prompt", total: 120, used: 84, remaining: 36, percentage: 70, nextResetTime: 1720000000 },
+ { type: "WEEK_LIMIT", remaining: 500 },
+ ],
+ });
+ });
+
+ it("code 200 envelope counts as success (bundle `$_` semantics)", async () => {
+ const { fetchImpl } = makeBillingFetch({ codingCode: 200, codingBody: { level: "pro", limits: [] } });
+ const snap = await collectQuotaSnapshot(makeConfig(), fetchImpl, loadFake);
+ expect(snap.errors).toEqual([]);
+ expect(snap.codingPlan).toEqual({ level: "pro", limits: [] });
+ });
+
+ it("bigmodel credential → open.bigmodel.cn monitor origin with the raw key", async () => {
+ const { fetchImpl, calls } = makeBillingFetch();
+ await collectQuotaSnapshot(makeConfig({ provider: "bigmodel" }), fetchImpl, loadBigmodel);
+ const monitor = planeCalls(calls, "monitor");
+ expect(monitor[0].url).toBe("https://open.bigmodel.cn/api/monitor/usage/quota/limit");
+ expect(monitor[0].headers["authorization"]).toBe("bm-key-123");
+ });
+
+ it("monitor endpoint failure degrades to codingPlan null + errors entry, credits data intact", async () => {
+ const { fetchImpl } = makeBillingFetch({
+ body: { server_time: 1, balances: [{ show_name: "Free", total_units: 10, remaining_units: 5 }] },
+ codingFail: true,
+ });
+ const snap = await collectQuotaSnapshot(makeConfig(), fetchImpl, loadFake);
+ expect(snap.codingPlan).toBeNull();
+ expect(snap.errors.length).toBe(1);
+ expect(snap.errors[0]).toContain("coding: 500");
+ expect(snap.balances).toHaveLength(1);
+ });
+
+ it("nonzero monitor envelope code surfaces in errors, snapshot still resolves", async () => {
+ const { fetchImpl } = makeBillingFetch({ codingCode: 3012 });
+ const snap = await collectQuotaSnapshot(makeConfig(), fetchImpl, loadFake);
+ expect(snap.codingPlan).toBeNull();
+ expect(snap.errors.length).toBe(1);
+ expect(snap.errors[0]).toContain("coding: 3012");
+ });
+
+ it("jwt-less credential still serves the coding plane (billing skipped with a note)", async () => {
+ const { fetchImpl, calls } = makeBillingFetch({
+ codingBody: { level: null, limits: [{ type: "TIME_LIMIT", remaining: 36, number: 120 }] },
+ });
+ const snap = await collectQuotaSnapshot(makeConfig(), fetchImpl, loadJwtLess);
+ expect(planeCalls(calls, "billing").length).toBe(0);
+ expect(planeCalls(calls, "monitor").length).toBe(1);
+ expect(snap.jwt).toBeNull();
+ expect(snap.balances).toEqual([]);
+ expect(snap.errors.length).toBe(1);
+ expect(snap.errors[0]).toContain("no plan JWT");
+ expect(snap.codingPlan).toEqual({ level: null, limits: [{ type: "TIME_LIMIT", remaining: 36, total: 120 }] });
+ });
+
+ it("unusable coding origin → coding plane silently skipped (fail-open)", async () => {
+ const cfg = makeConfig();
+ cfg.providers.zai.openaiBase = "not a url";
+ const { fetchImpl, calls } = makeBillingFetch();
+ const snap = await collectQuotaSnapshot(cfg, fetchImpl, loadFake);
+ expect(planeCalls(calls, "monitor").length).toBe(0);
+ expect(snap.codingPlan).toBeNull();
+ expect(snap.errors).toEqual([]);
+ });
+});
diff --git a/src/server/routes-quota.ts b/src/server/routes-quota.ts
index d7e4de1..4d12f49 100644
--- a/src/server/routes-quota.ts
+++ b/src/server/routes-quota.ts
@@ -1,18 +1,26 @@
/**
- * GET /quota — live free-quota snapshot from ZCode billing endpoints.
+ * GET /quota — live quota snapshot from both upstream quota planes.
*
- * Queries the same control plane the desktop client uses (`billing/balance` +
- * `billing/preview` on the configured claim origin) with the stored OAuth JWT
- * and the full desktop identity fingerprint. The billing gateway requires a
- * stable `X-Device-Mid`, so the config identity is forwarded unchanged.
+ * Two planes, mirroring the official desktop panel's split (`getSnapshotForRequest`):
+ * - credits plane: `zcode-plan/billing/balance` + `billing/preview` on the
+ * configured claim origin with the stored OAuth plan JWT and the desktop
+ * identity fingerprint (start-plan/trial credit buckets);
+ * - coding plane: `GET {coding origin}/api/monitor/usage/quota/limit` with the
+ * stored coding-plan API key in `authorization` (bundle `wK`/`Z_`+`md`) —
+ * the windowed usage limits of individual coding plans (`data.limits[]`,
+ * `TIME_LIMIT` rows preferred by the official `Xj` picker).
*
- * @see scripts in vibe-coding-labs/zcode-reverse-engineer (header shape) and
- * zcode.z.ai desktop bundle `pio()` (identity header semantics).
+ * The billing gateway requires a stable `X-Device-Mid`, so the config identity
+ * is forwarded unchanged.
+ *
+ * @see _reverse/NOTEPAD.md "coding-plan 用量平面" (chain extracted from
+ * zcode.z.ai desktop bundle `getSnapshotForQuery`/`BigModelUsageQuotaProvider`)
*/
import os from "node:os";
import { loadCredential } from "../auth/store.js";
import { buildIdentityHeaders, normalizePrintableHeaderValue } from "../proxy/identity.js";
import { inspectJwt } from "../auth/jwt-age.js";
+import { credentialString, type Credential } from "../auth/types.js";
import type { ProxyConfig } from "../config/types.js";
import { errorResponse } from "../proxy/handler.js";
@@ -32,6 +40,30 @@ export interface QuotaPlanEntry {
entitlements: Array<{ showName: string; grantUnits: number; unitType: string; effectiveAt?: number }>;
}
+/**
+ * One usage window from the coding-plan monitor plane (`limits[]` entry).
+ * `type` values are server-defined; `TIME_LIMIT` (the 5h/weekly prompt window
+ * of individual coding plans) is the shape the official panel prefers.
+ */
+export interface QuotaCodingLimit {
+ type: string;
+ unit?: string;
+ /** Upstream `number` — window total (undefined = unlimited/unreported). */
+ total?: number;
+ /** Upstream `usage`. */
+ used?: number;
+ remaining?: number;
+ percentage?: number;
+ /** Epoch seconds or milliseconds, as upstream sends it (both seen in the wild). */
+ nextResetTime?: number;
+}
+
+export interface QuotaCodingPlan {
+ /** Plan tier string from upstream (e.g. `"max"`); null when unreported. */
+ level: string | null;
+ limits: QuotaCodingLimit[];
+}
+
export interface QuotaSnapshot {
provider: string;
serverTime: number;
@@ -44,21 +76,26 @@ export interface QuotaSnapshot {
jwt: { ageHours: number; issuedAt: number } | null;
balances: QuotaBalanceEntry[];
claimablePlans: QuotaPlanEntry[];
+ /**
+ * Coding-plan monitor plane snapshot; null when the endpoint failed or was
+ * unreachable — the official panel tolerates the same way (quota: null).
+ */
+ codingPlan: QuotaCodingPlan | null;
errors: string[];
}
-/** Query one billing URL, tolerating per-endpoint failures. */
+/** Query one billing/monitor URL, tolerating per-endpoint failures. */
async function fetchBilling(
origin: string,
path: string,
headers: Record,
fetchImpl: typeof fetch,
-): Promise<{ code?: number; msg?: string; data?: unknown } | null> {
+): Promise<{ code?: number; msg?: string; data?: unknown; success?: unknown } | null> {
try {
const resp = await fetchImpl(`${origin.replace(/\/+$/, "")}${path}`, { headers });
const text = await resp.text();
try {
- return JSON.parse(text) as { code?: number; msg?: string; data?: unknown };
+ return JSON.parse(text) as { code?: number; msg?: string; data?: unknown; success?: unknown };
} catch {
return { code: resp.status, msg: text.slice(0, 120) };
}
@@ -67,23 +104,58 @@ async function fetchBilling(
}
}
+/** Mirror of the bundle's `$_` (isSuccessfulBigModelEnvelope): `success !== false` and code absent/0/200. */
+function isSuccessfulEnvelope(e: { code?: number; success?: unknown }): boolean {
+ return e.success !== false && (e.code === undefined || e.code === 0 || e.code === 200);
+}
+
+/** Coding origin per stored credential's provider — `https://api.z.ai` / `https://open.bigmodel.cn`. */
+function codingOrigin(config: ProxyConfig, provider: Credential["provider"]): string | null {
+ try {
+ return new URL(config.providers[provider].openaiBase).origin;
+ } catch {
+ return null;
+ }
+}
+
+/** Normalize one upstream limit entry; rows without any usable number are display noise and dropped. */
+function parseCodingLimit(e: any): QuotaCodingLimit | null {
+ if (typeof e?.type !== "string" || e.type.trim() === "") return null;
+ const total = toFiniteNumber(e.number);
+ const used = toFiniteNumber(e.usage);
+ const remaining = toFiniteNumber(e.remaining);
+ if (total === undefined && used === undefined && remaining === undefined) return null;
+ const pct = toFiniteNumber(e.percentage);
+ const reset = toFiniteNumber(e.next_reset_time ?? e.nextResetTime);
+ const unit = typeof e.unit === "string" && e.unit.trim() !== "" ? e.unit : undefined;
+ return {
+ type: e.type.trim(),
+ ...(unit ? { unit } : {}),
+ ...(total !== undefined ? { total } : {}),
+ ...(used !== undefined ? { used } : {}),
+ ...(remaining !== undefined ? { remaining } : {}),
+ ...(pct !== undefined ? { percentage: pct } : {}),
+ ...(reset !== undefined ? { nextResetTime: reset } : {}),
+ };
+}
+
/** Coerce an upstream value to a finite number, or undefined (never NaN — JSON.stringify would emit null). */
function toFiniteNumber(v: unknown): number | undefined {
const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN;
return Number.isFinite(n) ? n : undefined;
}
-/** Build the billing snapshot. Exported for tests. `loadCredentialImpl` is injectable for tests. */
+/** Build the quota snapshot. Exported for tests. `loadCredentialImpl` is injectable for tests. */
export async function collectQuotaSnapshot(
config: ProxyConfig,
fetchImpl: typeof fetch = fetch,
loadCredentialImpl: typeof loadCredential = loadCredential,
): Promise {
const cred = await loadCredentialImpl();
- if (!cred?.jwt) {
- throw new Error("not logged in — no JWT credential (run: zcode-proxy auth login)");
+ if (!cred) {
+ throw new Error("not logged in (run: zcode-proxy auth login)");
}
- const jwtInfo = inspectJwt(cred.jwt);
+ const jwtInfo = cred.jwt ? inspectJwt(cred.jwt) : null;
const jwt = jwtInfo
? { ageHours: Number(jwtInfo.ageHours.toFixed(2)), issuedAt: jwtInfo.iat }
: null;
@@ -105,12 +177,42 @@ export async function collectQuotaSnapshot(
const appVersion = identity.appVersion;
const errors: string[] = [];
- const [balance, preview] = await Promise.all([
- fetchBilling(origin, `/api/v1/zcode-plan/billing/balance?app_version=${encodeURIComponent(appVersion)}&platform=${encodeURIComponent(platform)}`, headers, fetchImpl),
- fetchBilling(origin, `/api/v1/zcode-plan/billing/preview?app_version=${encodeURIComponent(appVersion)}&platform=${encodeURIComponent(platform)}`, headers, fetchImpl),
- ]);
- if (balance && balance.code !== 0) errors.push(`balance: ${balance.code} ${balance.msg ?? ""}`.trim());
- if (preview && preview.code !== 0) errors.push(`preview: ${preview.code} ${preview.msg ?? ""}`.trim());
+ // Credits plane needs the plan JWT; a coding-plan account without one still
+ // gets its monitor-plane limits below instead of a dead snapshot.
+ let balance: Awaited> = null;
+ let preview: Awaited> = null;
+ if (cred.jwt) {
+ [balance, preview] = await Promise.all([
+ fetchBilling(origin, `/api/v1/zcode-plan/billing/balance?app_version=${encodeURIComponent(appVersion)}&platform=${encodeURIComponent(platform)}`, headers, fetchImpl),
+ fetchBilling(origin, `/api/v1/zcode-plan/billing/preview?app_version=${encodeURIComponent(appVersion)}&platform=${encodeURIComponent(platform)}`, headers, fetchImpl),
+ ]);
+ if (balance && !isSuccessfulEnvelope(balance)) errors.push(`balance: ${balance.code} ${balance.msg ?? ""}`.trim());
+ if (preview && !isSuccessfulEnvelope(preview)) errors.push(`preview: ${preview.code} ${preview.msg ?? ""}`.trim());
+ } else {
+ errors.push("balance: no plan JWT — credits plane unavailable (re-login to capture it)");
+ }
+
+ // Coding plane: same contract the official usage panel uses for individual
+ // coding plans (`authorization` = the raw API key, personal scope — no team
+ // headers, no `?type=2`). Failures degrade to null like the official
+ // `fetchQuota().catch(() => null)`, never killing the credits data.
+ let codingPlan: QuotaCodingPlan | null = null;
+ const cOrigin = codingOrigin(config, cred.provider);
+ if (cOrigin) {
+ const cEnvelope = await fetchBilling(cOrigin, "/api/monitor/usage/quota/limit", {
+ authorization: credentialString(cred),
+ accept: "application/json",
+ }, fetchImpl);
+ if (cEnvelope && isSuccessfulEnvelope(cEnvelope)) {
+ const d = (cEnvelope.data ?? {}) as { level?: unknown; limits?: unknown };
+ codingPlan = {
+ level: typeof d.level === "string" && d.level.trim() !== "" ? d.level.trim() : null,
+ limits: (Array.isArray(d.limits) ? d.limits : []).map(parseCodingLimit).filter((l): l is QuotaCodingLimit => l !== null),
+ };
+ } else {
+ errors.push(`coding: ${cEnvelope ? `${cEnvelope.code} ${cEnvelope.msg ?? ""}`.trim() : "request failed"}`);
+ }
+ }
const balances: QuotaBalanceEntry[] = [];
const balanceData = (balance?.data ?? {}) as { balances?: any[]; server_time?: number };
@@ -153,6 +255,7 @@ export async function collectQuotaSnapshot(
jwt,
balances,
claimablePlans,
+ codingPlan,
errors,
};
}
diff --git a/src/tui/app.ts b/src/tui/app.ts
index 52a72cd..81c0f5b 100644
--- a/src/tui/app.ts
+++ b/src/tui/app.ts
@@ -253,7 +253,7 @@ export async function runTui(args: ServeArgs): Promise {
if (quotaFetchInFlight) return;
quotaFetchInFlight = true;
const firstFetch = state.quota == null;
- state.quota = { status: "loading", balances: [], errors: [], error: "", fetchedAt: Date.now() };
+ state.quota = { status: "loading", balances: [], coding: null, errors: [], error: "", fetchedAt: Date.now() };
scheduleRender();
try {
const snap: QuotaSnapshot = await collectQuotaSnapshot(config);
@@ -264,7 +264,19 @@ export async function runTui(args: ServeArgs): Promise {
remainingUnits: b.remainingUnits,
totalUnits: b.totalUnits,
...(b.expiresAt !== undefined ? { expiresAt: b.expiresAt } : {}),
+ ...(b.unitType !== undefined ? { unitType: b.unitType } : {}),
})),
+ coding: snap.codingPlan
+ ? {
+ level: snap.codingPlan.level,
+ rows: snap.codingPlan.limits.map((l) => ({
+ type: l.type,
+ ...(l.remaining !== undefined ? { remaining: l.remaining } : {}),
+ ...(l.unit !== undefined ? { unit: l.unit } : {}),
+ ...(l.nextResetTime !== undefined ? { nextResetTime: l.nextResetTime } : {}),
+ })),
+ }
+ : null,
errors: snap.errors,
error: "",
fetchedAt: Date.now(),
@@ -275,6 +287,7 @@ export async function runTui(args: ServeArgs): Promise {
state.quota = {
status: "error",
balances: [],
+ coding: null,
errors: [],
error: (err as Error).message,
fetchedAt: Date.now(),
diff --git a/src/tui/frame.test.ts b/src/tui/frame.test.ts
index 33ac1e6..3fa2d73 100644
--- a/src/tui/frame.test.ts
+++ b/src/tui/frame.test.ts
@@ -253,6 +253,7 @@ describe("buildFrame", () => {
{ showName: "GLM-5.3", remainingUnits: 2000000, totalUnits: 3000000, expiresAt: 1767225600 },
{ showName: "GLM Coding", remainingUnits: 5, totalUnits: 100 },
],
+ coding: null,
errors: [],
error: "",
fetchedAt: new Date("2026-09-29T12:00:00").getTime(),
@@ -267,6 +268,81 @@ describe("buildFrame", () => {
expect(text).toContain(" Refresh ");
});
+ test("quota card renders coding-plan windows after credit buckets", () => {
+ const endOfDay = new Date().setHours(23, 59, 0, 0); // same calendar day → HH:MM reset form
+ const text = plainLines(baseState({
+ quota: {
+ status: "ok",
+ balances: [{ showName: "GLM-5.3", remainingUnits: 2000000, totalUnits: 3000000 }],
+ coding: {
+ level: "max",
+ rows: [
+ { type: "TIME_LIMIT", remaining: 36, unit: "prompt", nextResetTime: endOfDay },
+ { type: "WEEK_LIMIT", remaining: 500 },
+ ],
+ },
+ errors: [],
+ error: "",
+ fetchedAt: Date.now(),
+ },
+ })).join("\n");
+ expect(text).toContain("Balances");
+ expect(text).toContain("Coding");
+ expect(text).toContain("TIME_LIMIT");
+ // Mirror of the official panel: remaining alone — upstream `number` is not
+ // a comparable total (live TIME_LIMIT row: remaining=3894, number=1).
+ expect(text).toContain("36");
+ expect(text).toContain("reset 23:59");
+ expect(text).toContain("· max");
+ expect(text).toContain("WEEK_LIMIT");
+ expect(text).toContain("500");
+ });
+
+ test("coding rows carry their own group label and render standalone", () => {
+ const text = plainLines(baseState({
+ quota: {
+ status: "ok",
+ balances: [],
+ coding: { level: null, rows: [{ type: "TIME_LIMIT", remaining: 9, nextResetTime: 1767225600 }] },
+ errors: [],
+ error: "",
+ fetchedAt: Date.now(),
+ },
+ })).join("\n");
+ expect(text).toContain("Coding");
+ expect(text).not.toContain("Balances");
+ const codingLine = plainLines(baseState({
+ quota: {
+ status: "ok",
+ balances: [],
+ coding: { level: null, rows: [{ type: "TIME_LIMIT", remaining: 9, nextResetTime: 1767225600 }] },
+ errors: [],
+ error: "",
+ fetchedAt: Date.now(),
+ },
+ })).find((l) => l.includes("TIME_LIMIT")) ?? "";
+ expect(codingLine).toContain("9");
+ expect(codingLine).not.toContain("120");
+ expect(text).toContain("reset 01-01");
+ });
+
+ test("non-token unit types are surfaced, token stays invisible", () => {
+ const balanceLine = (unitType?: string): string =>
+ plainLines(baseState({
+ quota: {
+ status: "ok",
+ balances: [{ showName: "GLM-5.3", remainingUnits: 1, totalUnits: 2, ...(unitType ? { unitType } : {}) }],
+ coding: null,
+ errors: [],
+ error: "",
+ fetchedAt: Date.now(),
+ },
+ })).find((l) => l.includes("GLM-5.3")) ?? "";
+ expect(balanceLine("prompt")).toContain("· prompt");
+ expect(balanceLine("token")).not.toContain("token");
+ expect(balanceLine(undefined)).not.toContain("·");
+ });
+
test("quota card caps balance rows to keep the Logs card room", () => {
const balances = Array.from({ length: 8 }, (_, i) => ({
showName: `Model ${i}`,
@@ -274,34 +350,54 @@ describe("buildFrame", () => {
totalUnits: 100,
}));
const text = plainLines(baseState({
- quota: { status: "ok", balances, errors: [], error: "", fetchedAt: Date.now() },
+ quota: { status: "ok", balances, coding: null, errors: [], error: "", fetchedAt: Date.now() },
})).join("\n");
expect(text).toContain("Model 3");
expect(text).not.toContain("Model 4");
});
+ test("row cap spans both planes (balances first, coding after, overflow counted together)", () => {
+ const balances = Array.from({ length: 3 }, (_, i) => ({ showName: `Model ${i}`, remainingUnits: i, totalUnits: 100 }));
+ const codingRows = Array.from({ length: 3 }, (_, i) => ({ type: `LIMIT_${i}`, remaining: i }));
+ const text = plainLines(baseState({
+ quota: { status: "ok", balances, coding: { level: null, rows: codingRows }, errors: [], error: "", fetchedAt: Date.now() },
+ })).join("\n");
+ expect(text).toContain("Model 2");
+ expect(text).toContain("LIMIT_0");
+ expect(text).not.toContain("LIMIT_1");
+ expect(text).toContain("+2 more");
+ });
+
test("quota card shows error state and upstream warnings", () => {
const text = plainLines(baseState({
quota: {
status: "error",
balances: [],
+ coding: null,
errors: [],
- error: "not logged in — no JWT credential",
+ error: "not logged in (run: zcode-proxy auth login)",
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() },
+ quota: { status: "ok", balances: [], coding: null, errors: ["balance: 3012 risk"], error: "", fetchedAt: Date.now() },
})).join("\n");
expect(warned).toContain("⚠");
expect(warned).toContain("3012");
});
+ test("both planes empty → explicit no-entries row (not the old balance-only copy)", () => {
+ const text = plainLines(baseState({
+ quota: { status: "ok", balances: [], coding: { level: null, rows: [] }, errors: [], error: "", fetchedAt: Date.now() },
+ })).join("\n");
+ expect(text).toContain("no quota entries reported by upstream");
+ });
+
test("quota Refresh button is clickable and footer advertises [r]", () => {
const f = buildFrame(baseState({
- quota: { status: "ok", balances: [], errors: [], error: "", fetchedAt: Date.now() },
+ quota: { status: "ok", balances: [], coding: null, errors: [], error: "", fetchedAt: Date.now() },
}));
const actions = f.regions.map((r) => r.action);
expect(actions).toContainEqual({ kind: "key", key: "r" });
@@ -309,14 +405,14 @@ describe("buildFrame", () => {
// only on wider terminals.
const wide = buildFrame(baseState({
width: 140,
- quota: { status: "ok", balances: [], errors: [], error: "", fetchedAt: Date.now() },
+ quota: { status: "ok", balances: [], coding: null, 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 quota = { status: "ok" as const, balances: [{ showName: "GLM-5.3", remainingUnits: 1, totalUnits: 2 }], coding: null, 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");
@@ -332,6 +428,7 @@ describe("buildFrame", () => {
quota: {
status: "ok",
balances: Array.from({ length: 6 }, (_, i) => ({ showName: `Model ${i}`, remainingUnits: i, totalUnits: 100 })),
+ coding: { level: null, rows: Array.from({ length: 6 }, (_, i) => ({ type: `LIMIT_${i}`, remaining: i })) },
errors: [],
error: "",
fetchedAt: Date.now(),
@@ -346,6 +443,7 @@ describe("buildFrame", () => {
quota: {
status: "ok",
balances: [{ showName: "超长模型名称测试超长模型名称测试超长模型名称", remainingUnits: 1234567890, totalUnits: 9876543210, expiresAt: 1735689600 }],
+ coding: { level: "an-unreasonably-long-tier-name-from-upstream", rows: [{ type: "超长窗口类型名称测试超长窗口类型名称测试", remaining: 1234567, nextResetTime: 1735689600 }] },
errors: [],
error: "",
fetchedAt: Date.now(),
diff --git a/src/tui/frame.ts b/src/tui/frame.ts
index 5aa72ee..42dae48 100644
--- a/src/tui/frame.ts
+++ b/src/tui/frame.ts
@@ -49,17 +49,30 @@ export interface QuotaBalanceRow {
remainingUnits: number;
totalUnits: number;
expiresAt?: number;
+ /** Upstream unit semantics; rendered unless it's the noise-default `"token"`. */
+ unitType?: string;
+}
+
+/** One coding-plan usage window (monitor plane `limits[]` entry). */
+export interface QuotaCodingRow {
+ /** Server-defined window type; `TIME_LIMIT` is the 5h/weekly prompt window. */
+ type: string;
+ remaining?: number;
+ unit?: string;
+ nextResetTime?: 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.
+ * along on an otherwise-OK snapshot. `coding` is the coding-plan monitor plane
+ * (individual-subscription windows) — null when that endpoint failed.
*/
export interface QuotaState {
status: "loading" | "ok" | "error";
balances: QuotaBalanceRow[];
+ coding: { level: string | null; rows: QuotaCodingRow[] } | null;
errors: string[];
error: string;
/** Epoch ms of the last fetch attempt — rendered as the card pill clock. */
@@ -254,6 +267,15 @@ function fmtExpiry(expiresAt: number): string {
return d.getFullYear() === new Date().getFullYear() ? mmdd : `${d.getFullYear()}-${mmdd}`;
}
+/** Usage-window reset: `HH:MM` today, `MM-DD HH:MM` otherwise; `""` unparseable. */
+function fmtReset(nextResetTime: number): string {
+ const ms = nextResetTime > 1e12 ? nextResetTime : nextResetTime * 1000;
+ const d = new Date(ms);
+ if (Number.isNaN(d.getTime())) return "";
+ const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
+ return d.toDateString() === new Date().toDateString() ? hm : `${fmtExpiry(nextResetTime)} ${hm}`;
+}
+
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")}`;
@@ -368,23 +390,59 @@ export function buildFrame(s: FrameState): Frame {
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 }] : []),
- ]);
+ // Both planes render as one table: credits buckets ("Balances") then
+ // coding-plan windows ("Coding"), each group's first row carries the
+ // label. Rows share the value grammar: name · remaining [/ total] · time.
+ const coding = s.quota.coding?.rows ?? [];
+ type QuotaTableRow = { label: string; segs: Seg[] };
+ const unitNote = (u: string | undefined): Seg[] =>
+ !u || u === "token" ? [] : [{ t: ` · ${u}`, c: DIM }];
+ const rows: QuotaTableRow[] = [
+ ...s.quota.balances.map((b, i): QuotaTableRow => {
+ const exp = b.expiresAt ? fmtExpiry(b.expiresAt) : "";
+ return {
+ label: i === 0 ? "Balances" : "",
+ segs: [
+ { t: truncateToWidth(b.showName || "(unnamed)", 16), c: CYAN },
+ { t: " " },
+ { t: `${fmtUnits(b.remainingUnits)} / ${fmtUnits(b.totalUnits)}`, c: GREEN },
+ ...(exp ? [{ t: ` · exp ${exp}`, c: DIM }] : []),
+ ...unitNote(b.unitType),
+ ],
+ };
+ }),
+ ...coding.map((c, i): QuotaTableRow => {
+ // Live data (2026-09-29) shows upstream `number` is not a comparable
+ // total (TIME_LIMIT row: remaining=3894, number=1) — the official
+ // panel likewise renders `remaining` alone, never "X / Y".
+ const value = c.remaining !== undefined ? fmtUnits(c.remaining) : "—";
+ const reset = c.nextResetTime !== undefined ? fmtReset(c.nextResetTime) : "";
+ return {
+ label: i === 0 ? "Coding" : "",
+ segs: [
+ { t: truncateToWidth(c.type, 16), c: CYAN },
+ { t: " " },
+ { t: value, c: GREEN },
+ ...(reset ? [{ t: ` · reset ${reset}`, c: DIM }] : []),
+ ...(i === 0 && s.quota?.coding?.level ? [{ t: ` · ${s.quota.coding.level}`, c: DIM }] : []),
+ ...unitNote(c.unit),
+ ],
+ };
+ }),
+ ];
+ if (rows.length === 0) {
+ emit((composeRow(w, lines.length, "Balances", [{ t: "(no quota entries reported by upstream)", c: DIM }])).line);
+ }
+ for (const r of rows.slice(0, quotaRows)) {
+ const row = composeRow(w, lines.length, r.label, r.segs);
emit(row.line);
regions.push(...row.regions);
}
}
- const hiddenModels = Math.max(0, s.quota.balances.length - quotaRows);
+ const totalRows = s.quota.balances.length + (s.quota.coding?.rows.length ?? 0);
+ const hiddenModels = Math.max(0, totalRows - 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 }] : []),