glm-coding-router 1.1.2 → 2.1.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/LICENSE +21 -21
- package/README.md +542 -426
- package/dist/bin/glm-review.js +28 -3
- package/dist/bin/glm-worker.js +30 -4
- package/dist/budget/estimator.js +218 -0
- package/dist/budget/manager.js +223 -0
- package/dist/cli.js +46 -3
- package/dist/commands/dashboard.js +348 -0
- package/dist/commands/doctor-auth.js +107 -0
- package/dist/commands/doctor-command.js +171 -41
- package/dist/commands/landing.js +47 -0
- package/dist/commands/runs.js +568 -0
- package/dist/commands/status.js +28 -15
- package/dist/commands/usage.js +34 -58
- package/dist/commands/watch.js +289 -0
- package/dist/core/config.js +61 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/key-inspector.js +45 -0
- package/dist/core/paths.js +32 -0
- package/dist/core/process.js +83 -0
- package/dist/core/prompt.js +18 -5
- package/dist/core/routing-flags.js +59 -0
- package/dist/core/user-env.js +17 -7
- package/dist/core/zai-quota.js +148 -0
- package/dist/events/bus.js +64 -0
- package/dist/events/claude-adapter.js +416 -0
- package/dist/events/types.js +9 -0
- package/dist/handoff/bundle.js +203 -0
- package/dist/handoff/parent-handoff.js +48 -0
- package/dist/mcp/server.js +45 -1
- package/dist/routing/glm-routing.js +131 -0
- package/dist/runs/checkpoint.js +204 -0
- package/dist/runs/drain.js +165 -0
- package/dist/runs/heartbeat.js +45 -0
- package/dist/runs/registry.js +350 -0
- package/dist/runs/store.js +186 -0
- package/dist/runs/ulid.js +112 -0
- package/dist/runs/worker-run.js +672 -0
- package/dist/templates/agents-block.js +53 -44
- package/dist/templates/claude-block.js +56 -47
- package/dist/templates/glm-delegation-skill.js +76 -65
- package/dist/tui/command-ui.js +158 -0
- package/dist/tui/progress.js +338 -0
- package/dist/tui/render.js +144 -0
- package/package.json +1 -1
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadConfig } from "../core/config.js";
|
|
4
|
+
import { Errors } from "../core/errors.js";
|
|
5
|
+
import { logger } from "../core/logging.js";
|
|
6
|
+
import { runDir } from "../core/paths.js";
|
|
7
|
+
import { resolveZaiApiKey } from "../core/zai-key.js";
|
|
8
|
+
import { isOrphaned, listActive, listHistory } from "../runs/registry.js";
|
|
9
|
+
import { readEvents } from "../runs/store.js";
|
|
10
|
+
import { ansi, createWriter, paint } from "../tui/render.js";
|
|
11
|
+
import { describeWindow, fetchZaiQuota } from "../core/zai-quota.js";
|
|
12
|
+
import { emitJson } from "./context.js";
|
|
13
|
+
// Display-only zone thresholds for the dashboard's one-word verdict. Phase E
|
|
14
|
+
// owns the real routing thresholds (routing.preferFlashBelow etc.); these must
|
|
15
|
+
// stay independent so restyling the dashboard can never change a routing
|
|
16
|
+
// decision, and vice versa.
|
|
17
|
+
const ZONE_OK_ABOVE_RATIO = 0.3;
|
|
18
|
+
const ZONE_LOW_ABOVE_RATIO = 0.15;
|
|
19
|
+
/** Doc §8: recent runs are the last few, not the whole history. */
|
|
20
|
+
const RECENT_LIMIT = 5;
|
|
21
|
+
const DEFAULT_INTERVAL_SEC = 2;
|
|
22
|
+
/**
|
|
23
|
+
* glm-router dashboard — quota + active runs + recent runs + errors in one
|
|
24
|
+
* frame (doc §8). Non-TTY (or --json) prints exactly ONE snapshot and exits 0,
|
|
25
|
+
* so the command is pipeable and a monitoring outage never looks like a broken
|
|
26
|
+
* tool; TTY repaints the whole frame every --interval seconds until Ctrl+C.
|
|
27
|
+
*/
|
|
28
|
+
export async function dashboardCommand(options, deps = {}) {
|
|
29
|
+
const intervalSec = options.interval ?? DEFAULT_INTERVAL_SEC;
|
|
30
|
+
if (!Number.isFinite(intervalSec) || intervalSec <= 0) {
|
|
31
|
+
throw Errors.invalidArgs(`--interval expects a positive number of seconds, got "${String(options.interval)}"`);
|
|
32
|
+
}
|
|
33
|
+
const stream = deps.stdout ?? process.stdout;
|
|
34
|
+
const isTTY = deps.isTTY ?? stream.isTTY === true;
|
|
35
|
+
if (options.json || !isTTY) {
|
|
36
|
+
const snapshot = await buildSnapshot(deps);
|
|
37
|
+
if (options.json) {
|
|
38
|
+
emitJson(snapshotJson(snapshot));
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const writer = createWriter(stream); // non-TTY: color off, no ANSI
|
|
42
|
+
writer.line(renderSnapshot(snapshot, writer));
|
|
43
|
+
}
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
// TTY: repaint by clearing and rewriting — never scroll. All cursor control
|
|
47
|
+
// goes through render.ts's ansi helpers (D1: no escape codes here).
|
|
48
|
+
const writer = createWriter(stream);
|
|
49
|
+
writer.write(ansi.hideCursor);
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
let paintedLines = 0;
|
|
52
|
+
let painting = false;
|
|
53
|
+
let stopped = false;
|
|
54
|
+
const stop = () => {
|
|
55
|
+
if (stopped) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
stopped = true;
|
|
59
|
+
clearInterval(timer);
|
|
60
|
+
process.removeListener("SIGINT", onSigint);
|
|
61
|
+
writer.write(ansi.showCursor);
|
|
62
|
+
resolve(0);
|
|
63
|
+
};
|
|
64
|
+
const onSigint = () => stop();
|
|
65
|
+
const repaint = async () => {
|
|
66
|
+
if (stopped || painting) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
painting = true;
|
|
70
|
+
try {
|
|
71
|
+
const frame = renderSnapshot(await buildSnapshot(deps), writer);
|
|
72
|
+
if (stopped) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (paintedLines > 0) {
|
|
76
|
+
const clear = ansi.cursorUp(1) + ansi.clearLine;
|
|
77
|
+
writer.write(clear.repeat(paintedLines));
|
|
78
|
+
}
|
|
79
|
+
writer.write(`${frame}\n`);
|
|
80
|
+
paintedLines = frame.split("\n").length;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
// One failed repaint (e.g. a transient quota timeout) must not kill
|
|
84
|
+
// the loop; the next tick replaces the frame.
|
|
85
|
+
logger.debug(`dashboard: repaint failed: ${errorMessage(error)}`);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
painting = false;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const timer = setInterval(() => {
|
|
92
|
+
void repaint();
|
|
93
|
+
}, intervalSec * 1000);
|
|
94
|
+
process.on("SIGINT", onSigint);
|
|
95
|
+
void repaint();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** Gathers one frame from the quota endpoint and the run registry. */
|
|
99
|
+
async function buildSnapshot(deps) {
|
|
100
|
+
const home = deps.home ?? os.homedir();
|
|
101
|
+
const now = deps.now ?? (() => new Date());
|
|
102
|
+
const nowMs = now().getTime();
|
|
103
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
104
|
+
const resolveKey = deps.resolveKey ?? (() => resolveZaiApiKey());
|
|
105
|
+
// Fail-open by contract: a monitoring outage must never fail the dashboard
|
|
106
|
+
// (or block work) — it renders as one unavailable line, exit stays 0.
|
|
107
|
+
let quota;
|
|
108
|
+
const resolved = resolveKey();
|
|
109
|
+
if (resolved === undefined) {
|
|
110
|
+
quota = { ok: false, level: null, zone: "unknown", windows: [], error: "no Z.ai API key configured" };
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
try {
|
|
114
|
+
const data = await fetchZaiQuota(resolved.key, fetchImpl);
|
|
115
|
+
const windows = (data.limits ?? []).map(windowView);
|
|
116
|
+
quota = {
|
|
117
|
+
ok: true,
|
|
118
|
+
level: data.level ?? null,
|
|
119
|
+
zone: zoneFor(windows.map((window) => window.remainingRatio)),
|
|
120
|
+
windows,
|
|
121
|
+
error: null,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
quota = {
|
|
126
|
+
ok: false,
|
|
127
|
+
level: null,
|
|
128
|
+
zone: "unknown",
|
|
129
|
+
windows: [],
|
|
130
|
+
error: errorMessage(error),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const active = listActive(home).map((run) => ({
|
|
135
|
+
id: run.id,
|
|
136
|
+
state: run.state,
|
|
137
|
+
kind: run.kind,
|
|
138
|
+
model: run.model,
|
|
139
|
+
elapsedMs: elapsedSince(run.startedAt, nowMs),
|
|
140
|
+
cwd: run.cwd,
|
|
141
|
+
orphaned: isOrphaned(run, { now: () => nowMs, isAlive: deps.isAlive }),
|
|
142
|
+
}));
|
|
143
|
+
// `listHistory` also returns active runs (their directory exists from the
|
|
144
|
+
// first event, with no summary yet — which reads as CRASHED). Recent Runs is
|
|
145
|
+
// "how runs ended": the live ones are the section above, and showing them
|
|
146
|
+
// here as crashed would be actively misleading.
|
|
147
|
+
const activeIds = new Set(active.map((row) => row.id));
|
|
148
|
+
const recent = listHistory(home)
|
|
149
|
+
.filter((ref) => !activeIds.has(ref.id))
|
|
150
|
+
.slice(0, RECENT_LIMIT)
|
|
151
|
+
.map((ref) => ({
|
|
152
|
+
id: ref.id,
|
|
153
|
+
state: ref.state,
|
|
154
|
+
durationMs: ref.summary?.durationMs ?? null,
|
|
155
|
+
turns: ref.summary?.turns ?? null,
|
|
156
|
+
files: ref.summary?.filesChanged.length ?? null,
|
|
157
|
+
reason: failedReason(home, ref),
|
|
158
|
+
}));
|
|
159
|
+
return { generatedAt: new Date(nowMs).toISOString(), quota, active, recent, model: loadConfig(home).models.main };
|
|
160
|
+
}
|
|
161
|
+
/** The machine shape — mirrors the text frame key for key. */
|
|
162
|
+
function snapshotJson(snapshot) {
|
|
163
|
+
return {
|
|
164
|
+
generatedAt: snapshot.generatedAt,
|
|
165
|
+
model: snapshot.model,
|
|
166
|
+
quota: snapshot.quota,
|
|
167
|
+
active: snapshot.active,
|
|
168
|
+
recent: snapshot.recent,
|
|
169
|
+
errors: snapshot.recent
|
|
170
|
+
.filter((row) => row.state === "FAILED" || row.state === "CRASHED")
|
|
171
|
+
.map((row) => ({ id: row.id, state: row.state, reason: row.reason })),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function renderSnapshot(snapshot, writer) {
|
|
175
|
+
const lines = [`GLM Coding Router — dashboard ${formatLocalTime(snapshot.generatedAt)}`, ""];
|
|
176
|
+
if (snapshot.quota.ok) {
|
|
177
|
+
const level = snapshot.quota.level !== null ? ` (level: ${snapshot.quota.level})` : "";
|
|
178
|
+
lines.push(`Quota${level} — ${paintZone(writer, snapshot.quota.zone)}`);
|
|
179
|
+
if (snapshot.quota.windows.length === 0) {
|
|
180
|
+
lines.push(" (no quota windows reported)");
|
|
181
|
+
}
|
|
182
|
+
for (const window of snapshot.quota.windows) {
|
|
183
|
+
const used = window.used !== null ? String(window.used) : "?";
|
|
184
|
+
const total = window.limit !== null ? String(window.limit) : "?";
|
|
185
|
+
const percent = window.percent !== null ? ` (${String(window.percent)}%)` : "";
|
|
186
|
+
const resets = window.resetsAt !== null ? ` · resets ${window.resetsAt}` : "";
|
|
187
|
+
lines.push(` ${window.window.padEnd(15)} ${used} / ${total} credits${percent} · ` +
|
|
188
|
+
`${paintZone(writer, zoneFor([window.remainingRatio]))}${resets}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
lines.push("Quota", ` quota unavailable — ${snapshot.quota.error ?? "unknown error"}`);
|
|
193
|
+
}
|
|
194
|
+
lines.push("", "Active Runs");
|
|
195
|
+
if (snapshot.active.length === 0) {
|
|
196
|
+
lines.push(" (none)");
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
const rows = snapshot.active.map((row) => [
|
|
200
|
+
row.id.slice(-6),
|
|
201
|
+
row.state,
|
|
202
|
+
row.kind,
|
|
203
|
+
row.model,
|
|
204
|
+
row.elapsedMs !== null ? formatDuration(row.elapsedMs) : "—",
|
|
205
|
+
(path.basename(row.cwd) || row.cwd) + (row.orphaned ? paint(writer, "yellow", " (orphaned)") : ""),
|
|
206
|
+
]);
|
|
207
|
+
for (const line of renderRows(rows)) {
|
|
208
|
+
lines.push(` ${line}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
lines.push("", "Recent Runs");
|
|
212
|
+
if (snapshot.recent.length === 0) {
|
|
213
|
+
lines.push(" (none)");
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
for (const row of snapshot.recent) {
|
|
217
|
+
const numbers = row.durationMs === null && row.turns === null && row.files === null
|
|
218
|
+
? "—"
|
|
219
|
+
: [
|
|
220
|
+
row.durationMs !== null ? formatDuration(row.durationMs) : null,
|
|
221
|
+
row.turns !== null ? `${String(row.turns)} ${row.turns === 1 ? "turn" : "turns"}` : null,
|
|
222
|
+
row.files !== null ? `${String(row.files)} ${row.files === 1 ? "file" : "files"}` : null,
|
|
223
|
+
]
|
|
224
|
+
.filter((part) => part !== null)
|
|
225
|
+
.join(" · ");
|
|
226
|
+
lines.push(` ${row.id.slice(-6)} ${row.state.padEnd(10)} ${numbers}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const failed = snapshot.recent.filter((row) => row.state === "FAILED" || row.state === "CRASHED");
|
|
230
|
+
lines.push("", "Errors");
|
|
231
|
+
if (failed.length === 0) {
|
|
232
|
+
lines.push(" (none)");
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
for (const row of failed) {
|
|
236
|
+
const reason = row.reason !== null ? ` — ${row.reason}` : "";
|
|
237
|
+
lines.push(` ${paint(writer, "red", `✗ ${row.id.slice(-6)} ${row.state}${reason}`)}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
lines.push("", `Model ${snapshot.model}`);
|
|
241
|
+
return lines.join("\n");
|
|
242
|
+
}
|
|
243
|
+
function paintZone(writer, zone) {
|
|
244
|
+
const style = zone === "ok" ? "green" : zone === "low" ? "yellow" : zone === "critical" ? "red" : null;
|
|
245
|
+
return style !== null ? paint(writer, style, zone) : zone;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Worst window wins, matching how the router will judge the budget: a healthy
|
|
249
|
+
* 5-hour window cannot paper over an exhausted weekly one.
|
|
250
|
+
*/
|
|
251
|
+
function zoneFor(ratios) {
|
|
252
|
+
const known = ratios.filter((ratio) => typeof ratio === "number" && Number.isFinite(ratio));
|
|
253
|
+
if (known.length === 0) {
|
|
254
|
+
return "unknown";
|
|
255
|
+
}
|
|
256
|
+
const worst = Math.min(...known);
|
|
257
|
+
if (worst > ZONE_OK_ABOVE_RATIO) {
|
|
258
|
+
return "ok";
|
|
259
|
+
}
|
|
260
|
+
if (worst > ZONE_LOW_ABOVE_RATIO) {
|
|
261
|
+
return "low";
|
|
262
|
+
}
|
|
263
|
+
return "critical";
|
|
264
|
+
}
|
|
265
|
+
function windowView(limit) {
|
|
266
|
+
const used = typeof limit.currentValue === "number" ? limit.currentValue : null;
|
|
267
|
+
const total = typeof limit.usage === "number" ? limit.usage : null;
|
|
268
|
+
const percent = typeof limit.percentage === "number"
|
|
269
|
+
? limit.percentage
|
|
270
|
+
: used !== null && total !== null && total > 0
|
|
271
|
+
? Math.round((used / total) * 100)
|
|
272
|
+
: null;
|
|
273
|
+
const remainingRatio = typeof limit.remaining === "number" && total !== null && total > 0
|
|
274
|
+
? limit.remaining / total
|
|
275
|
+
: percent !== null
|
|
276
|
+
? (100 - percent) / 100
|
|
277
|
+
: null;
|
|
278
|
+
const resetsAt = typeof limit.nextResetTime === "number" && Number.isFinite(limit.nextResetTime)
|
|
279
|
+
? new Date(limit.nextResetTime).toISOString()
|
|
280
|
+
: null;
|
|
281
|
+
return { window: describeWindow(limit), used, limit: total, percent, remainingRatio, resetsAt };
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Why a run failed. `RunSummary` carries no reason field today (Phase B's
|
|
285
|
+
* choice), so the line falls back to the run's own `RunFailed` event — read
|
|
286
|
+
* only for FAILED/CRASHED entries, at most RECENT_LIMIT of them.
|
|
287
|
+
*/
|
|
288
|
+
function failedReason(home, ref) {
|
|
289
|
+
if (ref.state !== "FAILED" && ref.state !== "CRASHED") {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
const fromSummary = ref.summary !== null ? ref.summary.reason : undefined;
|
|
293
|
+
if (typeof fromSummary === "string" && fromSummary.length > 0) {
|
|
294
|
+
return fromSummary;
|
|
295
|
+
}
|
|
296
|
+
const events = readEvents(runDir(home, ref.date, ref.id));
|
|
297
|
+
for (let index = events.length - 1; index >= 0; index--) {
|
|
298
|
+
const event = events[index];
|
|
299
|
+
if (event.type === "RunFailed") {
|
|
300
|
+
return event.reason;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
/** Pads each column to the widest cell — a model or cwd of any length must not break the frame. */
|
|
306
|
+
function renderRows(rows) {
|
|
307
|
+
const widths = [];
|
|
308
|
+
for (const row of rows) {
|
|
309
|
+
row.forEach((cell, column) => {
|
|
310
|
+
widths[column] = Math.max(widths[column] ?? 0, cell.length);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return rows.map((row) => row.map((cell, column) => cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd());
|
|
314
|
+
}
|
|
315
|
+
/** Milliseconds since an ISO timestamp; null when unparseable. */
|
|
316
|
+
function elapsedSince(iso, nowMs) {
|
|
317
|
+
const startedMs = Date.parse(iso);
|
|
318
|
+
return Number.isFinite(startedMs) ? Math.max(0, nowMs - startedMs) : null;
|
|
319
|
+
}
|
|
320
|
+
/** Local time built from the date's own components so no locale can change it. */
|
|
321
|
+
function formatLocalTime(iso) {
|
|
322
|
+
const date = new Date(iso);
|
|
323
|
+
if (Number.isNaN(date.getTime())) {
|
|
324
|
+
return iso;
|
|
325
|
+
}
|
|
326
|
+
const p2 = (value) => String(value).padStart(2, "0");
|
|
327
|
+
return (`${date.getFullYear()}-${p2(date.getMonth() + 1)}-${p2(date.getDate())} ` +
|
|
328
|
+
`${p2(date.getHours())}:${p2(date.getMinutes())}:${p2(date.getSeconds())}`);
|
|
329
|
+
}
|
|
330
|
+
/** "5.4s", "2m 13s", "1h 04m" — one decimal below a minute, where it is honest. */
|
|
331
|
+
function formatDuration(durationMs) {
|
|
332
|
+
if (!Number.isFinite(durationMs) || durationMs < 0) {
|
|
333
|
+
return "—";
|
|
334
|
+
}
|
|
335
|
+
const seconds = durationMs / 1000;
|
|
336
|
+
if (seconds < 60) {
|
|
337
|
+
return `${seconds.toFixed(1)}s`;
|
|
338
|
+
}
|
|
339
|
+
const totalSeconds = Math.floor(seconds);
|
|
340
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
341
|
+
if (minutes < 60) {
|
|
342
|
+
return `${minutes}m ${String(totalSeconds % 60).padStart(2, "0")}s`;
|
|
343
|
+
}
|
|
344
|
+
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
345
|
+
}
|
|
346
|
+
function errorMessage(error) {
|
|
347
|
+
return error instanceof Error ? error.message : String(error);
|
|
348
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure credential-authentication state and doctor verdict logic
|
|
3
|
+
* (specs/terminal-ui-doctor.md §D, §E). Kept separate from doctor-command.ts
|
|
4
|
+
* so the precedence rules are unit-testable without mocking fetch/stdout.
|
|
5
|
+
*/
|
|
6
|
+
import { ZaiQuotaError, fetchZaiQuota } from "../core/zai-quota.js";
|
|
7
|
+
/**
|
|
8
|
+
* Authenticate exactly the effective key once. Never retries with a
|
|
9
|
+
* different key on rejection (spec §C.6) — that would hide the key the next
|
|
10
|
+
* worker will actually use.
|
|
11
|
+
*/
|
|
12
|
+
export async function authenticateZaiKey(key, fetchImpl, offline) {
|
|
13
|
+
if (offline) {
|
|
14
|
+
return {
|
|
15
|
+
state: "skipped",
|
|
16
|
+
checked: false,
|
|
17
|
+
method: "zai-quota-monitor",
|
|
18
|
+
reason: "offline",
|
|
19
|
+
detail: "Online authentication was not performed (--offline).",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (!key) {
|
|
23
|
+
return {
|
|
24
|
+
state: "missing",
|
|
25
|
+
checked: false,
|
|
26
|
+
method: "zai-quota-monitor",
|
|
27
|
+
reason: "missing-key",
|
|
28
|
+
detail: "No ZAI_API_KEY was found in the process environment or the per-user store.",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
await fetchZaiQuota(key, fetchImpl);
|
|
33
|
+
return {
|
|
34
|
+
state: "verified",
|
|
35
|
+
checked: true,
|
|
36
|
+
method: "zai-quota-monitor",
|
|
37
|
+
reason: "accepted",
|
|
38
|
+
detail: "The Z.ai monitor endpoint accepted the selected key.",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (!(error instanceof ZaiQuotaError)) {
|
|
43
|
+
return {
|
|
44
|
+
state: "unverified",
|
|
45
|
+
checked: true,
|
|
46
|
+
method: "zai-quota-monitor",
|
|
47
|
+
reason: "network-error",
|
|
48
|
+
detail: "Authentication could not be completed.",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
switch (error.kind) {
|
|
52
|
+
case "unauthorized":
|
|
53
|
+
return { state: "rejected", checked: true, method: "zai-quota-monitor", reason: "http-401", detail: error.message };
|
|
54
|
+
case "forbidden":
|
|
55
|
+
return { state: "rejected", checked: true, method: "zai-quota-monitor", reason: "http-403", detail: error.message };
|
|
56
|
+
case "rate-limited":
|
|
57
|
+
return { state: "unverified", checked: true, method: "zai-quota-monitor", reason: "rate-limited", detail: error.message };
|
|
58
|
+
case "http":
|
|
59
|
+
return { state: "unverified", checked: true, method: "zai-quota-monitor", reason: "http-error", detail: error.message };
|
|
60
|
+
case "network":
|
|
61
|
+
return {
|
|
62
|
+
state: "unverified",
|
|
63
|
+
checked: true,
|
|
64
|
+
method: "zai-quota-monitor",
|
|
65
|
+
reason: error.timeout ? "timeout" : "network-error",
|
|
66
|
+
detail: error.message,
|
|
67
|
+
};
|
|
68
|
+
case "invalid-response":
|
|
69
|
+
return { state: "unverified", checked: true, method: "zai-quota-monitor", reason: "invalid-response", detail: error.message };
|
|
70
|
+
case "provider":
|
|
71
|
+
return { state: "unverified", checked: true, method: "zai-quota-monitor", reason: "provider-error", detail: error.message };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** The Credentials-section row status for the authentication check (spec §D table's "Check" column). */
|
|
76
|
+
export function authenticationCheckStatus(state) {
|
|
77
|
+
switch (state) {
|
|
78
|
+
case "verified":
|
|
79
|
+
return "ok";
|
|
80
|
+
case "rejected":
|
|
81
|
+
case "missing":
|
|
82
|
+
return "fail";
|
|
83
|
+
case "unverified":
|
|
84
|
+
case "skipped":
|
|
85
|
+
return "warn";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Overall summary precedence (spec §E, first matching row wins). `networkProbeFailed`
|
|
90
|
+
* only applies when `--network` was explicitly requested; it is `false`/`undefined`
|
|
91
|
+
* whenever that probe was not run or came back reachable.
|
|
92
|
+
*/
|
|
93
|
+
export function deriveDoctorVerdict(input) {
|
|
94
|
+
const { localFailure, authentication, keyComparison, offline, networkProbeFailed } = input;
|
|
95
|
+
if (localFailure || authentication.state === "missing" || authentication.state === "rejected") {
|
|
96
|
+
return { status: "ISSUES", exitCode: 1 };
|
|
97
|
+
}
|
|
98
|
+
if (authentication.state === "unverified" || authentication.state === "skipped" || networkProbeFailed) {
|
|
99
|
+
const exitCode = offline && authentication.state === "skipped" ? 0 : 1;
|
|
100
|
+
return { status: "UNVERIFIED", exitCode };
|
|
101
|
+
}
|
|
102
|
+
// authentication.state === "verified" from here on.
|
|
103
|
+
if (keyComparison === "different" || keyComparison === "unavailable") {
|
|
104
|
+
return { status: "ATTENTION", exitCode: 0 };
|
|
105
|
+
}
|
|
106
|
+
return { status: "HEALTHY", exitCode: 0 };
|
|
107
|
+
}
|