glm-coding-router 0.3.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 +55 -0
- package/dist/cli.js +14 -0
- package/dist/commands/benchmark.js +252 -0
- package/dist/commands/usage.js +162 -0
- package/dist/core/errors.js +6 -0
- package/dist/core/process.js +70 -37
- package/dist/templates/benchmark-tasks.js +70 -0
- package/package.json +47 -47
package/README.md
CHANGED
|
@@ -200,6 +200,59 @@ glm-router delegate backend "Task A" # terminal 1
|
|
|
200
200
|
glm-router delegate tests "Task B" # terminal 2
|
|
201
201
|
```
|
|
202
202
|
|
|
203
|
+
## benchmark
|
|
204
|
+
|
|
205
|
+
Measure the Claude Code + GLM stack on built-in coding tasks (spec §54 v0.4).
|
|
206
|
+
Each task runs in a throwaway temp directory: the router writes the task files,
|
|
207
|
+
spawns the standard GLM worker (same env injection, plus `--output-format json`
|
|
208
|
+
to capture the result document), then runs the task's validation command:
|
|
209
|
+
|
|
210
|
+
```powershell
|
|
211
|
+
glm-router benchmark --yes # both built-in tasks, 1 run each
|
|
212
|
+
glm-router benchmark --yes --task fn-reverse --repeat 3
|
|
213
|
+
glm-router benchmark --yes --max-turns 15
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Report (per task × run): **duration**, **GLM calls** (assistant turns),
|
|
217
|
+
**retries** (`-` — not exposed by Claude Code yet), **tokens in/out**,
|
|
218
|
+
**tests** (PASS/FAIL of `node test.js`), **success**, **intervention**
|
|
219
|
+
(`needed` when the run did not self-complete). The full JSON report is always
|
|
220
|
+
saved to `%USERPROFILE%\.glm-coding-router\benchmarks\benchmark-<timestamp>.json`
|
|
221
|
+
and `--json` also prints it.
|
|
222
|
+
|
|
223
|
+
Built-in tasks: `fn-reverse` (implement `reverseWords` until the test passes),
|
|
224
|
+
`fix-bug` (repair an even-length `median` bug).
|
|
225
|
+
|
|
226
|
+
Notes:
|
|
227
|
+
- Benchmarking makes **real GLM API calls** — interactive runs ask for
|
|
228
|
+
confirmation; non-interactive runs require `--yes`.
|
|
229
|
+
- Failed tasks are measurements, not errors: the command exits 0 once the
|
|
230
|
+
suite ran. Missing key/claude or a broken spawn still fail with the usual
|
|
231
|
+
`ERROR [10]/[20]/[40]`.
|
|
232
|
+
- `--stack codex` is recognized but not supported yet (headless Codex
|
|
233
|
+
orchestration isn't drivable today); the harness is stack-shaped so it can
|
|
234
|
+
be added later.
|
|
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
|
+
|
|
203
256
|
## CLI reference
|
|
204
257
|
|
|
205
258
|
```text
|
|
@@ -211,6 +264,8 @@ glm-router key check key configured? from which source?
|
|
|
211
264
|
glm-router config show
|
|
212
265
|
glm-router config set models.main glm-5.3
|
|
213
266
|
glm-router delegate <name> run a GLM worker in an isolated git worktree
|
|
267
|
+
glm-router benchmark measure the Claude+GLM stack on built-in tasks
|
|
268
|
+
glm-router usage Z.ai quota snapshot + local benchmark totals
|
|
214
269
|
glm-router project init CLAUDE.md / AGENTS.md managed blocks (--dry-run supported)
|
|
215
270
|
glm-router project remove
|
|
216
271
|
glm-router skill install optional Codex delegation skill
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,8 @@ import { projectRemoveCommand } from "./commands/project-remove.js";
|
|
|
14
14
|
import { skillInstallCommand, skillRemoveCommand } from "./commands/skill.js";
|
|
15
15
|
import { uninstallCommand } from "./commands/uninstall.js";
|
|
16
16
|
import { delegateCommand } from "./commands/delegate.js";
|
|
17
|
+
import { benchmarkCommand } from "./commands/benchmark.js";
|
|
18
|
+
import { usageCommand } from "./commands/usage.js";
|
|
17
19
|
const program = new Command();
|
|
18
20
|
program
|
|
19
21
|
.name("glm-router")
|
|
@@ -94,6 +96,18 @@ program
|
|
|
94
96
|
.option("--profile <name>", "profile overlay (defaults to a profile named <name> if defined)")
|
|
95
97
|
.option("--remove", "remove the worktree after a successful run (branch is always kept)")
|
|
96
98
|
.action((name, prompt, commandOptions) => execute(() => delegateCommand(name, prompt, { ...globalOptions(), ...commandOptions })));
|
|
99
|
+
program
|
|
100
|
+
.command("benchmark")
|
|
101
|
+
.description("measure the Claude+GLM stack on built-in coding tasks (makes real GLM calls)")
|
|
102
|
+
.option("--task <id>", "run only this task (repeatable)", (value, previous) => previous.concat([value]), [])
|
|
103
|
+
.option("--stack <name>", "orchestration stack (default claude; codex not yet supported)")
|
|
104
|
+
.option("--max-turns <n>", "worker --max-turns override", (value) => Number(value))
|
|
105
|
+
.option("--repeat <n>", "run each task N times", (value) => Number(value))
|
|
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())));
|
|
97
111
|
program
|
|
98
112
|
.command("uninstall")
|
|
99
113
|
.description("guided removal (keeps ZAI_API_KEY by default)")
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import prompts from "prompts";
|
|
6
|
+
import { version } from "../core/version.js";
|
|
7
|
+
import { WORKER_TOOLS } from "../bin/glm-worker.js";
|
|
8
|
+
import { loadConfig } from "../core/config.js";
|
|
9
|
+
import { locateClaude } from "../core/claude.js";
|
|
10
|
+
import { createGlmEnv } from "../core/env.js";
|
|
11
|
+
import { Errors } from "../core/errors.js";
|
|
12
|
+
import { configDir } from "../core/paths.js";
|
|
13
|
+
import { spawnAgentCapture } from "../core/process.js";
|
|
14
|
+
import { resolveZaiApiKey } from "../core/zai-key.js";
|
|
15
|
+
import { BENCHMARK_TASKS, benchmarkTaskById } from "../templates/benchmark-tasks.js";
|
|
16
|
+
import { emitJson } from "./context.js";
|
|
17
|
+
const KNOWN_STACKS = ["claude", "codex"];
|
|
18
|
+
/**
|
|
19
|
+
* glm-router benchmark (spec §54 v0.4, specs/benchmark.md): run built-in
|
|
20
|
+
* coding tasks through the Claude+GLM worker path and report §54 metrics.
|
|
21
|
+
* Failed tasks are measurements, not CLI errors — exits 0 once the suite ran.
|
|
22
|
+
*/
|
|
23
|
+
export async function benchmarkCommand(options, deps = {}) {
|
|
24
|
+
const stack = options.stack ?? "claude";
|
|
25
|
+
if (stack === "codex") {
|
|
26
|
+
throw Errors.invalidArgs("The codex stack is not supported yet: headless Codex orchestration cannot be driven reliably today.", ["v0.4 ships the harness + claude stack; codex slots in later without redesign."]);
|
|
27
|
+
}
|
|
28
|
+
if (!KNOWN_STACKS.includes(stack)) {
|
|
29
|
+
throw Errors.invalidArgs(`Unknown stack "${stack}".`, [`Known stacks: ${KNOWN_STACKS.join(", ")}`]);
|
|
30
|
+
}
|
|
31
|
+
const ids = options.task && options.task.length > 0 ? options.task : BENCHMARK_TASKS.map((t) => t.id);
|
|
32
|
+
const unknown = ids.filter((id) => !benchmarkTaskById(id));
|
|
33
|
+
if (unknown.length > 0) {
|
|
34
|
+
throw Errors.invalidArgs(`Unknown task id(s): ${unknown.join(", ")}.`, [`Available tasks: ${BENCHMARK_TASKS.map((t) => t.id).join(", ")}`]);
|
|
35
|
+
}
|
|
36
|
+
const tasks = ids.map((id) => benchmarkTaskById(id));
|
|
37
|
+
const repeat = options.repeat ?? 1;
|
|
38
|
+
const maxTurns = options.maxTurns;
|
|
39
|
+
if (!Number.isInteger(repeat) || repeat < 1) {
|
|
40
|
+
throw Errors.invalidArgs(`--repeat expects a positive integer, got "${repeat}".`);
|
|
41
|
+
}
|
|
42
|
+
if (maxTurns !== undefined && (!Number.isInteger(maxTurns) || maxTurns < 1)) {
|
|
43
|
+
throw Errors.invalidArgs(`--max-turns expects a positive integer, got "${maxTurns}".`);
|
|
44
|
+
}
|
|
45
|
+
if (options.dryRun) {
|
|
46
|
+
if (options.json) {
|
|
47
|
+
emitJson({ stack, tasks: ids, repeat, maxTurns: maxTurns ?? "(config default)", dryRun: true });
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const lines = [
|
|
51
|
+
`would run stack ${stack}`,
|
|
52
|
+
`would run tasks ${ids.join(", ")}`,
|
|
53
|
+
`would repeat ${repeat}x`,
|
|
54
|
+
`would use maxTurns ${maxTurns ?? "(config default)"}`,
|
|
55
|
+
];
|
|
56
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
57
|
+
}
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
const interactive = deps.interactive ?? process.stdin.isTTY === true;
|
|
61
|
+
if (!options.yes) {
|
|
62
|
+
if (!interactive) {
|
|
63
|
+
throw Errors.invalidArgs(`benchmark makes real GLM API calls; confirm with --yes when running non-interactively.`, [` glm-router benchmark --yes`]);
|
|
64
|
+
}
|
|
65
|
+
const confirm = deps.confirm ?? defaultConfirm;
|
|
66
|
+
const ok = await confirm();
|
|
67
|
+
if (!ok) {
|
|
68
|
+
process.stdout.write("Cancelled.\n");
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const home = deps.home ?? os.homedir();
|
|
73
|
+
const env = deps.env ?? process.env;
|
|
74
|
+
const config = loadConfig(home);
|
|
75
|
+
const resolved = resolveZaiApiKey({ env, readUserEnv: deps.readUserEnv });
|
|
76
|
+
if (!resolved) {
|
|
77
|
+
throw Errors.zaiKeyMissing();
|
|
78
|
+
}
|
|
79
|
+
const claudePath = locateClaude(config, env);
|
|
80
|
+
const turns = maxTurns ?? config.worker.maxTurns;
|
|
81
|
+
const now = deps.now ?? Date.now;
|
|
82
|
+
const mkdtemp = deps.mkdtemp ?? (() => fs.mkdtempSync(path.join(os.tmpdir(), "glm-benchmark-")));
|
|
83
|
+
const spawn = deps.spawn ?? spawnAgentCapture;
|
|
84
|
+
const startedAt = new Date(now()).toISOString();
|
|
85
|
+
const metrics = [];
|
|
86
|
+
for (const task of tasks) {
|
|
87
|
+
for (let run = 1; run <= repeat; run++) {
|
|
88
|
+
metrics.push(await runTask(task, run, { turns, claudePath, config, key: resolved.key, env, now, mkdtemp, spawn }));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const report = {
|
|
92
|
+
version,
|
|
93
|
+
stack,
|
|
94
|
+
startedAt,
|
|
95
|
+
finishedAt: new Date(now()).toISOString(),
|
|
96
|
+
maxTurns: turns,
|
|
97
|
+
repeat,
|
|
98
|
+
tasks: metrics,
|
|
99
|
+
};
|
|
100
|
+
const savedPath = saveReport(report, home);
|
|
101
|
+
if (!options.quiet && !options.json) {
|
|
102
|
+
process.stdout.write(`report saved to ${savedPath}\n`);
|
|
103
|
+
}
|
|
104
|
+
if (options.json) {
|
|
105
|
+
emitJson({ ...report, savedPath });
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
renderTable(metrics, options);
|
|
109
|
+
}
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
async function runTask(task, run, ctx) {
|
|
113
|
+
const dir = ctx.mkdtemp();
|
|
114
|
+
try {
|
|
115
|
+
for (const [relative, content] of Object.entries(task.files)) {
|
|
116
|
+
const file = path.join(dir, relative);
|
|
117
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
118
|
+
fs.writeFileSync(file, content, "utf8");
|
|
119
|
+
}
|
|
120
|
+
const args = [
|
|
121
|
+
"-p",
|
|
122
|
+
task.prompt,
|
|
123
|
+
"--max-turns",
|
|
124
|
+
String(ctx.turns),
|
|
125
|
+
"--permission-mode",
|
|
126
|
+
"acceptEdits",
|
|
127
|
+
"--tools",
|
|
128
|
+
WORKER_TOOLS,
|
|
129
|
+
"--output-format",
|
|
130
|
+
"json",
|
|
131
|
+
];
|
|
132
|
+
const childEnv = createGlmEnv(ctx.config, ctx.key, ctx.env);
|
|
133
|
+
const started = ctx.now();
|
|
134
|
+
let captured;
|
|
135
|
+
try {
|
|
136
|
+
captured = await ctx.spawn(ctx.claudePath, { args, cwd: dir, env: childEnv, interactive: false });
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
throw Errors.childAgentFailed(error instanceof Error ? error.message : String(error));
|
|
140
|
+
}
|
|
141
|
+
const durationMs = ctx.now() - started;
|
|
142
|
+
const parsed = parseClaudeResult(captured.stdout);
|
|
143
|
+
const validation = await runValidation(task.validate, dir);
|
|
144
|
+
const selfCompleted = captured.code === 0 && parsed?.isError !== true && !isErrorSubtype(parsed?.subtype);
|
|
145
|
+
const success = selfCompleted && validation.pass;
|
|
146
|
+
return {
|
|
147
|
+
task: task.id,
|
|
148
|
+
run,
|
|
149
|
+
durationMs,
|
|
150
|
+
childDurationMs: parsed?.durationMs ?? null,
|
|
151
|
+
workerExit: captured.code,
|
|
152
|
+
glmCalls: parsed?.numTurns ?? null,
|
|
153
|
+
inputTokens: parsed?.usage?.input_tokens ?? null,
|
|
154
|
+
outputTokens: parsed?.usage?.output_tokens ?? null,
|
|
155
|
+
subtype: parsed?.subtype ?? null,
|
|
156
|
+
testsPass: validation.pass,
|
|
157
|
+
testsOutput: validation.output,
|
|
158
|
+
success,
|
|
159
|
+
interventionNeeded: !selfCompleted,
|
|
160
|
+
resultParsed: parsed !== null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Parse the child's `--output-format json` result document (last JSON line wins). */
|
|
168
|
+
export function parseClaudeResult(stdout) {
|
|
169
|
+
const lines = stdout.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
170
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
171
|
+
try {
|
|
172
|
+
const parsed = JSON.parse(lines[i]);
|
|
173
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
174
|
+
return {
|
|
175
|
+
subtype: typeof parsed.subtype === "string" ? parsed.subtype : undefined,
|
|
176
|
+
isError: typeof parsed.is_error === "boolean" ? parsed.is_error : undefined,
|
|
177
|
+
numTurns: typeof parsed.num_turns === "number" ? parsed.num_turns : undefined,
|
|
178
|
+
durationMs: typeof parsed.duration_ms === "number" ? parsed.duration_ms : undefined,
|
|
179
|
+
usage: typeof parsed.usage === "object" && parsed.usage !== null
|
|
180
|
+
? {
|
|
181
|
+
input_tokens: numberOrUndefined(parsed.usage.input_tokens),
|
|
182
|
+
output_tokens: numberOrUndefined(parsed.usage.output_tokens),
|
|
183
|
+
}
|
|
184
|
+
: undefined,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Not JSON — keep walking backwards.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
function numberOrUndefined(value) {
|
|
195
|
+
return typeof value === "number" ? value : undefined;
|
|
196
|
+
}
|
|
197
|
+
function isErrorSubtype(subtype) {
|
|
198
|
+
return subtype !== undefined && subtype.startsWith("error");
|
|
199
|
+
}
|
|
200
|
+
async function runValidation(argv, cwd) {
|
|
201
|
+
const [bin, ...rest] = argv;
|
|
202
|
+
return new Promise((resolve) => {
|
|
203
|
+
execFile(bin, rest, { cwd, windowsHide: true, encoding: "utf8", timeout: 30_000 }, (error, stdout, stderr) => {
|
|
204
|
+
const output = `${stdout}${stderr}`.trim();
|
|
205
|
+
resolve({ pass: !error, output: output.length > 0 ? output.slice(-400) : "(no output)" });
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
function saveReport(report, home) {
|
|
210
|
+
const dir = path.join(configDir(home), "benchmarks");
|
|
211
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
212
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
213
|
+
const file = path.join(dir, `benchmark-${stamp}.json`);
|
|
214
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
215
|
+
fs.writeFileSync(tmp, JSON.stringify(report, null, 2) + "\n", "utf8");
|
|
216
|
+
fs.renameSync(tmp, file);
|
|
217
|
+
return file;
|
|
218
|
+
}
|
|
219
|
+
function renderTable(metrics, options) {
|
|
220
|
+
if (options.quiet)
|
|
221
|
+
return;
|
|
222
|
+
const header = ["task", "run", "duration", "GLM calls", "retries", "tokens i/o", "tests", "success", "intervention"];
|
|
223
|
+
const rows = metrics.map((m) => [
|
|
224
|
+
m.task,
|
|
225
|
+
String(m.run),
|
|
226
|
+
formatMs(m.durationMs),
|
|
227
|
+
m.glmCalls === null ? "-" : String(m.glmCalls),
|
|
228
|
+
"-", // retry count: not exposed by Claude Code (specs/benchmark.md)
|
|
229
|
+
m.inputTokens === null || m.outputTokens === null ? "-" : `${m.inputTokens}/${m.outputTokens}`,
|
|
230
|
+
m.testsPass ? "PASS" : "FAIL",
|
|
231
|
+
m.success ? "yes" : "no",
|
|
232
|
+
m.interventionNeeded ? "needed" : "none",
|
|
233
|
+
]);
|
|
234
|
+
const widths = header.map((_, column) => Math.max(header[column].length, ...rows.map((row) => row[column].length)));
|
|
235
|
+
const line = (cells) => cells.map((cell, column) => cell.padEnd(widths[column])).join(" ");
|
|
236
|
+
process.stdout.write([line(header), ...rows.map(line)].join("\n") + "\n");
|
|
237
|
+
for (const m of metrics.filter((m) => !m.testsPass)) {
|
|
238
|
+
process.stdout.write(`\n${m.task} (run ${m.run}) validation output:\n${m.testsOutput}\n`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function formatMs(ms) {
|
|
242
|
+
return ms >= 10_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms / 100) / 10}s`;
|
|
243
|
+
}
|
|
244
|
+
async function defaultConfirm() {
|
|
245
|
+
const response = await prompts({
|
|
246
|
+
type: "confirm",
|
|
247
|
+
name: "ok",
|
|
248
|
+
message: "Benchmark will make real GLM API calls. Continue?",
|
|
249
|
+
initial: false,
|
|
250
|
+
});
|
|
251
|
+
return response.ok === true;
|
|
252
|
+
}
|
|
@@ -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/dist/core/errors.js
CHANGED
|
@@ -96,6 +96,12 @@ export const Errors = {
|
|
|
96
96
|
],
|
|
97
97
|
exitCode: ExitCode.InvalidArgs,
|
|
98
98
|
}),
|
|
99
|
+
invalidArgs: (detail, hint) => new GlmRouterError({
|
|
100
|
+
name: "INVALID_ARGS",
|
|
101
|
+
message: detail,
|
|
102
|
+
hint: hint && hint.length > 0 ? hint : undefined,
|
|
103
|
+
exitCode: ExitCode.InvalidArgs,
|
|
104
|
+
}),
|
|
99
105
|
gitNotFound: () => new GlmRouterError({
|
|
100
106
|
name: "GIT_NOT_FOUND",
|
|
101
107
|
message: "git was not found on PATH.",
|
package/dist/core/process.js
CHANGED
|
@@ -10,51 +10,84 @@ import { Errors } from "./errors.js";
|
|
|
10
10
|
export function spawnAgent(binPath, options) {
|
|
11
11
|
const { args, cwd, env, interactive = true } = options;
|
|
12
12
|
return new Promise((resolve, reject) => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
catch (error) {
|
|
24
|
-
reject(Errors.childAgentFailed(errorMessage(error)));
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
const signals = ["SIGINT", "SIGTERM"];
|
|
28
|
-
const handlers = new Map();
|
|
29
|
-
for (const signal of signals) {
|
|
30
|
-
const handler = () => {
|
|
31
|
-
if (child.killed)
|
|
32
|
-
return;
|
|
33
|
-
try {
|
|
34
|
-
child.kill(signal);
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
// Child already gone; the exit event settles the promise.
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
handlers.set(signal, handler);
|
|
41
|
-
process.on(signal, handler);
|
|
42
|
-
}
|
|
43
|
-
const cleanup = () => {
|
|
44
|
-
for (const [signal, handler] of handlers) {
|
|
45
|
-
process.removeListener(signal, handler);
|
|
46
|
-
}
|
|
47
|
-
};
|
|
13
|
+
const child = spawn(binPath, args, {
|
|
14
|
+
cwd,
|
|
15
|
+
env,
|
|
16
|
+
stdio: interactive ? "inherit" : ["ignore", "inherit", "inherit"],
|
|
17
|
+
shell: false,
|
|
18
|
+
windowsHide: false,
|
|
19
|
+
});
|
|
20
|
+
const handlers = forwardSignals(child);
|
|
48
21
|
child.on("error", (error) => {
|
|
49
|
-
|
|
22
|
+
removeSignals(handlers);
|
|
50
23
|
reject(Errors.childAgentFailed(errorMessage(error)));
|
|
51
24
|
});
|
|
52
25
|
child.on("exit", (code) => {
|
|
53
|
-
|
|
26
|
+
removeSignals(handlers);
|
|
54
27
|
resolve(code ?? 1);
|
|
55
28
|
});
|
|
56
29
|
});
|
|
57
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Like spawnAgent but pipes stdout/stderr instead of inheriting them and
|
|
33
|
+
* resolves the captured output (specs/benchmark.md) — same no-shell rule and
|
|
34
|
+
* signal forwarding. Used by `benchmark` to parse the child's result JSON.
|
|
35
|
+
*/
|
|
36
|
+
export function spawnAgentCapture(binPath, options) {
|
|
37
|
+
const { args, cwd, env } = options;
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const child = spawn(binPath, args, {
|
|
40
|
+
cwd,
|
|
41
|
+
env,
|
|
42
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
43
|
+
shell: false,
|
|
44
|
+
windowsHide: false,
|
|
45
|
+
});
|
|
46
|
+
let stdout = "";
|
|
47
|
+
let stderr = "";
|
|
48
|
+
child.stdout?.setEncoding("utf8");
|
|
49
|
+
child.stderr?.setEncoding("utf8");
|
|
50
|
+
child.stdout?.on("data", (chunk) => {
|
|
51
|
+
stdout += chunk;
|
|
52
|
+
});
|
|
53
|
+
child.stderr?.on("data", (chunk) => {
|
|
54
|
+
stderr += chunk;
|
|
55
|
+
});
|
|
56
|
+
const handlers = forwardSignals(child);
|
|
57
|
+
child.on("error", (error) => {
|
|
58
|
+
removeSignals(handlers);
|
|
59
|
+
reject(Errors.childAgentFailed(errorMessage(error)));
|
|
60
|
+
});
|
|
61
|
+
child.on("exit", (code) => {
|
|
62
|
+
removeSignals(handlers);
|
|
63
|
+
resolve({ code: code ?? 1, stdout, stderr });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** Install SIGINT/SIGTERM forwarding; returns the handlers for cleanup. */
|
|
68
|
+
function forwardSignals(child) {
|
|
69
|
+
const handlers = new Map();
|
|
70
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
71
|
+
const handler = () => {
|
|
72
|
+
if (child.killed)
|
|
73
|
+
return;
|
|
74
|
+
try {
|
|
75
|
+
child.kill(signal);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Child already gone; the exit event settles the promise.
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
handlers.set(signal, handler);
|
|
82
|
+
process.on(signal, handler);
|
|
83
|
+
}
|
|
84
|
+
return handlers;
|
|
85
|
+
}
|
|
86
|
+
function removeSignals(handlers) {
|
|
87
|
+
for (const [signal, handler] of handlers) {
|
|
88
|
+
process.removeListener(signal, handler);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
58
91
|
function errorMessage(error) {
|
|
59
92
|
return error instanceof Error ? error.message : String(error);
|
|
60
93
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export const BENCHMARK_TASKS = [
|
|
2
|
+
{
|
|
3
|
+
id: "fn-reverse",
|
|
4
|
+
description: "implement reverseWords from a stub until node test.js passes",
|
|
5
|
+
files: {
|
|
6
|
+
"src/util.js": [
|
|
7
|
+
"// Implement reverseWords(str): reverse the ORDER of the words in str.",
|
|
8
|
+
'// Example: reverseWords("hello world") === "world hello".',
|
|
9
|
+
"// Collapse extra whitespace between words and trim the ends.",
|
|
10
|
+
"function reverseWords(str) {",
|
|
11
|
+
" // TODO: implement",
|
|
12
|
+
"}",
|
|
13
|
+
"",
|
|
14
|
+
"module.exports = { reverseWords };",
|
|
15
|
+
"",
|
|
16
|
+
].join("\n"),
|
|
17
|
+
"test.js": [
|
|
18
|
+
'const assert = require("node:assert");',
|
|
19
|
+
'const { reverseWords } = require("./src/util.js");',
|
|
20
|
+
'assert.strictEqual(reverseWords("hello world"), "world hello");',
|
|
21
|
+
'assert.strictEqual(reverseWords("a"), "a");',
|
|
22
|
+
'assert.strictEqual(reverseWords(" spaced out "), "out spaced");',
|
|
23
|
+
'assert.strictEqual(reverseWords(""), "");',
|
|
24
|
+
'console.log("PASS");',
|
|
25
|
+
"",
|
|
26
|
+
].join("\n"),
|
|
27
|
+
},
|
|
28
|
+
prompt: [
|
|
29
|
+
"Implement reverseWords in src/util.js so that `node test.js` passes.",
|
|
30
|
+
"Words are separated by whitespace; collapse repeats and trim the ends.",
|
|
31
|
+
"Do not modify test.js. Verify by running `node test.js` with Bash.",
|
|
32
|
+
].join(" "),
|
|
33
|
+
validate: ["node", "test.js"],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "fix-bug",
|
|
37
|
+
description: "repair an even-length median bug until node test.js passes",
|
|
38
|
+
files: {
|
|
39
|
+
"stats.js": [
|
|
40
|
+
"// median(values) returns the median of a non-empty list of numbers.",
|
|
41
|
+
"function median(values) {",
|
|
42
|
+
" const sorted = [...values].sort((a, b) => a - b);",
|
|
43
|
+
" const mid = Math.floor(sorted.length / 2);",
|
|
44
|
+
" return sorted[mid]; // BUG: wrong for even-length lists",
|
|
45
|
+
"}",
|
|
46
|
+
"",
|
|
47
|
+
"module.exports = { median };",
|
|
48
|
+
"",
|
|
49
|
+
].join("\n"),
|
|
50
|
+
"test.js": [
|
|
51
|
+
'const assert = require("node:assert");',
|
|
52
|
+
'const { median } = require("./stats.js");',
|
|
53
|
+
"assert.strictEqual(median([3, 1, 2]), 2);",
|
|
54
|
+
"assert.strictEqual(median([4, 1, 3, 2]), 2.5);",
|
|
55
|
+
"assert.strictEqual(median([5]), 5);",
|
|
56
|
+
'console.log("PASS");',
|
|
57
|
+
"",
|
|
58
|
+
].join("\n"),
|
|
59
|
+
},
|
|
60
|
+
prompt: [
|
|
61
|
+
"stats.js has a bug: median() returns the wrong result for even-length lists.",
|
|
62
|
+
"Fix it so that `node test.js` passes (even lists return the average of the two middle values).",
|
|
63
|
+
"Do not modify test.js. Verify by running `node test.js` with Bash.",
|
|
64
|
+
].join(" "),
|
|
65
|
+
validate: ["node", "test.js"],
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
export function benchmarkTaskById(id) {
|
|
69
|
+
return BENCHMARK_TASKS.find((task) => task.id === id);
|
|
70
|
+
}
|
package/package.json
CHANGED
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "glm-coding-router",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "GLM Coding Plan workers for Claude Code and Codex",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"author": "hieu9721",
|
|
8
|
-
"repository": {
|
|
9
|
-
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/hieu9721/GLM-coding-router.git"
|
|
11
|
-
},
|
|
12
|
-
"bin": {
|
|
13
|
-
"glm-router": "./dist/cli.js",
|
|
14
|
-
"glm-chat": "./dist/bin/glm-chat.js",
|
|
15
|
-
"glm-worker": "./dist/bin/glm-worker.js",
|
|
16
|
-
"glm-review": "./dist/bin/glm-review.js",
|
|
17
|
-
"glm-fast": "./dist/bin/glm-fast.js"
|
|
18
|
-
},
|
|
19
|
-
"files": [
|
|
20
|
-
"dist"
|
|
21
|
-
],
|
|
22
|
-
"scripts": {
|
|
23
|
-
"dev": "tsx src/cli.ts",
|
|
24
|
-
"build": "tsc",
|
|
25
|
-
"test": "vitest run",
|
|
26
|
-
"test:watch": "vitest",
|
|
27
|
-
"lint": "eslint src tests",
|
|
28
|
-
"prepublishOnly": "npm run build && npm test"
|
|
29
|
-
},
|
|
30
|
-
"engines": {
|
|
31
|
-
"node": ">=20"
|
|
32
|
-
},
|
|
33
|
-
"dependencies": {
|
|
34
|
-
"commander": "^15.0.0",
|
|
35
|
-
"prompts": "^2.4.2",
|
|
36
|
-
"zod": "^4.6.5"
|
|
37
|
-
},
|
|
38
|
-
"devDependencies": {
|
|
39
|
-
"@types/node": "^22.20.3",
|
|
40
|
-
"@types/prompts": "^2.4.9",
|
|
41
|
-
"eslint": "^9.39.5",
|
|
42
|
-
"tsx": "^4.23.13",
|
|
43
|
-
"typescript": "^5.9.3",
|
|
44
|
-
"typescript-eslint": "^8.70.0",
|
|
45
|
-
"vitest": "^5.0.1"
|
|
46
|
-
}
|
|
47
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "glm-coding-router",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "GLM Coding Plan workers for Claude Code and Codex",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "hieu9721",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/hieu9721/GLM-coding-router.git"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"glm-router": "./dist/cli.js",
|
|
14
|
+
"glm-chat": "./dist/bin/glm-chat.js",
|
|
15
|
+
"glm-worker": "./dist/bin/glm-worker.js",
|
|
16
|
+
"glm-review": "./dist/bin/glm-review.js",
|
|
17
|
+
"glm-fast": "./dist/bin/glm-fast.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"dev": "tsx src/cli.ts",
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:watch": "vitest",
|
|
27
|
+
"lint": "eslint src tests",
|
|
28
|
+
"prepublishOnly": "npm run build && npm test"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"commander": "^15.0.0",
|
|
35
|
+
"prompts": "^2.4.2",
|
|
36
|
+
"zod": "^4.6.5"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^22.20.3",
|
|
40
|
+
"@types/prompts": "^2.4.9",
|
|
41
|
+
"eslint": "^9.39.5",
|
|
42
|
+
"tsx": "^4.23.13",
|
|
43
|
+
"typescript": "^5.9.3",
|
|
44
|
+
"typescript-eslint": "^8.70.0",
|
|
45
|
+
"vitest": "^5.0.1"
|
|
46
|
+
}
|
|
47
|
+
}
|