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 }] : []),