@jmcombs/pi-steward 0.0.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/core/disconnected-source.ts +110 -0
  4. package/core/drift.ts +247 -0
  5. package/core/format.ts +317 -0
  6. package/core/host-metrics.ts +121 -0
  7. package/core/llama-config.ts +72 -0
  8. package/core/llama-connection.ts +215 -0
  9. package/core/llama-models.ts +261 -0
  10. package/core/llama-slots.ts +104 -0
  11. package/core/llama-source.ts +1523 -0
  12. package/core/log-parse.ts +440 -0
  13. package/core/model-color.ts +59 -0
  14. package/core/select.ts +2923 -0
  15. package/core/slot-activity.ts +658 -0
  16. package/core/source.ts +84 -0
  17. package/core/state.ts +609 -0
  18. package/core/status-widget.ts +222 -0
  19. package/core/temperature.ts +149 -0
  20. package/core/types.ts +431 -0
  21. package/index.ts +503 -0
  22. package/package.json +51 -0
  23. package/server/api.ts +216 -0
  24. package/server/assets.ts +198 -0
  25. package/server/config-wiring.ts +490 -0
  26. package/server/drift-probe.ts +150 -0
  27. package/server/host-collector.ts +272 -0
  28. package/server/index.ts +228 -0
  29. package/server/log-tailer.ts +432 -0
  30. package/server/service-control.ts +337 -0
  31. package/server/service-probe.ts +71 -0
  32. package/server/steward-config.ts +430 -0
  33. package/setup/init-prompt.ts +214 -0
  34. package/setup/steward-setup.d.mts +16 -0
  35. package/setup/steward-setup.mjs +1398 -0
  36. package/ui/components/console.ts +511 -0
  37. package/ui/components/gauges.ts +120 -0
  38. package/ui/components/metrics.ts +63 -0
  39. package/ui/components/models.ts +296 -0
  40. package/ui/components/service.ts +358 -0
  41. package/ui/components/slots.ts +114 -0
  42. package/ui/components/sparkline.ts +59 -0
  43. package/ui/components/toolbar.ts +211 -0
  44. package/ui/dom.ts +120 -0
  45. package/ui/favicon.svg +17 -0
  46. package/ui/index.html +34 -0
  47. package/ui/main.ts +678 -0
  48. package/ui/steward.css +2008 -0
