glm-coding-router 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -233,6 +233,26 @@ Notes:
233
233
  orchestration isn't drivable today); the harness is stack-shaped so it can
234
234
  be added later.
235
235
 
236
+ ## usage
237
+
238
+ Provider usage snapshots (spec §54 v0.5) — what is reliably retrievable:
239
+
240
+ ```powershell
241
+ glm-router usage
242
+ ```
243
+
244
+ - **Z.ai Coding Plan quota** (network): queries the Z.ai monitor endpoint
245
+ (`/api/monitor/usage/quota/limit`) with your key and shows each credit
246
+ window — consumed/total, percentage, reset time — plus the plan level.
247
+ Unreachable endpoint or a rejected request renders `✗ <reason>` and exits 1.
248
+ - **Local totals** (offline): aggregates saved benchmark reports — run count
249
+ and summed input/output tokens (`glm-router benchmark` writes them).
250
+ - **Claude quota / Codex usage**: always shown as "not available" — neither
251
+ exposes a headless usage API today (and claude.ai quota is irrelevant while
252
+ traffic is routed to GLM).
253
+
254
+ `--json` emits the same data machine-readably. No key configured → `ERROR [10]`.
255
+
236
256
  ## CLI reference
237
257
 
238
258
  ```text
@@ -245,6 +265,7 @@ glm-router config show
245
265
  glm-router config set models.main glm-5.3
246
266
  glm-router delegate <name> run a GLM worker in an isolated git worktree
247
267
  glm-router benchmark measure the Claude+GLM stack on built-in tasks
268
+ glm-router usage Z.ai quota snapshot + local benchmark totals
248
269
  glm-router project init CLAUDE.md / AGENTS.md managed blocks (--dry-run supported)
249
270
  glm-router project remove
250
271
  glm-router skill install optional Codex delegation skill
package/dist/cli.js CHANGED
@@ -15,6 +15,7 @@ import { skillInstallCommand, skillRemoveCommand } from "./commands/skill.js";
15
15
  import { uninstallCommand } from "./commands/uninstall.js";
16
16
  import { delegateCommand } from "./commands/delegate.js";
17
17
  import { benchmarkCommand } from "./commands/benchmark.js";
18
+ import { usageCommand } from "./commands/usage.js";
18
19
  const program = new Command();
19
20
  program
20
21
  .name("glm-router")
@@ -103,6 +104,10 @@ program
103
104
  .option("--max-turns <n>", "worker --max-turns override", (value) => Number(value))
104
105
  .option("--repeat <n>", "run each task N times", (value) => Number(value))
105
106
  .action((commandOptions) => execute(() => benchmarkCommand({ ...globalOptions(), ...commandOptions })));
107
+ program
108
+ .command("usage")
109
+ .description("provider usage snapshots: Z.ai Coding Plan quota + local benchmark totals")
110
+ .action(() => execute(() => usageCommand(globalOptions())));
106
111
  program
107
112
  .command("uninstall")
108
113
  .description("guided removal (keeps ZAI_API_KEY by default)")
@@ -0,0 +1,162 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { Errors } from "../core/errors.js";
5
+ import { configDir } from "../core/paths.js";
6
+ import { version } from "../core/version.js";
7
+ import { resolveZaiApiKey } from "../core/zai-key.js";
8
+ import { emitJson } from "./context.js";
9
+ /** Z.ai monitor API used by their own dashboard (specs/usage.md). */
10
+ const ZAI_QUOTA_URL = "https://api.z.ai/api/monitor/usage/quota/limit";
11
+ /** Window labels for the observed enum values (specs/usage.md); unknown values stay generic. */
12
+ function describeWindow(limit) {
13
+ if (limit.unit === 3 && typeof limit.number === "number") {
14
+ return `${limit.number}-hour window`;
15
+ }
16
+ if (limit.unit === 6 && limit.number === 1) {
17
+ return "weekly";
18
+ }
19
+ return `window unit=${String(limit.unit)} x ${String(limit.number)}`;
20
+ }
21
+ /** Fetch and validate the Z.ai quota snapshot. Never logs the Authorization header. */
22
+ async function fetchZaiQuota(key, fetchImpl) {
23
+ let response;
24
+ try {
25
+ response = await fetchImpl(ZAI_QUOTA_URL, {
26
+ method: "GET",
27
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
28
+ signal: AbortSignal.timeout(10_000),
29
+ });
30
+ }
31
+ catch (error) {
32
+ throw new Error(`Z.ai monitor endpoint unreachable (${error instanceof Error ? error.message : "network error"})`);
33
+ }
34
+ if (!response.ok) {
35
+ throw new Error(`Z.ai monitor endpoint returned HTTP ${response.status}`);
36
+ }
37
+ let body;
38
+ try {
39
+ body = (await response.json());
40
+ }
41
+ catch {
42
+ throw new Error("Z.ai monitor endpoint returned a non-JSON body");
43
+ }
44
+ if (body.code !== 200 || typeof body.data !== "object" || body.data === null) {
45
+ throw new Error(`Z.ai monitor endpoint rejected the request (${body.msg ?? `code ${String(body.code)}`})`);
46
+ }
47
+ return body.data;
48
+ }
49
+ /** Aggregate saved benchmark reports (specs/benchmark.md) into one local summary. */
50
+ export function aggregateLocalUsage(home) {
51
+ const dir = path.join(configDir(home), "benchmarks");
52
+ let runs = 0;
53
+ let tokensIn = 0;
54
+ let tokensOut = 0;
55
+ let lastFinishedAt = null;
56
+ if (!fs.existsSync(dir)) {
57
+ return { runs, tokensIn, tokensOut, lastFinishedAt };
58
+ }
59
+ for (const name of fs.readdirSync(dir)) {
60
+ if (!name.endsWith(".json"))
61
+ continue;
62
+ try {
63
+ const report = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
64
+ for (const run of report.tasks ?? []) {
65
+ runs += 1;
66
+ tokensIn += run.inputTokens ?? 0;
67
+ tokensOut += run.outputTokens ?? 0;
68
+ }
69
+ if (typeof report.finishedAt === "string" && (!lastFinishedAt || report.finishedAt > lastFinishedAt)) {
70
+ lastFinishedAt = report.finishedAt;
71
+ }
72
+ }
73
+ catch {
74
+ // Malformed report files are skipped — usage must never crash on bad data.
75
+ }
76
+ }
77
+ return { runs, tokensIn, tokensOut, lastFinishedAt };
78
+ }
79
+ /**
80
+ * glm-router usage (spec §54 v0.5, specs/usage.md): provider usage snapshots
81
+ * where APIs allow reliable retrieval. Z.ai quota via the monitor endpoint;
82
+ * Claude/Codex have no headless usage surface and say so; local totals come
83
+ * from saved benchmark reports.
84
+ */
85
+ export async function usageCommand(options, deps = {}) {
86
+ const home = deps.home ?? os.homedir();
87
+ const env = deps.env ?? process.env;
88
+ const fetchImpl = deps.fetchImpl ?? fetch;
89
+ const resolved = resolveZaiApiKey({ env, readUserEnv: deps.readUserEnv });
90
+ if (!resolved) {
91
+ throw Errors.zaiKeyMissing();
92
+ }
93
+ let quota;
94
+ let quotaError;
95
+ try {
96
+ quota = await fetchZaiQuota(resolved.key, fetchImpl);
97
+ }
98
+ catch (error) {
99
+ quotaError = error instanceof Error ? error.message : String(error);
100
+ }
101
+ const local = aggregateLocalUsage(home);
102
+ const limits = quota?.limits ?? [];
103
+ const json = {
104
+ version,
105
+ zai: quotaError
106
+ ? { ok: false, error: quotaError }
107
+ : {
108
+ ok: true,
109
+ level: quota?.level ?? null,
110
+ limits: limits.map((limit) => ({
111
+ window: describeWindow(limit),
112
+ consumed: limit.currentValue ?? null,
113
+ total: limit.usage ?? null,
114
+ remaining: limit.remaining ?? null,
115
+ percentage: limit.percentage ?? null,
116
+ resetsAt: typeof limit.nextResetTime === "number" ? new Date(limit.nextResetTime).toISOString() : null,
117
+ })),
118
+ },
119
+ local,
120
+ claude: "not available — Claude Code exposes no headless usage API",
121
+ codex: "not available — Codex exposes no plan-usage API",
122
+ };
123
+ if (options.json) {
124
+ emitJson(json);
125
+ return quotaError ? 1 : 0;
126
+ }
127
+ const lines = [`GLM Coding Router v${version} — usage snapshot`, ""];
128
+ if (quotaError) {
129
+ lines.push(`Z.ai Coding Plan`);
130
+ lines.push(` ✗ ${quotaError}`);
131
+ }
132
+ else {
133
+ lines.push(`Z.ai Coding Plan${quota?.level ? ` (level: ${quota.level})` : ""}`);
134
+ if (limits.length === 0) {
135
+ lines.push(" (no quota windows reported)");
136
+ }
137
+ for (const limit of limits) {
138
+ const consumed = limit.currentValue ?? "?";
139
+ const total = limit.usage ?? "?";
140
+ const percentage = typeof limit.percentage === "number"
141
+ ? limit.percentage
142
+ : typeof limit.currentValue === "number" && typeof limit.usage === "number" && limit.usage > 0
143
+ ? Math.round((limit.currentValue / limit.usage) * 100)
144
+ : "?";
145
+ const resets = typeof limit.nextResetTime === "number" ? ` — resets ${new Date(limit.nextResetTime).toISOString()}` : "";
146
+ lines.push(` ${describeWindow(limit).padEnd(15)} ${consumed} / ${total} credits (${percentage}%)${resets}`);
147
+ }
148
+ }
149
+ lines.push("");
150
+ lines.push("Local (benchmark reports)");
151
+ if (local.runs === 0) {
152
+ lines.push(" (none yet — run glm-router benchmark)");
153
+ }
154
+ else {
155
+ lines.push(` runs ${local.runs} · tokens ${local.tokensIn} in / ${local.tokensOut} out · last ${local.lastFinishedAt}`);
156
+ }
157
+ lines.push("");
158
+ lines.push(`Claude quota ${json.claude}`);
159
+ lines.push(`Codex usage ${json.codex}`);
160
+ process.stdout.write(lines.join("\n") + "\n");
161
+ return quotaError ? 1 : 0;
162
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glm-coding-router",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "GLM Coding Plan workers for Claude Code and Codex",
5
5
  "type": "module",
6
6
  "license": "MIT",