@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
package/core/format.ts ADDED
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Presentation formatters.
3
+ *
4
+ * Everything the dashboard renders as text passes through here, so the strings
5
+ * live in one place and can be asserted on without a DOM. Keep this module free
6
+ * of Node and DOM APIs — see `./types.ts`.
7
+ */
8
+
9
+ import type { TemperatureUnit } from "./temperature.js";
10
+ import { celsiusToFahrenheit } from "./temperature.js";
11
+ import type { ModelInfo } from "./types.js";
12
+
13
+ /** Lowest temperature the gauges plot. Below this the bar reads empty. */
14
+ export const TEMP_SCALE_MIN_C = 30;
15
+
16
+ /** Highest temperature the gauges plot. At or above this the bar reads full. */
17
+ export const TEMP_SCALE_MAX_C = 95;
18
+
19
+ /**
20
+ * Above this a temperature is amber.
21
+ *
22
+ * Every threshold in this module is Celsius, and every reading is compared
23
+ * against it in Celsius — the display unit is applied by
24
+ * {@link formatTemperature} alone. Converting before comparing would read a
25
+ * 79 °C warning as 174 against a 75 threshold and paint every gauge critical.
26
+ */
27
+ export const TEMP_WARNING_C = 75;
28
+
29
+ /** Above this a temperature is red. Celsius, for the same reason. */
30
+ export const TEMP_ERROR_C = 85;
31
+
32
+ function clamp01(value: number): number {
33
+ if (!Number.isFinite(value)) return 0;
34
+ if (value < 0) return 0;
35
+ if (value > 1) return 1;
36
+ return value;
37
+ }
38
+
39
+ function pad(value: number, width: number): string {
40
+ return String(value).padStart(width, "0");
41
+ }
42
+
43
+ /**
44
+ * Uptime as `3h 34m`. Minutes are zero-padded so the value does not change
45
+ * width every ten minutes and jitter the rail.
46
+ */
47
+ export function formatUptime(elapsedMs: number): string {
48
+ const seconds = Math.max(0, Math.floor((Number.isFinite(elapsedMs) ? elapsedMs : 0) / 1000));
49
+ const hours = Math.floor(seconds / 3600);
50
+ const minutes = Math.floor((seconds % 3600) / 60);
51
+ return `${hours}h ${pad(minutes, 2)}m`;
52
+ }
53
+
54
+ /**
55
+ * Log timestamp as `HH:MM:SS.mmm` in the operator's local time — the same shape
56
+ * `llama-server` prints, so lines copied out of the console still line up with
57
+ * the raw log.
58
+ */
59
+ export function formatClock(timestampMs: number): string {
60
+ const d = new Date(timestampMs);
61
+ return `${pad(d.getHours(), 2)}:${pad(d.getMinutes(), 2)}:${pad(d.getSeconds(), 2)}.${pad(
62
+ d.getMilliseconds(),
63
+ 3,
64
+ )}`;
65
+ }
66
+
67
+ /**
68
+ * The same clock without milliseconds, for prose that names a moment rather
69
+ * than identifying a line — `nothing new since 14:19:02`. The millisecond field
70
+ * is what makes two adjacent lines distinguishable; in a sentence it is noise.
71
+ */
72
+ export function formatClockSeconds(timestampMs: number): string {
73
+ const d = new Date(timestampMs);
74
+ return `${pad(d.getHours(), 2)}:${pad(d.getMinutes(), 2)}:${pad(d.getSeconds(), 2)}`;
75
+ }
76
+
77
+ /**
78
+ * A line count with thousands separators — `1,203`. Log counts run into the
79
+ * thousands within minutes, and an unseparated `1203` beside a `209` is a
80
+ * comparison the operator has to make character by character.
81
+ */
82
+ export function formatCount(value: number): string {
83
+ const whole = Number.isFinite(value) ? Math.trunc(value) : 0;
84
+ return whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
85
+ }
86
+
87
+ /** `1 line` / `240 lines`, grouped. The singular is worth the branch. */
88
+ export function formatLines(value: number): string {
89
+ return `${formatCount(value)} ${value === 1 ? "line" : "lines"}`;
90
+ }
91
+
92
+ /** A 0–1 fraction as a whole-percent label, e.g. `78%`. */
93
+ export function formatPercent(fraction: number): string {
94
+ return `${Math.round(Number.isFinite(fraction) ? fraction * 100 : 0)}%`;
95
+ }
96
+
97
+ /** A 0–1 fraction as a bar width in percent, clamped so overshoot cannot bleed. */
98
+ export function barPercent(fraction: number): number {
99
+ return Math.round(clamp01(fraction) * 100);
100
+ }
101
+
102
+ /** Memory gauges read `29.8 / 48 GB`; VRAM wants a decimal, RAM does not. */
103
+ export function formatMemory(usedGB: number, totalGB: number, decimals: number): string {
104
+ return `${usedGB.toFixed(decimals)} / ${totalGB} GB`;
105
+ }
106
+
107
+ /**
108
+ * Temperature label, e.g. `64°C` or `148°F` — the ONE place a reading changes
109
+ * unit.
110
+ *
111
+ * The unit arrives as a parameter and is never sniffed for: this module has no
112
+ * business knowing what locale a browser is in, and the server that also imports
113
+ * it could not answer anyway. Both units round to a whole number, and Fahrenheit
114
+ * rounds after converting, so `64.4 °C` reads `148°F` (147.92) rather than the
115
+ * `147°F` a pre-rounded 64 would give.
116
+ */
117
+ export function formatTemperature(celsius: number, unit: TemperatureUnit): string {
118
+ return unit === "fahrenheit"
119
+ ? `${Math.round(celsiusToFahrenheit(celsius))}°F`
120
+ : `${Math.round(celsius)}°C`;
121
+ }
122
+
123
+ /** Threshold color for a temperature gauge. Compares in Celsius, always. */
124
+ export function temperatureColor(celsius: number): string {
125
+ if (celsius > TEMP_ERROR_C) return "var(--error)";
126
+ if (celsius > TEMP_WARNING_C) return "var(--warning)";
127
+ return "var(--success)";
128
+ }
129
+
130
+ /**
131
+ * Temperatures never sit near zero, so the bar plots the 30–95 °C band rather
132
+ * than 0–100: at 0–100 every reading would hug the middle and say nothing.
133
+ *
134
+ * The band is Celsius whatever the label reads — the bar is a position in the
135
+ * thermal range, and that range does not move because the text beside it does.
136
+ */
137
+ export function temperatureBarPercent(celsius: number): number {
138
+ return barPercent((celsius - TEMP_SCALE_MIN_C) / (TEMP_SCALE_MAX_C - TEMP_SCALE_MIN_C));
139
+ }
140
+
141
+ /**
142
+ * A size in GB with up to two decimals, trailing zeros trimmed: `18.4`, `0.42`,
143
+ * `0.5`. Two places keep sub-gigabyte models legible (`0.42`, not `0.4`) without
144
+ * making large ones noisy (`18.4`, not `18.40`).
145
+ */
146
+ export function formatSizeGB(sizeGB: number): string {
147
+ return String(Number(sizeGB.toFixed(2)));
148
+ }
149
+
150
+ /**
151
+ * A token count for a tight label, keyed off binary thousands so that power-of-
152
+ * two context windows read as clean round values: `40960` → `40k`, `65536` →
153
+ * `64k`. Counts below 1024 print whole; larger ones show one decimal, trimmed
154
+ * when it is zero. A value that is not a number reads as `0`.
155
+ */
156
+ export function formatTokenCount(value: number): string {
157
+ if (!Number.isFinite(value) || value <= 0) return "0";
158
+ if (value < 1024) return String(Math.round(value));
159
+ const thousands = Math.round((value / 1024) * 10) / 10;
160
+ return `${Number.isInteger(thousands) ? thousands : thousands.toFixed(1)}k`;
161
+ }
162
+
163
+ /** A generation-rate label, e.g. `63 t/s`, or an em dash when there is none. */
164
+ export function formatTps(tokensPerSecond: number | null): string {
165
+ return tokensPerSecond !== null && Number.isFinite(tokensPerSecond)
166
+ ? `${Math.round(tokensPerSecond)} t/s`
167
+ : "—";
168
+ }
169
+
170
+ /** Above this share of a slot's context the headroom reading turns amber. */
171
+ export const CONTEXT_WARNING_PCT = 85;
172
+
173
+ /** At or above this share the reading turns red — a lane about to overflow. */
174
+ export const CONTEXT_ERROR_PCT = 98;
175
+
176
+ /**
177
+ * Threshold color for a context-headroom reading (0–100). The number is always
178
+ * printed alongside it, so the color is a second cue, never the only one.
179
+ */
180
+ export function contextHeadroomColor(percent: number): string {
181
+ if (percent >= CONTEXT_ERROR_PCT) return "var(--error)";
182
+ if (percent > CONTEXT_WARNING_PCT) return "var(--warning)";
183
+ return "var(--text-tertiary)";
184
+ }
185
+
186
+ /**
187
+ * The bit-depth a quantisation code implies, as `4-bit`, `16-bit`, … — the first
188
+ * run of digits in the code (`Q4_0`→`4-bit`, `Q5_K_M`→`5-bit`, `F16`→`16-bit`,
189
+ * `q8_0`→`8-bit`). It is the human reading that leads a card's identity line and
190
+ * labels its KV-cache; the exact code stays beside it. Empty when the code holds
191
+ * no digits (or is itself empty), so the caller drops the token rather than
192
+ * printing a bare `-bit`.
193
+ */
194
+ export function bitsFromCode(code: string): string {
195
+ const digits = code.match(/\d+/);
196
+ return digits === null ? "" : `${digits[0]}-bit`;
197
+ }
198
+
199
+ /**
200
+ * The KV-cache bit-depth label, `8-bit` or `8-bit / 5-bit`: each side run through
201
+ * {@link bitsFromCode}, collapsed to one token when K and V match (the common
202
+ * case) and kept split only when they genuinely differ. A side whose code has no
203
+ * digits to convert falls back to the raw code, so the label is never blank.
204
+ */
205
+ export function formatKvBits(kvCache: string): string {
206
+ const slash = kvCache.indexOf("/");
207
+ const kCode = slash === -1 ? kvCache : kvCache.slice(0, slash);
208
+ const vCode = slash === -1 ? kvCache : kvCache.slice(slash + 1);
209
+ const k = bitsFromCode(kCode) || kCode;
210
+ const v = bitsFromCode(vCode) || vCode;
211
+ return k === v ? k : `${k} / ${v}`;
212
+ }
213
+
214
+ /**
215
+ * The value a labeled card field carries when the fact behind it is not
216
+ * confirmed. A field's fact is trusted only while a process is running for the
217
+ * model (see the `confirmed` flag the selectors pass): a filename is a guess and
218
+ * a preset's launch args are intent, neither true until the model loads. Every
219
+ * field but `Type` collapses to this token when unconfirmed, so every unloaded
220
+ * card reads the same rather than differing by how much each has configured.
221
+ */
222
+ export const NA = "n/a";
223
+
224
+ /** `on`/`off`/`auto` shown title-cased; a lookup keeps the index access honest. */
225
+ const FLASH_LABELS: Record<ModelInfo["flashAttn"], string> = {
226
+ on: "On",
227
+ off: "Off",
228
+ auto: "Auto",
229
+ };
230
+
231
+ /**
232
+ * The `Quant` field: `4-bit (Q4_0)` — the bit-depth reading leads, the raw code
233
+ * rides beside it. {@link NA} when unconfirmed, or when the code carries no
234
+ * digits to read a depth from (a bare code is the noise this field translates).
235
+ */
236
+ export function formatQuantField(quant: string, confirmed: boolean): string {
237
+ if (!confirmed) return NA;
238
+ const bits = bitsFromCode(quant);
239
+ return bits === "" ? NA : `${bits} (${quant})`;
240
+ }
241
+
242
+ /** The `Size` field: `0.42 GB`. {@link NA} until loaded (no `meta`, no bytes). */
243
+ export function formatSizeField(sizeGB: number | null, confirmed: boolean): string {
244
+ return confirmed && sizeGB !== null ? `${formatSizeGB(sizeGB)} GB` : NA;
245
+ }
246
+
247
+ /**
248
+ * The `Context` field: `40k / slot` — the per-slot window each request is handed.
249
+ * Per-slot only, with no native-window ceiling: the trained max is a separate
250
+ * fact the labeled grid does not carry. {@link NA} until loaded.
251
+ */
252
+ export function formatContextField(ctx: number | null, confirmed: boolean): string {
253
+ return confirmed && ctx !== null ? `${formatTokenCount(ctx)} / slot` : NA;
254
+ }
255
+
256
+ /**
257
+ * The `GPU Layers` field: the requested `--n-gpu-layers` as a raw integer (`99`
258
+ * is llama.cpp's "all layers" sentinel, shown as-is). {@link NA} when the count
259
+ * is `null` — including a loaded model whose layers were never pinned, because
260
+ * the effective count is never reported back, so `n/a` is the honest reading.
261
+ */
262
+ export function formatGpuLayersField(gpuLayers: number | null, confirmed: boolean): string {
263
+ return confirmed && gpuLayers !== null ? String(gpuLayers) : NA;
264
+ }
265
+
266
+ /**
267
+ * The `Flash` field: `On`/`Off`/`Auto`. Can read `Auto` even while loaded — the
268
+ * server does not report which way `auto` resolved. {@link NA} until loaded.
269
+ */
270
+ export function formatFlashField(flashAttn: ModelInfo["flashAttn"], confirmed: boolean): string {
271
+ return confirmed ? FLASH_LABELS[flashAttn] : NA;
272
+ }
273
+
274
+ /**
275
+ * The `KV Cache` field: `8-bit`, or `8-bit / 5-bit` when K and V differ — the
276
+ * per-side bit-depth via {@link formatKvBits}. {@link NA} until loaded.
277
+ */
278
+ export function formatKvCacheField(kvCache: string, confirmed: boolean): string {
279
+ return confirmed ? formatKvBits(kvCache) : NA;
280
+ }
281
+
282
+ /**
283
+ * The `Type` field: `Generative` or `Embedder`. The one field that never reads
284
+ * {@link NA} — the router reports a model's modalities even while it is unloaded,
285
+ * so this is confirmed for every card.
286
+ */
287
+ export function formatTypeField(embedding: boolean): string {
288
+ return embedding ? "Embedder" : "Generative";
289
+ }
290
+
291
+ /** One exported log line. Structurally satisfied by the console's view model. */
292
+ export interface LogTextRow {
293
+ time: string;
294
+ level: string;
295
+ model: string;
296
+ /**
297
+ * The pipe frame the row moved into its own column, or `""`. Written back in
298
+ * front of the message so the exported text is the file's own line, not a
299
+ * reassembly of the parts Steward chose to display.
300
+ */
301
+ frameRaw: string;
302
+ message: string;
303
+ }
304
+
305
+ /**
306
+ * The copy/download payload: `HH:MM:SS.mmm LEVEL model message`, one line each.
307
+ * It mirrors what is on screen, filters included — the operator is quoting the
308
+ * console, not dumping the buffer.
309
+ *
310
+ * The message half is byte-exact against the source file: the frame the task
311
+ * column took out of the message goes back in front of it. That is the
312
+ * guarantee that makes moving the frame legitimate, so it is written here, in
313
+ * the one function both Copy and Download go through.
314
+ */
315
+ export function formatLogText(rows: readonly LogTextRow[]): string {
316
+ return rows.map((r) => `${r.time} ${r.level} ${r.model} ${r.frameRaw}${r.message}`).join("\n");
317
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The host-metrics seam: the contract between Steward and whatever collector a
3
+ * machine runs to measure its own GPU/CPU/memory.
4
+ *
5
+ * A collector is an operator-declared command (see `steward.json`) that streams
6
+ * NDJSON to stdout, one reading per line, tagged with a schema so a stray
7
+ * process cannot be mistaken for one of ours. This module owns the wire schema
8
+ * and its validator, plus the injectable {@link HostMetricsProvider} the live
9
+ * source reads. The Node implementation that spawns the command and drains its
10
+ * stdout lives in `server/host-collector.ts`; the parser is here, Node-free, so
11
+ * both the browser type-check and unit tests can reach it — mirroring how
12
+ * {@link import("./llama-source.js").ServiceProbe} keeps its interface in `core/`
13
+ * and its Node body in `server/`.
14
+ *
15
+ * Keep this module free of Node and DOM APIs — see `./types.ts`.
16
+ */
17
+
18
+ /**
19
+ * The schema tag every collector line must carry. A line without it — even one
20
+ * that is otherwise valid JSON — is not a Steward reading and is dropped, so a
21
+ * collector command that accidentally prints other JSON to stdout cannot inject
22
+ * garbage into the host band.
23
+ */
24
+ export const HOST_METRICS_SCHEMA = "steward.hostmetrics/1";
25
+
26
+ /**
27
+ * One validated host reading. Every metric field is `number | null`: `null`
28
+ * means "this machine cannot measure it" (a dashed/hatched gauge), never a real
29
+ * zero. `ts` is the producer's own clock, retained for diagnostics; staleness is
30
+ * judged on arrival wall-clock (see {@link HostSample.receivedAt}), not on this.
31
+ *
32
+ * Field names mirror {@link import("./types.js").HostMetrics} so the overlay is a
33
+ * straight copy. A `unified`-memory machine simply omits the VRAM fields (they
34
+ * arrive `null`); it exposes no readable VRAM total and one is never synthesised.
35
+ */
36
+ export interface HostReading {
37
+ /** Producer timestamp, epoch ms. Required — a line missing it is malformed. */
38
+ ts: number;
39
+ /** GPU utilisation, 0–1. */
40
+ gpuUtil: number | null;
41
+ gpuTempC: number | null;
42
+ /** CPU utilisation, 0–1. */
43
+ cpuUtil: number | null;
44
+ cpuTempC: number | null;
45
+ ramUsedGB: number | null;
46
+ ramTotalGB: number | null;
47
+ vramUsedGB: number | null;
48
+ vramTotalGB: number | null;
49
+ }
50
+
51
+ /** The most recent validated reading, plus the wall-clock at which it arrived. */
52
+ export interface HostSample {
53
+ reading: HostReading;
54
+ /** Arrival wall-clock, epoch ms — the clock staleness is measured against. */
55
+ receivedAt: number;
56
+ }
57
+
58
+ /**
59
+ * The live source's view of the host collector: the latest sample (or `null`
60
+ * before the first one lands, or after the collector has been given up on), and
61
+ * a way to release the underlying process. Injected into the otherwise Node-free
62
+ * {@link import("./llama-source.js").LlamaSource}; the Node body is
63
+ * `createHostCollector`.
64
+ */
65
+ export interface HostMetricsProvider {
66
+ /** The most recent validated reading, or `null` when there is none. */
67
+ latest(): HostSample | null;
68
+ /** Releases the collector process group. Safe to call more than once. */
69
+ close(): void;
70
+ }
71
+
72
+ /** True only for a real, finite number — the same bar the gauges hold readings to. */
73
+ function finiteNumber(value: unknown): value is number {
74
+ return typeof value === "number" && Number.isFinite(value);
75
+ }
76
+
77
+ /** A finite reading, or `null` for anything else (missing, `null`, NaN, wrong type). */
78
+ function readingField(value: unknown): number | null {
79
+ return finiteNumber(value) ? value : null;
80
+ }
81
+
82
+ /** True for a non-null object we can read string-keyed fields off. */
83
+ function isRecord(value: unknown): value is Record<string, unknown> {
84
+ return typeof value === "object" && value !== null && !Array.isArray(value);
85
+ }
86
+
87
+ /**
88
+ * Validates one NDJSON line against the host-metrics schema, returning the
89
+ * reading or `null` when the line is not one of ours.
90
+ *
91
+ * The contract (plan H2): the `schema` tag and a numeric `ts` are REQUIRED — a
92
+ * line that parses but is missing either is MALFORMED and dropped, never turned
93
+ * into an all-`null` sample that would read as "measured nothing". Each metric
94
+ * field, in contrast, is optional and independently `number | null`: absent,
95
+ * `null`, or a non-finite value all become `null` (a no-reading gauge), while a
96
+ * finite number rides through. Malformed JSON, a non-object, or an array all
97
+ * yield `null`. This function never throws.
98
+ */
99
+ export function parseHostMetricsLine(line: string): HostReading | null {
100
+ let parsed: unknown;
101
+ try {
102
+ parsed = JSON.parse(line);
103
+ } catch {
104
+ return null;
105
+ }
106
+ if (!isRecord(parsed)) return null;
107
+ if (parsed.schema !== HOST_METRICS_SCHEMA) return null;
108
+ if (!finiteNumber(parsed.ts)) return null;
109
+
110
+ return {
111
+ ts: parsed.ts,
112
+ gpuUtil: readingField(parsed.gpuUtil),
113
+ gpuTempC: readingField(parsed.gpuTempC),
114
+ cpuUtil: readingField(parsed.cpuUtil),
115
+ cpuTempC: readingField(parsed.cpuTempC),
116
+ ramUsedGB: readingField(parsed.ramUsedGB),
117
+ ramTotalGB: readingField(parsed.ramTotalGB),
118
+ vramUsedGB: readingField(parsed.vramUsedGB),
119
+ vramTotalGB: readingField(parsed.vramTotalGB),
120
+ };
121
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Turns a `llama-server` `/props` body into the CONFIG rows the rail shows.
3
+ *
4
+ * `/props` arrives as `unknown` off the wire, so every field is validated before
5
+ * it is read — a missing or wrong-typed field renders as an em dash, never as
6
+ * `undefined` or `NaN`. Two server shapes are handled: the routed server Pi runs
7
+ * by default (`role: "router"`, with a model cap and an autoload flag), and a
8
+ * bare single-model server (`-m model.gguf`), which reports neither. Only the
9
+ * router shape is exercised against a live server; the single-model branch is
10
+ * built from llama.cpp's documented shape and unit-tested with a hand-authored
11
+ * fixture.
12
+ *
13
+ * This is a pure function so it can be tested directly against the captured real
14
+ * `/props` fixtures. Keep it free of Node and DOM APIs.
15
+ */
16
+
17
+ import { listenAddress } from "./llama-connection.js";
18
+ import type { ConfigEntry } from "./types.js";
19
+
20
+ /** Shown wherever a value is absent or the wrong type. */
21
+ const MISSING = "—";
22
+
23
+ /** True for a non-null object we can read string-keyed fields off. */
24
+ function isRecord(value: unknown): value is Record<string, unknown> {
25
+ return typeof value === "object" && value !== null;
26
+ }
27
+
28
+ /** A non-empty string field, or `null` when absent/blank/wrong-typed. */
29
+ function readString(value: unknown): string | null {
30
+ return typeof value === "string" && value.trim() !== "" ? value : null;
31
+ }
32
+
33
+ /** A finite-number field rendered as text, or the em dash when absent. */
34
+ function readCount(value: unknown): string {
35
+ return typeof value === "number" && Number.isFinite(value) ? String(value) : MISSING;
36
+ }
37
+
38
+ /** A boolean flag as `on`/`off`, or the em dash when it is neither. */
39
+ function readToggle(value: unknown): string {
40
+ if (value === true) return "on";
41
+ if (value === false) return "off";
42
+ return MISSING;
43
+ }
44
+
45
+ /**
46
+ * The CONFIG rows for a `/props` body read from `baseUrl`. `listen` is derived
47
+ * from the connection, not the body, so it is present even when the body is
48
+ * empty. Router-only rows (`max models`, `autoload`) are emitted only in routed
49
+ * mode; a single-model server omits them rather than showing blanks.
50
+ */
51
+ export function parseRouterConfig(props: unknown, baseUrl: string): ConfigEntry[] {
52
+ const record = isRecord(props) ? props : {};
53
+ const listen = listenAddress(baseUrl);
54
+ const build = readString(record.build_info);
55
+ const binary = build !== null ? `llama-server ${build}` : MISSING;
56
+
57
+ if (record.role === "router") {
58
+ return [
59
+ { key: "mode", value: "routed" },
60
+ { key: "engine", value: binary },
61
+ { key: "address", value: listen },
62
+ { key: "max models", value: readCount(record.max_instances) },
63
+ { key: "autoload", value: readToggle(record.models_autoload) },
64
+ ];
65
+ }
66
+
67
+ return [
68
+ { key: "mode", value: "single model" },
69
+ { key: "engine", value: binary },
70
+ { key: "address", value: listen },
71
+ ];
72
+ }