@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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/core/disconnected-source.ts +110 -0
- package/core/drift.ts +247 -0
- package/core/format.ts +317 -0
- package/core/host-metrics.ts +121 -0
- package/core/llama-config.ts +72 -0
- package/core/llama-connection.ts +215 -0
- package/core/llama-models.ts +261 -0
- package/core/llama-slots.ts +104 -0
- package/core/llama-source.ts +1523 -0
- package/core/log-parse.ts +440 -0
- package/core/model-color.ts +59 -0
- package/core/select.ts +2923 -0
- package/core/slot-activity.ts +658 -0
- package/core/source.ts +84 -0
- package/core/state.ts +609 -0
- package/core/status-widget.ts +222 -0
- package/core/temperature.ts +149 -0
- package/core/types.ts +431 -0
- package/index.ts +503 -0
- package/package.json +51 -0
- package/server/api.ts +216 -0
- package/server/assets.ts +198 -0
- package/server/config-wiring.ts +490 -0
- package/server/drift-probe.ts +150 -0
- package/server/host-collector.ts +272 -0
- package/server/index.ts +228 -0
- package/server/log-tailer.ts +432 -0
- package/server/service-control.ts +337 -0
- package/server/service-probe.ts +71 -0
- package/server/steward-config.ts +430 -0
- package/setup/init-prompt.ts +214 -0
- package/setup/steward-setup.d.mts +16 -0
- package/setup/steward-setup.mjs +1398 -0
- package/ui/components/console.ts +511 -0
- package/ui/components/gauges.ts +120 -0
- package/ui/components/metrics.ts +63 -0
- package/ui/components/models.ts +296 -0
- package/ui/components/service.ts +358 -0
- package/ui/components/slots.ts +114 -0
- package/ui/components/sparkline.ts +59 -0
- package/ui/components/toolbar.ts +211 -0
- package/ui/dom.ts +120 -0
- package/ui/favicon.svg +17 -0
- package/ui/index.html +34 -0
- package/ui/main.ts +678 -0
- 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
|
+
}
|