@@ -0,0 +1,215 @@
1
+ /**
2
+ * How Steward finds the `llama-server` it reads.
3
+ *
4
+ * A connection is just a base URL and an API key. Where they come from depends
5
+ * on the host: inside Pi they come from the provider auth the operator already
6
+ * configured (the same resolution Pi's own llama.cpp extension performs), and
7
+ * outside Pi — the dev server — they come from the environment. Both paths fall
8
+ * back to the loopback default `llama-server` binds, so the dashboard always
9
+ * has somewhere to point.
10
+ *
11
+ * Pi's `LlamaClient`/`normalizeLlamaServerUrl` live at a deep path outside the
12
+ * package's `exports` map, so importing them would bypass Node's encapsulation
13
+ * and break on hosts that ship a subset shim. The eight-line normalizer is
14
+ * reimplemented here instead, and we talk HTTP with `fetch` ourselves.
15
+ *
16
+ * This module is only ever loaded in Node (the server half), never shipped to
17
+ * the browser, so `process.env` here is fine.
18
+ */
19
+
20
+ /** A resolved way to reach one `llama-server`. */
21
+ export interface LlamaConnection {
22
+ /** Normalized origin, e.g. `http://127.0.0.1:8080` — no trailing slash, no `/v1`. */
23
+ baseUrl: string;
24
+ /** Bearer key, or `""` when the server is keyless. */
25
+ apiKey: string;
26
+ }
27
+
28
+ /**
29
+ * The slice of Pi's provider auth Steward needs. Declared structurally rather
30
+ * than imported so a host that ships a narrower shape (or none) still type-checks.
31
+ */
32
+ interface ProviderAuthResult {
33
+ auth: { apiKey?: string; baseUrl?: string };
34
+ /** Provider-scoped config resolved from credentials, e.g. `LLAMA_BASE_URL`. */
35
+ env?: Record<string, string>;
36
+ }
37
+
38
+ /**
39
+ * The one host capability this module reaches for. Optional at every level so
40
+ * that on a host whose extension API omits `modelRegistry` (a subset shim), the
41
+ * feature check simply fails and we fall back to the environment — a missing
42
+ * property never throws, unlike a static named import of an absent symbol.
43
+ */
44
+ export interface ConnectionContext {
45
+ modelRegistry?: {
46
+ getProviderAuth?(provider: string): Promise<ProviderAuthResult | undefined>;
47
+ };
48
+ }
49
+
50
+ /** The provider id `llama-server` registers under, matching Pi's own extension. */
51
+ const LLAMA_PROVIDER = "llama.cpp";
52
+
53
+ /** Where `llama-server` listens unless told otherwise; already in normal form. */
54
+ const DEFAULT_BASE_URL = "http://127.0.0.1:8080";
55
+
56
+ /**
57
+ * Normalizes a server URL to a bare origin: http/https only, no query, no
58
+ * fragment, no trailing slash, and no `/v1` suffix (that is the inference path,
59
+ * not the server root). Throws on a non-http(s) URL rather than guessing.
60
+ *
61
+ * A faithful reimplementation of Pi's `normalizeLlamaServerUrl` — see the module
62
+ * comment for why it is not imported.
63
+ */
64
+ export function normalizeLlamaServerUrl(value: string): string {
65
+ const url = new URL(value.trim());
66
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
67
+ throw new Error("Server URL must use http or https");
68
+ }
69
+ url.hash = "";
70
+ url.search = "";
71
+ url.pathname = url.pathname.replace(/\/+$/u, "").replace(/\/v1$/u, "") || "/";
72
+ return url.toString().replace(/\/$/u, "");
73
+ }
74
+
75
+ /**
76
+ * The `host:port` a base URL points at, for the CONFIG `listen` row and the
77
+ * degraded overlays. Falls back to the raw string if the URL cannot be parsed,
78
+ * so a caller never sees `undefined`.
79
+ */
80
+ export function listenAddress(baseUrl: string): string {
81
+ try {
82
+ return new URL(baseUrl).host;
83
+ } catch {
84
+ return baseUrl;
85
+ }
86
+ }
87
+
88
+ /** Normalizes a configured value, or falls back to the default on empty/invalid. */
89
+ function normalizeOrDefault(value: string): string {
90
+ if (value.trim() === "") return DEFAULT_BASE_URL;
91
+ try {
92
+ return normalizeLlamaServerUrl(value);
93
+ } catch {
94
+ // A malformed override should degrade the dashboard, not crash it.
95
+ return DEFAULT_BASE_URL;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Resolves where to reach `llama-server`.
101
+ *
102
+ * Precedence mirrors Pi's llama.cpp extension: when the host exposes provider
103
+ * auth, `LLAMA_BASE_URL` from the resolved provider env wins, then the
104
+ * credential's `baseUrl`, then the loopback default; the key is the credential's
105
+ * `apiKey`. Without that host capability (the dev server, or a subset shim) the
106
+ * same two variables are read from the process environment instead.
107
+ *
108
+ * `env` is injectable so tests need not mutate the real environment.
109
+ */
110
+ /** The API key from Pi's provider auth, or the environment. Never throws. */
111
+ async function providerApiKey(
112
+ ctx: ConnectionContext | undefined,
113
+ env: Record<string, string | undefined>,
114
+ ): Promise<string> {
115
+ const getProviderAuth = ctx?.modelRegistry?.getProviderAuth;
116
+ if (typeof getProviderAuth === "function") {
117
+ try {
118
+ const result = await getProviderAuth(LLAMA_PROVIDER);
119
+ if (result !== undefined) return result.auth.apiKey ?? "";
120
+ } catch {
121
+ // Best-effort, same as the main path.
122
+ }
123
+ }
124
+ return env.LLAMA_API_KEY ?? "";
125
+ }
126
+
127
+ export async function resolveLlamaConnection(
128
+ ctx?: ConnectionContext,
129
+ env: Record<string, string | undefined> = process.env,
130
+ recordedBaseUrl?: string | null,
131
+ ): Promise<LlamaConnection> {
132
+ // `steward.json`'s baseUrl wins over everything. It is the operator telling
133
+ // Steward which server to watch; Pi's provider auth describes which server Pi
134
+ // *chats with*, and the two are allowed to differ — a testbed on one port
135
+ // while the daily driver answers on another is a normal thing to want.
136
+ //
137
+ // This used to be ignored, so a machine that had recorded :8091 was polled on
138
+ // the provider's :8080: the dashboard read "llama.cpp not reachable" and every
139
+ // control appeared broken while the server was perfectly healthy. The API key
140
+ // still comes from the provider, which is the only place it lives.
141
+ if (typeof recordedBaseUrl === "string" && recordedBaseUrl.trim() !== "") {
142
+ const apiKey = await providerApiKey(ctx, env);
143
+ return { baseUrl: normalizeOrDefault(recordedBaseUrl), apiKey };
144
+ }
145
+
146
+ const getProviderAuth = ctx?.modelRegistry?.getProviderAuth;
147
+ if (typeof getProviderAuth === "function") {
148
+ try {
149
+ const result = await getProviderAuth(LLAMA_PROVIDER);
150
+ if (result !== undefined) {
151
+ // An empty `LLAMA_BASE_URL` means "unset" — the same way Pi's own
152
+ // resolver treats it. Plain `??` would let "" shadow a configured
153
+ // `auth.baseUrl` and drop us onto the loopback default instead.
154
+ const envUrl = result.env?.LLAMA_BASE_URL;
155
+ const configured =
156
+ typeof envUrl === "string" && envUrl !== "" ? envUrl : result.auth.baseUrl;
157
+ return { baseUrl: normalizeOrDefault(configured ?? ""), apiKey: result.auth.apiKey ?? "" };
158
+ }
159
+ } catch {
160
+ // Auth resolution is best-effort: fall through to the environment.
161
+ }
162
+ }
163
+
164
+ return {
165
+ baseUrl: normalizeOrDefault(env.LLAMA_BASE_URL ?? ""),
166
+ apiKey: env.LLAMA_API_KEY ?? "",
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Where Pi's provider actually points, or `null` when that cannot be
172
+ * established — as opposed to {@link resolveLlamaConnection}, which falls back
173
+ * to the loopback default so the dashboard always has somewhere to poll.
174
+ *
175
+ * That fallback is right for polling and wrong for comparing. The widget used it
176
+ * to decide whether Pi and Steward disagreed, so a host that could not resolve
177
+ * provider auth at all produced a confident `pi points at :8080` — a number
178
+ * nobody had configured, reported as a misconfiguration, on a machine where both
179
+ * of Pi's config files said :8091. An unknown must not render as a plausible
180
+ * value.
181
+ */
182
+ export async function providerBaseUrlOrNull(
183
+ ctx?: ConnectionContext,
184
+ env: Record<string, string | undefined> = process.env,
185
+ ): Promise<string | null> {
186
+ const getProviderAuth = ctx?.modelRegistry?.getProviderAuth;
187
+ if (typeof getProviderAuth === "function") {
188
+ try {
189
+ const result = await getProviderAuth(LLAMA_PROVIDER);
190
+ if (result !== undefined) {
191
+ const envUrl = result.env?.LLAMA_BASE_URL;
192
+ const configured =
193
+ typeof envUrl === "string" && envUrl !== "" ? envUrl : result.auth.baseUrl;
194
+ if (typeof configured === "string" && configured.trim() !== "") {
195
+ try {
196
+ return normalizeLlamaServerUrl(configured);
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+ }
202
+ } catch {
203
+ // Unresolvable is unknown, not a default.
204
+ }
205
+ }
206
+ const fromEnv = env.LLAMA_BASE_URL;
207
+ if (typeof fromEnv === "string" && fromEnv.trim() !== "") {
208
+ try {
209
+ return normalizeLlamaServerUrl(fromEnv);
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+ return null;
215
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Turns a `llama-server` `/models` body into {@link ModelInfo} rows.
3
+ *
4
+ * The body arrives as `unknown` off the wire, so every field is narrowed before
5
+ * it is read — a missing or wrong-typed field degrades to a null or a default,
6
+ * never to `undefined` or `NaN`. The response is `{ object, data: [...] }`, but
7
+ * a bare array is accepted too so the parser is easy to test against a single
8
+ * captured model.
9
+ *
10
+ * This parser cannot decide `active`: that depends on whether any of the model's
11
+ * slots is processing, which lives behind a different endpoint. A loaded model
12
+ * is reported as `resident` here; the source upgrades it to `active` after it
13
+ * has cross-referenced `/slots`. Keep this module free of Node and DOM APIs.
14
+ */
15
+
16
+ import type { ModelInfo, ModelStatus } from "./types.js";
17
+
18
+ /** True for a non-null object we can read string-keyed fields off. */
19
+ function isRecord(value: unknown): value is Record<string, unknown> {
20
+ return typeof value === "object" && value !== null;
21
+ }
22
+
23
+ /** A non-empty string, or `null` when absent/blank/wrong-typed. */
24
+ function readString(value: unknown): string | null {
25
+ return typeof value === "string" && value.trim() !== "" ? value : null;
26
+ }
27
+
28
+ /** A finite number, or `null` when absent/wrong-typed/not finite. */
29
+ function readNumber(value: unknown): number | null {
30
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
31
+ }
32
+
33
+ /** The elements of `value` that are strings, or `[]` when it is not an array. */
34
+ function readStringArray(value: unknown): string[] {
35
+ return Array.isArray(value)
36
+ ? value.filter((item): item is string => typeof item === "string")
37
+ : [];
38
+ }
39
+
40
+ /** The model records in a `/models` body, whether it is wrapped or bare. */
41
+ function readModelList(raw: unknown): unknown[] {
42
+ if (Array.isArray(raw)) return raw;
43
+ if (isRecord(raw) && Array.isArray(raw.data)) return raw.data;
44
+ return [];
45
+ }
46
+
47
+ /**
48
+ * The value following the first of `flags` in a launch-args array, or `null`.
49
+ * `["--port", "56568"]` → `argValue(args, ["--port"])` is `"56568"`.
50
+ */
51
+ function argValue(args: readonly string[], flags: readonly string[]): string | null {
52
+ for (let i = 0; i < args.length - 1; i += 1) {
53
+ if (flags.includes(args[i] ?? "")) return args[i + 1] ?? null;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /** Whether any of `flags` appears in the launch args at all. */
59
+ function hasFlag(args: readonly string[], flags: readonly string[]): boolean {
60
+ return args.some((arg) => flags.includes(arg));
61
+ }
62
+
63
+ /** Common quantisation tokens, longest-first so `Q4_K_M` beats `Q4`. */
64
+ const QUANT_PATTERN =
65
+ /\b(IQ\d+_[A-Z0-9]+|Q\d+_[A-Z0-9]+(?:_[A-Z0-9]+)?|Q\d+_\d+|Q\d+|F16|F32|BF16)\b/;
66
+
67
+ /** Best-effort quant label from a model id, e.g. `Qwen3-0.6B-Q4_0` → `Q4_0`. */
68
+ function quantFromId(id: string): string {
69
+ const match = QUANT_PATTERN.exec(id);
70
+ return match?.[1] ?? "";
71
+ }
72
+
73
+ /**
74
+ * Best-effort quant from the `--model` path an unloaded preset launches with:
75
+ * the token before `.gguf` in the file's basename, e.g.
76
+ * `…/Qwen3-0.6B-Q4_0.gguf` → `Q4_0`. `""` when the arg is absent or unparseable.
77
+ * This is what lets an unloaded preset card show its quant even though llama.cpp
78
+ * ships no `meta` (and so no `ftype`) until the model is resident.
79
+ */
80
+ function quantFromArgs(args: readonly string[]): string {
81
+ const path = argValue(args, ["--model", "-m"]);
82
+ if (path === null) return "";
83
+ const base = path.slice(path.lastIndexOf("/") + 1).replace(/\.gguf$/i, "");
84
+ return quantFromId(base);
85
+ }
86
+
87
+ /** The last path/tag segment of an id: `ggml-org/Qwen3-GGUF:Q4_0` → `Qwen3-GGUF:Q4_0`. */
88
+ function lastSegment(id: string): string {
89
+ const slash = id.lastIndexOf("/");
90
+ return slash === -1 ? id : id.slice(slash + 1);
91
+ }
92
+
93
+ /** Drops a trailing `<sep><suffix>` (case-insensitive) from `value`, if present. */
94
+ function stripSuffix(value: string, suffix: string): string {
95
+ if (suffix === "") return value;
96
+ for (const sep of ["-", ":", "_", "."]) {
97
+ const tail = `${sep}${suffix}`;
98
+ if (value.toLowerCase().endsWith(tail.toLowerCase())) {
99
+ return value.slice(0, value.length - tail.length);
100
+ }
101
+ }
102
+ return value;
103
+ }
104
+
105
+ /** Display name: last segment with the quant tag and a `GGUF` marker trimmed. */
106
+ function shortName(id: string, quant: string): string {
107
+ let short = lastSegment(id);
108
+ short = stripSuffix(short, quant);
109
+ short = stripSuffix(short, "GGUF");
110
+ short = stripSuffix(short, "gguf");
111
+ return short === "" ? id : short;
112
+ }
113
+
114
+ /**
115
+ * Maps llama.cpp's `status.value` to a {@link ModelStatus}. `loaded` becomes
116
+ * `resident` (the source may upgrade it to `active`); `sleeping` is loaded and
117
+ * ready, so it folds into `resident` too; anything unrecognised is treated as
118
+ * not loaded rather than invented.
119
+ */
120
+ function mapStatus(value: string | null): ModelStatus {
121
+ switch (value) {
122
+ case "loaded":
123
+ case "sleeping":
124
+ return "resident";
125
+ case "loading":
126
+ return "loading";
127
+ case "downloading":
128
+ return "downloading";
129
+ default:
130
+ return "unloaded";
131
+ }
132
+ }
133
+
134
+ /** Flash-attention from launch args: an explicit value, a bare flag, or `auto`. */
135
+ function readFlashAttn(args: readonly string[]): "on" | "off" | "auto" {
136
+ const value = argValue(args, ["--flash-attn", "-fa"]);
137
+ if (value === "on" || value === "off" || value === "auto") return value;
138
+ if (hasFlag(args, ["--flash-attn", "-fa"])) return "on";
139
+ return "auto";
140
+ }
141
+
142
+ /** KV-cache types from launch args, defaulting each side to `f16`. */
143
+ function readKvCache(args: readonly string[]): string {
144
+ const k = argValue(args, ["--cache-type-k", "-ctk"]) ?? "f16";
145
+ const v = argValue(args, ["--cache-type-v", "-ctv"]) ?? "f16";
146
+ return `${k}/${v}`;
147
+ }
148
+
149
+ /** One `/models` record → a {@link ModelInfo}, or `null` when it has no id. */
150
+ function parseModel(raw: unknown): ModelInfo | null {
151
+ if (!isRecord(raw)) return null;
152
+ const id = readString(raw.id);
153
+ if (id === null) return null;
154
+
155
+ const status = isRecord(raw.status) ? raw.status : {};
156
+ const args = readStringArray(status.args);
157
+ const modelStatus = mapStatus(readString(status.value));
158
+
159
+ const architecture = isRecord(raw.architecture) ? raw.architecture : {};
160
+ const outputs = readStringArray(architecture.output_modalities);
161
+ // A model that cannot emit text is an embedder. When the field is absent we
162
+ // assume a normal text model rather than guessing an embedder.
163
+ const embedding = outputs.length > 0 && !outputs.includes("text");
164
+
165
+ // `meta` is only present for loaded models, so size and the native context
166
+ // window are known only then; the quant falls back to the launch args (an
167
+ // unloaded preset names its `.gguf`) and finally to the id.
168
+ const meta = isRecord(raw.meta) ? raw.meta : {};
169
+ const ftype = readString(meta.ftype);
170
+ const argQuant = quantFromArgs(args);
171
+ const quant = ftype ?? (argQuant !== "" ? argQuant : quantFromId(id));
172
+ const sizeBytes = readNumber(meta.size);
173
+ const nativeCtx = readNumber(meta.n_ctx_train);
174
+
175
+ // `--parallel` is a plain integer when pinned; the live slot count later
176
+ // overrides it for a loaded model, but it is all an unloaded preset has.
177
+ const parallelArg = argValue(args, ["--parallel", "-np"]);
178
+ const parallel = parallelArg === null ? null : readNumber(Number(parallelArg));
179
+
180
+ // Per-slot context. Loaded models report it directly; an unloaded preset only
181
+ // states the whole `--ctx-size`, which the router splits across `--parallel`,
182
+ // so we divide to land on the same per-slot figure a loaded model would show.
183
+ const metaCtx = readNumber(meta.n_ctx);
184
+ const ctxSizeArg = argValue(args, ["--ctx-size", "-c"]);
185
+ const ctxSize = ctxSizeArg === null ? null : readNumber(Number(ctxSizeArg));
186
+ const ctx =
187
+ metaCtx ??
188
+ (ctxSize !== null && ctxSize > 0 && parallel !== null && parallel > 0
189
+ ? Math.floor(ctxSize / parallel)
190
+ : null);
191
+
192
+ // Only a pinned `--n-gpu-layers` counts; a missing flag stays `null` rather
193
+ // than becoming `Number(null) === 0`, which would invent a reading.
194
+ const ngl = argValue(args, ["--n-gpu-layers", "-ngl"]);
195
+ const gpuLayers = ngl === null ? null : readNumber(Number(ngl));
196
+
197
+ return {
198
+ id,
199
+ short: shortName(id, quant),
200
+ embedding,
201
+ quant,
202
+ sizeGB: sizeBytes === null ? null : sizeBytes / 1e9,
203
+ ctx,
204
+ nativeCtx,
205
+ gpuLayers,
206
+ detail: embedding ? "embedding" : null,
207
+ parallel,
208
+ flashAttn: readFlashAttn(args),
209
+ kvCache: readKvCache(args),
210
+ status: modelStatus,
211
+ tokensPerSecond: null,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Which model is listening on which port, from the same `/models` body — the log
217
+ * console's attribution map.
218
+ *
219
+ * The router prefixes every child line with `[port]` and nothing else, so a port
220
+ * is all the console has to go on. The log *does* state the mapping, in the
221
+ * `spawning … name=X on port P` line, but that line is written once per load and
222
+ * is typically thousands of lines behind the live tail (14,010 in one measured
223
+ * corpus) — so a cold-started console would attribute nothing. HTTP has the
224
+ * answer on demand instead, and stays right across load/unload cycles because
225
+ * every poll rebuilds it.
226
+ *
227
+ * Only genuinely listening ports are reported: an unloaded preset carries
228
+ * `--port 0`, which is not a port and would otherwise map every model in the
229
+ * catalogue onto one imaginary child.
230
+ */
231
+ export function parseModelPorts(raw: unknown): Map<number, string> {
232
+ const ports = new Map<number, string>();
233
+ for (const entry of readModelList(raw)) {
234
+ if (!isRecord(entry)) continue;
235
+ const id = readString(entry.id);
236
+ if (id === null) continue;
237
+ const status = isRecord(entry.status) ? entry.status : {};
238
+ const value = argValue(readStringArray(status.args), ["--port"]);
239
+ if (value === null) continue;
240
+ const port = Number.parseInt(value, 10);
241
+ if (!Number.isInteger(port) || port <= 0) continue;
242
+ // Last wins: ports are ephemeral and a model respawned on a new port must
243
+ // not be shadowed by a stale entry.
244
+ ports.set(port, id);
245
+ }
246
+ return ports;
247
+ }
248
+
249
+ /**
250
+ * All models in a `/models` body, in the order the server listed them. Garbage
251
+ * in (`null`, `{}`, wrong types) yields `[]` or drops the offending row; it
252
+ * never throws.
253
+ */
254
+ export function parseModels(raw: unknown): ModelInfo[] {
255
+ const models: ModelInfo[] = [];
256
+ for (const entry of readModelList(raw)) {
257
+ const model = parseModel(entry);
258
+ if (model !== null) models.push(model);
259
+ }
260
+ return models;
261
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Turns a `llama-server` `/slots` body into {@link SlotInfo} rows, and a
3
+ * `/metrics` body into a generation rate.
4
+ *
5
+ * Both arrive as `unknown` (or raw Prometheus text) off the wire, so each field
6
+ * is narrowed before it is read. The model id is not in the slot body — we know
7
+ * it because we asked `/slots?model=<id>` — so it is passed in and stamped onto
8
+ * every row. Keep this module free of Node and DOM APIs — see `./types.ts`.
9
+ */
10
+
11
+ import type { SlotInfo } from "./types.js";
12
+
13
+ /** True for a non-null object we can read string-keyed fields off. */
14
+ function isRecord(value: unknown): value is Record<string, unknown> {
15
+ return typeof value === "object" && value !== null;
16
+ }
17
+
18
+ /** A finite number, or `null` when absent/wrong-typed/not finite. */
19
+ function readNumber(value: unknown): number | null {
20
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
21
+ }
22
+
23
+ /**
24
+ * Tokens generated so far, from `next_token[0].n_decoded`, or `null` when the
25
+ * body carries no such reading. An absent figure is not a zero: a slot the
26
+ * server said nothing about has generated an unknown number of tokens, and
27
+ * printing `0 decoded` for it would state a measurement nobody made.
28
+ */
29
+ function readDecoded(value: unknown): number | null {
30
+ if (!Array.isArray(value)) return null;
31
+ const first = value[0];
32
+ if (!isRecord(first)) return null;
33
+ return readNumber(first.n_decoded);
34
+ }
35
+
36
+ /**
37
+ * The slots for `modelId`. `raw` that is not an array (a 400 on a bare `/slots`,
38
+ * a stray object) yields `[]`; a slot missing its id falls back to its position.
39
+ *
40
+ * Every reading degrades to `null` rather than to a default, and a slot whose
41
+ * `is_processing` is missing entirely reads `unknown` rather than `idle` — an
42
+ * absent flag is a body we do not understand, not a server at rest.
43
+ */
44
+ export function parseSlots(raw: unknown, modelId: string): SlotInfo[] {
45
+ if (!Array.isArray(raw)) return [];
46
+ const slots: SlotInfo[] = [];
47
+ raw.forEach((entry, index) => {
48
+ if (!isRecord(entry)) return;
49
+ slots.push({
50
+ id: readNumber(entry.id) ?? index,
51
+ modelId,
52
+ promptTokens: readNumber(entry.n_prompt_tokens),
53
+ ctxTotal: readNumber(entry.n_ctx),
54
+ decoded: readDecoded(entry.next_token),
55
+ state:
56
+ entry.is_processing === true
57
+ ? "processing"
58
+ : entry.is_processing === false
59
+ ? "idle"
60
+ : "unknown",
61
+ });
62
+ });
63
+ return slots;
64
+ }
65
+
66
+ /** The `/metrics` values Steward reads, aggregated across a model's instance. */
67
+ export interface LlamaMetrics {
68
+ /** Generation rate (`predicted_tokens_seconds`), null when absent or `nan`. */
69
+ tps: number | null;
70
+ /** Requests being processed (`requests_processing`), 0 when absent. */
71
+ requestsProcessing: number;
72
+ /** Requests deferred for a free slot (`requests_deferred`), 0 when absent. */
73
+ requestsDeferred: number;
74
+ }
75
+
76
+ /**
77
+ * Reads one `llamacpp:<name>` gauge from a Prometheus scrape, or `null` when the
78
+ * line is absent or its value is not finite (llama.cpp prints `nan` before the
79
+ * first generation).
80
+ */
81
+ function readGauge(prometheusText: string, name: string): number | null {
82
+ const match = new RegExp(`^llamacpp:${name}\\s+([0-9eE+.-]+)`, "m").exec(prometheusText);
83
+ if (match?.[1] === undefined) return null;
84
+ const value = Number(match[1]);
85
+ return Number.isFinite(value) ? value : null;
86
+ }
87
+
88
+ /** The generation rate, or `null` when absent or not yet a finite number. */
89
+ export function parseTps(prometheusText: string): number | null {
90
+ return readGauge(prometheusText, "predicted_tokens_seconds");
91
+ }
92
+
93
+ /**
94
+ * The rate and request gauges from a `/metrics` scrape. The request gauges are
95
+ * counts, so an absent line means zero; the rate is `null` (unknown) when
96
+ * absent, since 0 t/s and "no reading" are different states.
97
+ */
98
+ export function parseMetrics(prometheusText: string): LlamaMetrics {
99
+ return {
100
+ tps: readGauge(prometheusText, "predicted_tokens_seconds"),
101
+ requestsProcessing: readGauge(prometheusText, "requests_processing") ?? 0,
102
+ requestsDeferred: readGauge(prometheusText, "requests_deferred") ?? 0,
103
+ };
104
+ }