dsh-local-models 0.0.0-stage → 0.2.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/lib/index.js ADDED
@@ -0,0 +1,2536 @@
1
+ /**
2
+ * dsh-local-models — node half.
3
+ *
4
+ * Owns one llama-server child process spawned from the Local Models settings
5
+ * tab, polled until /health on 127.0.0.1:$PORT comes up, stopped from the
6
+ * same tab, and killed when this fiber disposes (graceful dsh shutdown).
7
+ *
8
+ * HTTP API mounted on the dsh webserver under /local-models:
9
+ * GET /local-models/browse?dir=PATH → dirs + .gguf files
10
+ * POST /local-models/gguf-meta → {path} → GGUF header parse (cached)
11
+ * GET /local-models/status → state + fresh /health probe
12
+ * GET /local-models/logs?offset=&max= → tail of llama-server.log (terminal)
13
+ * POST /local-models/run → {path, ctx, mtp, kv} → spawn llama-server
14
+ * POST /local-models/stop → stop the child (or reap the port)
15
+ * POST /local-models/settings → {autostartRouter, autoUnloadMins,
16
+ * binPath, shortcuts} → settings.json
17
+ * POST /local-models/runtime/check → {binPath} → resolve + `<bin> --version`
18
+ * POST /local-models/register → add the ready server as an llm-pi-ai
19
+ * provider route via the settings service
20
+ *
21
+ * The llama-server binary is resolved per launch from an ordered candidate
22
+ * chain (Runtime setting → LOCAL_MODELS_BIN → PATH → usual dirs), so pointing
23
+ * the tab at a new build needs no dsh restart. See binCandidates().
24
+ *
25
+ * GGUF parsing is a port of the header-only parser from the user's
26
+ * vram-calculator project (src/gguf.ts) over Node fs instead of Blob slices.
27
+ */
28
+ import { spawn, execSync, execFile } from "node:child_process";
29
+ import {
30
+ accessSync,
31
+ appendFileSync,
32
+ constants as fsConstants,
33
+ existsSync,
34
+ mkdirSync,
35
+ openSync,
36
+ readFileSync,
37
+ readSync,
38
+ readdirSync,
39
+ renameSync,
40
+ statSync,
41
+ writeFileSync,
42
+ closeSync,
43
+ } from "node:fs";
44
+ import { basename, join, dirname, resolve, sep, delimiter, isAbsolute } from "node:path";
45
+ import { fileURLToPath } from "node:url";
46
+ import { homedir } from "node:os";
47
+ import process from "node:process";
48
+
49
+ export const inject = ["settings", "credentials", "webServer"];
50
+
51
+ const PORT_RAW = Number(process.env.LOCAL_MODELS_PORT ?? 8080);
52
+ const PORT = Number.isFinite(PORT_RAW) && PORT_RAW > 0 && PORT_RAW < 65536
53
+ ? Math.floor(PORT_RAW)
54
+ : 8080;
55
+ const BIN_NAME = process.platform === "win32" ? "llama-server.exe" : "llama-server";
56
+ /** LOCAL_MODELS_BIN — the pre-UI way to point at the binary. Still honored,
57
+ * but the Runtime setting (settings.json `binPath`) wins over it. */
58
+ const BIN_ENV = process.env.LOCAL_MODELS_BIN ?? "";
59
+ /** File-browser shortcut dirs from the environment (": "-separated, "name=path"
60
+ * entries allowed). The Runtime card's folder list overrides it; this stays as
61
+ * the fallback for installs that never opened the tab. */
62
+ const SHORTCUTS_ENV = parseShortcutSpec(process.env.LOCAL_MODELS_SHORTCUTS ?? "");
63
+ const HEALTH_TIMEOUT_MS = 1000;
64
+ const START_TIMEOUT_S = 300;
65
+ /** Reasoning-effort levels selectable in the Local Models tab.
66
+ * Probe-validated against this model's chat template: template accepts only
67
+ * low / medium / xhigh as reasoning_effort values ("none" disables thinking,
68
+ * launch --reasoning off disables at startup); high / minimal / max raise a
69
+ * Jinja error (HTTP 500) and are therefore excluded. */
70
+ const THINKING_LEVELS = ["off", "low", "medium", "xhigh"];
71
+ /** Image-payload guards declared on vision registrations (env-overridable). */
72
+ const IMAGE_MAX_BYTES = Number(process.env.LOCAL_MODELS_MAX_IMAGE_BYTES ?? 10 * 1024 * 1024);
73
+ const IMAGE_PIXEL_BUDGET = Number(process.env.LOCAL_MODELS_IMAGE_PIXEL_BUDGET ?? 4_194_304);
74
+ /** Router-mode cap (env-overridable, LOCAL_MODELS_ROUTER_MAX). Default 1:
75
+ * the VRAM policy is "always unload the active model before the requested one
76
+ * loads" — the router queues the request and unloads the idle LRU model first
77
+ * (sequential, never two resident models). */
78
+ const ROUTER_MAX = Number(process.env.LOCAL_MODELS_ROUTER_MAX ?? 1);
79
+ const ROUTER_PRESETS_FILE = join(dataDir(), "router-presets.ini");
80
+ /** llama-server stdout+stderr sink; the terminal tail route serves this file. */
81
+ const LOG_PATH = join(dataDir(), "llama-server.log");
82
+ /** Run the mmproj (vision projector) on the CPU - weights in system RAM -
83
+ * freeing ~0.87 GiB VRAM from the daily envelope. Encode on CPU takes
84
+ * ~15-22 s per image. Set LOCAL_MODELS_MMPROJ_CPU=0 to keep vision on the GPU. */
85
+ const MMPROJ_CPU = process.env.LOCAL_MODELS_MMPROJ_CPU !== "0";
86
+ /**
87
+ * Max output advertised when a server is registered in dsh: 131K tokens,
88
+ * the full daily window. The qwen3.x series is a heavy thinker — at xhigh
89
+ * the thinking block alone regularly exceeds the old 32K default and the
90
+ * reply gets cut off — so registration offers the whole window as max
91
+ * output. Still capped at the launched ctx in the profile builders below:
92
+ * a small-ctx profile must never advertise more output than it has room
93
+ * for.
94
+ */
95
+ const MAX_OUTPUT_TOKENS = 131072;
96
+ /** Fixed-MTP ceiling. The tab offers 0-7 and the API clamps here: upstream
97
+ * accepts any `--spec-draft-n-max` and clamps the effective depth to the
98
+ * model's own nextn depth at load. Depth 3 is the measured 16 GiB sweet spot
99
+ * (deeper drafts cost VRAM and collapse decode at large ctx — see
100
+ * bench/mtp_tuning.md), but the whole range stays selectable. */
101
+ const MTP_MAX = 7;
102
+ /** Draft acceptance floor for fixed MTP. Upstream's own default is 0.00
103
+ * (ungated). Gating is workload-dependent, not a free win: it *raises*
104
+ * acceptance while lowering throughput whenever the drafter is unsure, and
105
+ * the penalty is large (63.5% acc / 124 drafts vs 91.1% / 263 over the same
106
+ * 256 generated = -32% decode on short-context real text), while on
107
+ * deep-context, high-acceptance text it is worth ~+2%. Ungated is the config
108
+ * with the better worst case; n-max 3 is the depth that was never bad in
109
+ * either regime. Measured in bench/mtp_tuning.md. */
110
+ const MTP_P_MIN = 0;
111
+ /**
112
+ * KV cache quantization types accepted by upstream llama-server
113
+ * (common/arg.cpp `kv_cache_types`; anything else is rejected at arg-parse
114
+ * time). Highest precision first. The K/V defaults are the tuned 16 GiB pair
115
+ * this tab has always shipped, so an untouched tab spawns the same args.
116
+ */
117
+ const KV_CACHE_TYPES = ["f32", "f16", "bf16", "q8_0", "q5_1", "q5_0", "q4_1", "iq4_nl", "q4_0"];
118
+ const KV_CACHE_DEFAULT_K = "q5_0";
119
+ const KV_CACHE_DEFAULT_V = "q4_1";
120
+
121
+ function dshHome() {
122
+ return process.env.DSH_HOME ?? join(process.env.HOME ?? "/tmp", ".dsh");
123
+ }
124
+
125
+ function dataDir() {
126
+ return join(dshHome(), "local-models");
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Runtime: where the llama.cpp binaries live.
131
+ //
132
+ // The path used to be hardcoded to one machine's build dir. It is now resolved
133
+ // per launch (not at import time) from an ordered candidate list, so pointing
134
+ // the tab at a fresh build takes effect on the next Load — no dsh restart:
135
+ //
136
+ // Runtime setting (settings.json `binPath`) ← what the tab writes
137
+ // → LOCAL_MODELS_BIN ← the pre-UI env knob
138
+ // → every directory on PATH
139
+ // → the usual per-user build dirs (~/llama.cpp/build/bin,
140
+ // ~/Projetos/llama.cpp/build/bin, ~/.local/bin)
141
+ // → /usr/local/bin, /usr/bin ← distro packages
142
+ //
143
+ // Every layer is either configured or home-relative: no absolute path of any
144
+ // particular machine is baked in. Both a directory ("…/build/bin") and the
145
+ // binary itself are accepted.
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /** "name=path" / plain-path entries from LOCAL_MODELS_SHORTCUTS. Label
149
+ * defaults to the dir's basename. Mirrors what the tab sends for its own list
150
+ * (the client renders `label` + `path` chips either way). */
151
+ export function parseShortcutSpec(spec) {
152
+ return String(spec ?? "").split(":").map((s) => s.trim()).filter(Boolean).map((s) => {
153
+ const eq = s.indexOf("=");
154
+ return eq > 0
155
+ ? { label: s.slice(0, eq).trim() || "dir", path: s.slice(eq + 1).trim() }
156
+ : { label: basename(s) || s, path: s };
157
+ }).filter((s) => s.path);
158
+ }
159
+
160
+ /** Expand a leading "~/" against `home` (empty home = leave as typed). */
161
+ export function expandHome(p, home) {
162
+ const s = typeof p === "string" ? p.trim() : "";
163
+ if (!s || !home) return s;
164
+ if (s === "~") return home;
165
+ return s.startsWith("~/") ? join(home, s.slice(2)) : s;
166
+ }
167
+
168
+ function defaultIsDir(p) {
169
+ try {
170
+ return statSync(p).isDirectory();
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ function defaultIsFile(p) {
177
+ try {
178
+ return statSync(p).isFile();
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+
184
+ /** Is this path runnable? A file that exists but lost +x is the classic
185
+ * "spawn EACCES" report, so it is called out separately from "not found". */
186
+ export function isExecutable(p) {
187
+ try {
188
+ accessSync(p, fsConstants.X_OK);
189
+ return statSync(p).isFile();
190
+ } catch {
191
+ return false;
192
+ }
193
+ }
194
+
195
+ /** Human label for a candidate's provenance, shown by the Runtime card. */
196
+ export const BIN_SOURCE_LABEL = {
197
+ setting: "Runtime setting",
198
+ env: "LOCAL_MODELS_BIN",
199
+ path: "PATH",
200
+ home: "~ build dir",
201
+ system: "system dir",
202
+ };
203
+
204
+ /** Is this a binary we are willing to launch as llama-server? The name check
205
+ * is what keeps the Runtime card from becoming a general-purpose "run any
206
+ * binary" button, and what stops a stray file from widening the file-browser
207
+ * roots to whatever directory it lives in. Exported for tests. */
208
+ export function isLlamaServerPath(p) {
209
+ return typeof p === "string" && p !== "" && basename(p) === BIN_NAME && isExecutable(p);
210
+ }
211
+
212
+ /** Why a path is not a llama-server, or null when it is. Shared by the
213
+ * /settings validation, the /runtime/check verdict and requireBin() so all
214
+ * three tell the user the same thing. Exported for tests. */
215
+ export function binIdentityError(p) {
216
+ if (typeof p !== "string" || p === "") return "no " + BIN_NAME + " path";
217
+ if (basename(p) !== BIN_NAME) return "not a " + BIN_NAME + " binary: " + p;
218
+ if (!isExecutable(p)) return "not executable: " + p + " (chmod +x)";
219
+ return null;
220
+ }
221
+
222
+ /** Ordered llama-server candidates: every entry carries where it came from.
223
+ * `search: false` keeps only the explicit entries (setting + env) — that is
224
+ * what "test this one path" means for the Runtime card's Check button, so a
225
+ * miss there can never look like a hit on some other layer. Pure but for the
226
+ * injected `isDir`, so tests can drive it without depending on what happens to
227
+ * be installed on the machine running them. */
228
+ export function binCandidates(opts = {}) {
229
+ const {
230
+ configured = "",
231
+ env = "",
232
+ pathEnv = "",
233
+ home = "",
234
+ name = BIN_NAME,
235
+ search = true,
236
+ systemDirs = ["/usr/local/bin", "/usr/bin"],
237
+ isDir = defaultIsDir,
238
+ } = opts;
239
+ const out = [];
240
+ const seen = new Set();
241
+ // A candidate may name the binary or the directory holding it; a directory
242
+ // (existing, or written with a trailing slash) gets the binary appended.
243
+ const push = (candidate, source, asDir = false) => {
244
+ let p = expandHome(candidate, home);
245
+ if (!p) return;
246
+ if (asDir || isDir(p) || p.endsWith("/") || p.endsWith(sep)) p = join(p, name);
247
+ if (seen.has(p)) return;
248
+ seen.add(p);
249
+ out.push({ path: p, source, sourceLabel: BIN_SOURCE_LABEL[source] ?? source });
250
+ };
251
+ push(configured, "setting");
252
+ if (search) {
253
+ push(env, "env");
254
+ for (const dir of String(pathEnv ?? "").split(delimiter)) {
255
+ if (dir.trim()) push(resolve(dir.trim()), "path", true);
256
+ }
257
+ if (home) {
258
+ // Per-user build locations, most common first. Home-relative on
259
+ // purpose: an absolute path of one machine must never ship here.
260
+ push(join(home, "llama.cpp", "build", "bin"), "home", true);
261
+ push(join(home, "Projetos", "llama.cpp", "build", "bin"), "home", true);
262
+ push(join(home, ".local", "bin"), "home", true);
263
+ }
264
+ for (const dir of systemDirs) push(dir, "system", true);
265
+ }
266
+ return out;
267
+ }
268
+
269
+ /** First candidate that exists on disk, plus the full search list so a miss
270
+ * can tell the user everywhere we looked. `resolved` is null when nothing
271
+ * exists — callers refuse the launch instead of spawning a phantom path. */
272
+ export function resolveBin(opts = {}) {
273
+ const candidates = binCandidates(opts);
274
+ const isFile = opts.isFile ?? defaultIsFile;
275
+ const hit = candidates.find((c) => isFile(c.path)) ?? null;
276
+ return {
277
+ configured: String(opts.configured ?? "").trim(),
278
+ resolved: hit ? hit.path : null,
279
+ source: hit ? hit.source : null,
280
+ sourceLabel: hit ? hit.sourceLabel : null,
281
+ executable: hit ? isExecutable(hit.path) : false,
282
+ candidates,
283
+ };
284
+ }
285
+
286
+ /** The runtime as it stands right now: settings + env + this machine. */
287
+ export function currentBin() {
288
+ return resolveBin({
289
+ configured: readSettings().binPath,
290
+ env: BIN_ENV,
291
+ pathEnv: process.env.PATH ?? "",
292
+ home: homedir(),
293
+ });
294
+ }
295
+
296
+ /** Promise wrapper around child_process.execFile: resolves with both streams
297
+ * instead of rejecting, so the caller can read a version banner off a binary
298
+ * that exits non-zero. */
299
+ function execFileAsync(file, args, opts) {
300
+ return new Promise((resolvePromise) => {
301
+ execFile(file, args, { encoding: "utf8", ...opts }, (err, stdout, stderr) => {
302
+ resolvePromise({ err: err ?? null, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
303
+ });
304
+ });
305
+ }
306
+
307
+ /** `<bin> --version`: proves the binary actually starts and names the build the
308
+ * tab would launch. Async — a hung binary must not stall the dsh event loop —
309
+ * and cached by path+mtime+size+mode, because `chmod +x` changes neither mtime
310
+ * nor size and a stale EACCES would otherwise stick forever. Only successful
311
+ * probes are cached (a failure is cheap to retry and must never be sticky) and
312
+ * the cache is capped, so cycling through paths cannot grow it without bound.
313
+ * `runner` is injectable so tests need no real build. */
314
+ const versionCache = new Map();
315
+ const VERSION_CACHE_MAX = 32;
316
+ export async function probeBin(p, runner = execFileAsync) {
317
+ if (typeof p !== "string" || !p) return { ok: false, error: "no binary path" };
318
+ let key;
319
+ try {
320
+ const st = statSync(p);
321
+ key = st.mtimeMs + ":" + st.size + ":" + st.mode;
322
+ } catch (err) {
323
+ return { ok: false, error: err?.message ?? String(err) };
324
+ }
325
+ const cached = versionCache.get(p);
326
+ if (cached && cached.key === key) return cached.value;
327
+ const { err, stdout, stderr } = await runner(p, ["--version"], { timeout: 5000 });
328
+ // Some builds print the banner and still exit non-zero: keep the banner.
329
+ const out = String(stdout ?? "") + String(stderr ?? "");
330
+ const line = out.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
331
+ const value = line
332
+ ? { ok: true, version: line }
333
+ : { ok: false, error: err ? (err.message ?? String(err)) : "no --version output" };
334
+ if (value.ok) {
335
+ // Refresh the insertion order so the cap evicts the least recently used.
336
+ versionCache.delete(p);
337
+ versionCache.set(p, { key, value });
338
+ while (versionCache.size > VERSION_CACHE_MAX) versionCache.delete(versionCache.keys().next().value);
339
+ } else {
340
+ versionCache.delete(p);
341
+ }
342
+ return value;
343
+ }
344
+
345
+ /** Already-probed version for this path, or null. Never spawns anything, so
346
+ * the 2 s status poll can report it for free. */
347
+ export function cachedBinVersion(p) {
348
+ const hit = versionCache.get(p);
349
+ return hit ? (hit.value.version ?? null) : null;
350
+ }
351
+
352
+ /** Browser shortcut dirs in effect: the Runtime card's list once the user has
353
+ * saved one (even an empty one), else LOCAL_MODELS_SHORTCUTS, else none (home
354
+ * is always reachable). */
355
+ export function shortcutDirs() {
356
+ const configured = readSettings().shortcuts;
357
+ if (configured !== null) return configured.map((p) => ({ label: basename(p) || p, path: p }));
358
+ return SHORTCUTS_ENV;
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // GPU inventory: what the tab's VRAM budget and its multi-GPU controls are
363
+ // based on. NVIDIA is asked through nvidia-smi, AMD is read from sysfs
364
+ // (amdgpu's mem_info_vram_total), anything else stays unknown and the tab
365
+ // falls back to the 16 GiB assumption it always shipped. Detection never
366
+ // blocks the status poll (see peekGpuInventory) and never blocks a launch: a
367
+ // failed probe only means "unknown".
368
+ // ---------------------------------------------------------------------------
369
+ const VRAM_ASSUMED_BYTES = 16 * 1024 * 1024 * 1024;
370
+ const GPU_CACHE_MS = 60_000;
371
+ const GPU_PROBE_TIMEOUT_MS = 4000;
372
+ /** Best-effort PCI id → product name (e.g. "Navi 48 [Radeon RX 9070 XT]").
373
+ * Optional file: absent on many systems, and absence just leaves the PCI id. */
374
+ const PCI_IDS_PATHS = ["/usr/share/hwdata/pci.ids", "/usr/share/misc/pci.ids", "/usr/share/pci.ids"];
375
+
376
+ /** Parse `nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits`
377
+ * (one "name, MiB" per GPU). Pure, so the parse is testable without a GPU. */
378
+ export function parseNvidiaSmi(out) {
379
+ const gpus = [];
380
+ for (const raw of String(out ?? "").split("\n")) {
381
+ const line = raw.trim();
382
+ if (!line || /^name\s*,/i.test(line)) continue; // skip a --format=csv header
383
+ const comma = line.lastIndexOf(",");
384
+ if (comma <= 0) continue;
385
+ const name = line.slice(0, comma).trim();
386
+ const mib = Number(line.slice(comma + 1).trim());
387
+ if (!name || !Number.isFinite(mib) || mib <= 0) continue;
388
+ gpus.push({ vendor: "nvidia", name, vramBytes: Math.round(mib * 1024 * 1024) });
389
+ }
390
+ return gpus;
391
+ }
392
+
393
+ /** Resolve "1002:7550" against a pci.ids body ("<vendor> <name>" lines with
394
+ * tab-indented devices). Pure: the caller reads the file. */
395
+ export function lookupPciName(text, vendorId, deviceId) {
396
+ const vendor = String(vendorId ?? "").toLowerCase();
397
+ const device = String(deviceId ?? "").toLowerCase();
398
+ if (!vendor || !device) return null;
399
+ let inVendor = false;
400
+ for (const line of String(text ?? "").split("\n")) {
401
+ if (!line || line.startsWith("#")) continue;
402
+ if (!line.startsWith("\t")) {
403
+ // "<id> <name>" (a vendor). Stop at the next vendor once inside.
404
+ const id = line.slice(0, 4).trim().toLowerCase();
405
+ if (inVendor) break;
406
+ inVendor = id === vendor && /^\S+\s{2}/.test(line);
407
+ continue;
408
+ }
409
+ if (!inVendor) continue;
410
+ // "\t<id> <name>" is a device; "\t\t<id> <name>" is a subsystem.
411
+ if (line.startsWith("\t\t")) continue;
412
+ if (line.slice(1, 5).trim().toLowerCase() !== device) continue;
413
+ const name = line.slice(5).trim();
414
+ if (name) return name;
415
+ }
416
+ return null;
417
+ }
418
+
419
+ /** First readable pci.ids, or null. Read once and kept: the file is static. */
420
+ let pciIdsText;
421
+ function pciIds() {
422
+ if (pciIdsText !== undefined) return pciIdsText;
423
+ pciIdsText = null;
424
+ for (const p of PCI_IDS_PATHS) {
425
+ try {
426
+ pciIdsText = readFileSync(p, "utf8");
427
+ break;
428
+ } catch { /* try the next location */ }
429
+ }
430
+ return pciIdsText;
431
+ }
432
+
433
+ /** AMD GPUs from sysfs. `listDir`/`read` are injected so tests need no /sys. */
434
+ export function readAmdGpus(opts = {}) {
435
+ const {
436
+ drmDir = "/sys/class/drm",
437
+ listDir = (d) => readdirSync(d),
438
+ read = (p) => readFileSync(p, "utf8").trim(),
439
+ } = opts;
440
+ let cards;
441
+ try {
442
+ cards = listDir(drmDir);
443
+ } catch {
444
+ return [];
445
+ }
446
+ const gpus = [];
447
+ for (const card of cards.filter((c) => /^card\d+$/.test(c)).sort()) {
448
+ const dev = join(drmDir, card, "device");
449
+ try {
450
+ // 0x1002 = AMD; everything else here (i915/nouveau/virtio…) is left
451
+ // to the other probes.
452
+ if (!read(join(dev, "vendor")).toLowerCase().includes("0x1002")) continue;
453
+ const bytes = Number(read(join(dev, "mem_info_vram_total")));
454
+ if (!Number.isFinite(bytes) || bytes <= 0) continue;
455
+ // PCI_ID=1002:7550 → a real product name when pci.ids is installed.
456
+ const pciId = /PCI_ID=([0-9a-f]{4}):([0-9a-f]{4})/i.exec(read(join(dev, "uevent")) ?? "");
457
+ const product = pciId ? lookupPciName(pciIds(), pciId[1], pciId[2]) : null;
458
+ gpus.push({
459
+ vendor: "amd",
460
+ name: product ?? "AMD GPU" + (pciId ? " " + pciId[1] + ":" + pciId[2] : ""),
461
+ vramBytes: bytes,
462
+ card,
463
+ });
464
+ } catch { /* not a readable amdgpu card */ }
465
+ }
466
+ return gpus;
467
+ }
468
+
469
+ /** Honor CUDA_VISIBLE_DEVICES / HIP_VISIBLE_DEVICES: llama.cpp only sees the
470
+ * listed GPUs, so the budget must not count the hidden ones. Numeric lists are
471
+ * mapped by index; UUID/MIG entries (and "all") cannot be mapped from here and
472
+ * keep the full list rather than guessing. */
473
+ export function filterVisibleGpus(gpus, spec) {
474
+ if (typeof spec !== "string") return gpus;
475
+ const s = spec.trim();
476
+ if (s.toLowerCase() === "all") return gpus;
477
+ if (s === "" || s === "-1" || s.toLowerCase() === "none") return [];
478
+ const parts = s.split(",").map((p) => p.trim()).filter(Boolean);
479
+ if (parts.some((p) => !/^\d+$/.test(p))) return gpus;
480
+ const want = new Set(parts.map(Number));
481
+ return gpus.filter((_, i) => want.has(i));
482
+ }
483
+
484
+ /** Detect the GPUs llama.cpp would see. Never throws: unknown hardware is a
485
+ * normal answer (`gpus: []`, `assumed: true`). */
486
+ export async function detectGpus(opts = {}) {
487
+ const { runner = execFileAsync, env = process.env, ...sysOpts } = opts;
488
+ let gpus = [];
489
+ let source = "assumed";
490
+ try {
491
+ const { err, stdout } = await runner(
492
+ "nvidia-smi",
493
+ ["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
494
+ { timeout: GPU_PROBE_TIMEOUT_MS },
495
+ );
496
+ if (!err) {
497
+ const found = filterVisibleGpus(parseNvidiaSmi(stdout), env.CUDA_VISIBLE_DEVICES);
498
+ if (found.length > 0) {
499
+ gpus = found;
500
+ source = "nvidia-smi";
501
+ }
502
+ }
503
+ } catch { /* no nvidia-smi here */ }
504
+ if (gpus.length === 0) {
505
+ const amd = filterVisibleGpus(readAmdGpus(sysOpts), env.HIP_VISIBLE_DEVICES ?? env.ROCR_VISIBLE_DEVICES);
506
+ if (amd.length > 0) {
507
+ gpus = amd;
508
+ source = "sysfs";
509
+ }
510
+ }
511
+ return { gpus, source, totalBytes: gpus.reduce((sum, g) => sum + g.vramBytes, 0), assumed: gpus.length === 0 };
512
+ }
513
+
514
+ let gpuCache = { at: 0, value: null };
515
+ let gpuProbe = null;
516
+ /** Cached inventory for the status route. Deliberately synchronous: it never
517
+ * waits for nvidia-smi (a hung probe must not stall the 2 s poll). An expired
518
+ * cache returns the previous answer and refreshes in the background, so the
519
+ * next poll has the new one. */
520
+ export function peekGpuInventory() {
521
+ if (gpuCache.value && Date.now() - gpuCache.at < GPU_CACHE_MS) return gpuCache.value;
522
+ if (gpuProbe === null) {
523
+ gpuProbe = detectGpus()
524
+ .then((value) => { gpuCache = { at: Date.now(), value }; })
525
+ .catch(() => { gpuCache = { at: Date.now(), value: null }; })
526
+ .finally(() => { gpuProbe = null; });
527
+ }
528
+ return gpuCache.value ?? { gpus: [], source: "probing", pending: true, totalBytes: VRAM_ASSUMED_BYTES, assumed: true };
529
+ }
530
+
531
+ async function portUp() {
532
+ try {
533
+ const res = await fetch("http://127.0.0.1:" + PORT + "/health", {
534
+ signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
535
+ });
536
+ return res.ok;
537
+ } catch {
538
+ return false;
539
+ }
540
+ }
541
+
542
+ /** SIGTERM, then SIGKILL a couple of seconds later if the child is still up.
543
+ * The escalation timer is tracked per child so exit/error handlers and
544
+ * dispose() can cancel it — otherwise it fires after the fact holding a
545
+ * stale reference. The timer is unref'd so it never keeps the host alive. */
546
+ const killTimers = new WeakMap();
547
+ function killChild(child) {
548
+ if (child === null) return;
549
+ try {
550
+ child.kill("SIGTERM");
551
+ } catch {
552
+ /* already gone */
553
+ }
554
+ const timer = setTimeout(() => {
555
+ killTimers.delete(child);
556
+ try {
557
+ if (child.exitCode === null && child.signalCode === null) {
558
+ child.kill("SIGKILL");
559
+ }
560
+ } catch {
561
+ /* already gone */
562
+ }
563
+ }, 2000);
564
+ if (typeof timer.unref === "function") timer.unref();
565
+ killTimers.set(child, timer);
566
+ }
567
+
568
+ /** Cancel a pending SIGKILL escalation (child already reaped). */
569
+ function clearKillTimer(child) {
570
+ if (child === null) return;
571
+ const timer = killTimers.get(child);
572
+ if (timer !== undefined) {
573
+ clearTimeout(timer);
574
+ killTimers.delete(child);
575
+ }
576
+ }
577
+
578
+ /** Best-effort kill of whatever pid is listening on $PORT.
579
+ * Orphan detection only: callers invoke this when no tracked child exists
580
+ * (stop() already confirmed the port is up and run() refuses a busy port),
581
+ * so this never replaces killing the recorded child.pid. Our own pid is
582
+ * excluded so a busy default port can't turn into self-SIGTERM. */
583
+ function killPortListener() {
584
+ let pids;
585
+ try {
586
+ pids = execSync("lsof -ti :" + PORT, { encoding: "utf8" }).trim();
587
+ } catch {
588
+ return false;
589
+ }
590
+ let killed = false;
591
+ for (const raw of pids.split("\n")) {
592
+ const pid = Number(raw);
593
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) {
594
+ try {
595
+ process.kill(pid, "SIGTERM");
596
+ killed = true;
597
+ } catch {
598
+ /* gone */
599
+ }
600
+ }
601
+ }
602
+ return killed;
603
+ }
604
+ // ---------------------------------------------------------------------------
605
+ // GGUF header parsing (ported from vram-calculator/src/gguf.ts).
606
+ // ---------------------------------------------------------------------------
607
+ const GV_UINT8 = 0, GV_INT8 = 1, GV_UINT16 = 2, GV_INT16 = 3, GV_UINT32 = 4,
608
+ GV_INT32 = 5, GV_FLOAT32 = 6, GV_BOOL = 7, GV_STRING = 8, GV_ARRAY = 9,
609
+ GV_UINT64 = 10, GV_INT64 = 11, GV_FLOAT64 = 12;
610
+ const CHUNK = 4 * 1024 * 1024;
611
+ // Tokenizer arrays are counted, never materialised: every sentencepiece/BPE
612
+ // GGUF writes a quarter of a million entries here, and the only thing the
613
+ // estimator asks of them is a length — nVocab, which is exactly the
614
+ // tokenizer.ggml.tokens count when <arch>.vocab_size is absent (it is absent
615
+ // from every Qwen3.5/3.8-family file). The payload is still walked, so the KV
616
+ // pairs that follow the array stay reachable.
617
+ const COUNT_ONLY_ARRAY_KEYS = new Set([
618
+ "tokenizer.ggml.tokens", "tokenizer.ggml.token_type", "tokenizer.ggml.merges",
619
+ ]);
620
+
621
+ class FileReader {
622
+ constructor(path) {
623
+ this.path = path;
624
+ this.fd = openSync(path, "r");
625
+ this.size = statSync(path).size;
626
+ this.buffer = new Uint8Array(0);
627
+ this.pos = 0;
628
+ this.view = null;
629
+ }
630
+ ensure(n) {
631
+ while (this.pos + n > this.buffer.length) {
632
+ const start = this.buffer.length;
633
+ if (start >= this.size) {
634
+ throw new Error("Unexpected end of file while parsing GGUF header");
635
+ }
636
+ const want = Math.min(Math.max(n, CHUNK), this.size - start);
637
+ const chunk = new Uint8Array(want);
638
+ const got = readSync(this.fd, chunk, 0, want, start);
639
+ if (got <= 0) {
640
+ throw new Error("Unexpected end of file while parsing GGUF header");
641
+ }
642
+ const merged = new Uint8Array(this.buffer.length + got);
643
+ merged.set(this.buffer, 0);
644
+ merged.set(chunk.subarray(0, got), this.buffer.length);
645
+ this.buffer = merged;
646
+ this.view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
647
+ }
648
+ }
649
+ readRaw(n) {
650
+ this.ensure(n);
651
+ const out = this.buffer.subarray(this.pos, this.pos + n);
652
+ this.pos += n;
653
+ return out;
654
+ }
655
+ u8() { this.ensure(1); const v = this.view.getUint8(this.pos); this.pos += 1; return v; }
656
+ i8() { this.ensure(1); const v = this.view.getInt8(this.pos); this.pos += 1; return v; }
657
+ u16() { this.ensure(2); const v = this.view.getUint16(this.pos, true); this.pos += 2; return v; }
658
+ i16() { this.ensure(2); const v = this.view.getInt16(this.pos, true); this.pos += 2; return v; }
659
+ u32() { this.ensure(4); const v = this.view.getUint32(this.pos, true); this.pos += 4; return v; }
660
+ i32() { this.ensure(4); const v = this.view.getInt32(this.pos, true); this.pos += 4; return v; }
661
+ u64() {
662
+ this.ensure(8);
663
+ const lo = this.view.getUint32(this.pos, true);
664
+ const hi = this.view.getUint32(this.pos + 4, true);
665
+ this.pos += 8;
666
+ return Number((BigInt(hi) << 32n) | BigInt(lo));
667
+ }
668
+ i64() {
669
+ this.ensure(8);
670
+ const lo = this.view.getUint32(this.pos, true);
671
+ const hi = this.view.getInt32(this.pos + 4, true);
672
+ this.pos += 8;
673
+ return Number((BigInt(hi) << 32n) | BigInt(lo));
674
+ }
675
+ f32() { this.ensure(4); const v = this.view.getFloat32(this.pos, true); this.pos += 4; return v; }
676
+ f64() { this.ensure(8); const v = this.view.getFloat64(this.pos, true); this.pos += 8; return v; }
677
+ readString() {
678
+ const len = this.u64();
679
+ const bytes = this.readRaw(len);
680
+ return new TextDecoder().decode(bytes);
681
+ }
682
+ /** Step over a string without decoding it; returns its byte length. */
683
+ skipString() {
684
+ const len = this.u64();
685
+ this.readRaw(len);
686
+ return len;
687
+ }
688
+ /** Read a metadata value. keep=false walks it without keeping it: strings
689
+ * are measured but never decoded, arrays are counted but never built (see
690
+ * COUNT_ONLY_ARRAY_KEYS). An array yields its element count either way. */
691
+ readValue(type, keep = true) {
692
+ switch (type) {
693
+ case GV_UINT8: return this.u8();
694
+ case GV_INT8: return this.i8();
695
+ case GV_UINT16: return this.u16();
696
+ case GV_INT16: return this.i16();
697
+ case GV_UINT32: return this.u32();
698
+ case GV_INT32: return this.i32();
699
+ case GV_FLOAT32: return this.f32();
700
+ case GV_BOOL: return this.u8() !== 0;
701
+ case GV_STRING: return keep ? this.readString() : this.skipString();
702
+ case GV_ARRAY: {
703
+ const elemType = this.u32();
704
+ const count = this.u64();
705
+ if (!Number.isSafeInteger(count) || count > this.size) {
706
+ throw new Error("Corrupt GGUF: unreasonable array length (" + count + ")");
707
+ }
708
+ if (!keep) {
709
+ for (let i = 0; i < count; i++) this.readValue(elemType, false);
710
+ return count;
711
+ }
712
+ const arr = [];
713
+ for (let i = 0; i < count; i++) arr.push(this.readValue(elemType));
714
+ return arr;
715
+ }
716
+ case GV_UINT64: return this.u64();
717
+ case GV_INT64: return this.i64();
718
+ case GV_FLOAT64: return this.f64();
719
+ default: throw new Error("Unknown GGUF metadata value type: " + type);
720
+ }
721
+ }
722
+ close() {
723
+ try { closeSync(this.fd); } catch { /* ignore */ }
724
+ }
725
+ }
726
+
727
+ function alignUp(x, a) {
728
+ return Math.ceil(x / a) * a;
729
+ }
730
+
731
+ function num(v) {
732
+ return typeof v === "number" ? v : null;
733
+ }
734
+
735
+ /** Routed-expert weight tensor? llama.cpp names every MoE arch's routed
736
+ * experts blk.N.ffn_{gate,down,up,gate_up}_exps (chunked: *_chexps).
737
+ * Shared experts (*shexp*) are deliberately excluded — always active. */
738
+ function isExpertTensorName(name) {
739
+ return typeof name === "string"
740
+ && (name.includes("_exps") || name.includes("chexps"))
741
+ && !name.includes("shexp");
742
+ }
743
+
744
+ // general.file_type values (LLAMA_FTYPE_* in include/llama.h).
745
+ const FILE_TYPE_NAMES = {
746
+ 0: "F32", 1: "F16", 2: "Q4_0", 3: "Q4_1", 7: "Q8_0",
747
+ 8: "Q5_0", 9: "Q5_1", 10: "Q2_K", 11: "Q3_K_S", 12: "Q3_K_M",
748
+ 13: "Q3_K_L", 14: "Q4_K_S", 15: "Q4_K_M", 16: "Q5_K_S", 17: "Q5_K_M",
749
+ 18: "Q6_K", 19: "IQ2_XXS", 20: "IQ2_XS", 21: "Q2_K_S", 22: "IQ3_XS",
750
+ 23: "IQ3_XXS", 24: "IQ1_S", 25: "IQ4_NL", 26: "IQ3_S", 27: "IQ3_M",
751
+ 28: "IQ2_S", 29: "IQ2_M", 30: "IQ4_XS", 31: "IQ1_M", 32: "BF16",
752
+ 36: "TQ1_0", 37: "TQ2_0", 38: "MXFP4_MOE", 39: "NVFP4",
753
+ };
754
+
755
+ function parseGGUF(path) {
756
+ const r = new FileReader(path);
757
+ try {
758
+ const magicBuf = r.readRaw(4);
759
+ const magicStr = String.fromCharCode(magicBuf[0], magicBuf[1], magicBuf[2], magicBuf[3]);
760
+ if (magicStr !== "GGUF") throw new Error("Not a GGUF file (magic = " + JSON.stringify(magicStr) + ")");
761
+ const version = r.u32();
762
+ if (version < 2 || version > 3) {
763
+ throw new Error("Unsupported GGUF version " + version + " (only v2/v3 are supported).");
764
+ }
765
+ const tensorCount = r.u64();
766
+ const metadataKvCount = r.u64();
767
+ if (!Number.isSafeInteger(tensorCount) || tensorCount > r.size ||
768
+ !Number.isSafeInteger(metadataKvCount) || metadataKvCount > r.size) {
769
+ throw new Error("Corrupt GGUF header (tensor/metadata count is bogus).");
770
+ }
771
+ const raw = {};
772
+ const arrayCounts = {}; // key → element count for arrays counted, not kept
773
+ for (let i = 0; i < metadataKvCount; i++) {
774
+ const key = r.readString();
775
+ const type = r.u32();
776
+ if (type === GV_ARRAY && COUNT_ONLY_ARRAY_KEYS.has(key)) {
777
+ arrayCounts[key] = r.readValue(type, false);
778
+ continue;
779
+ }
780
+ raw[key] = r.readValue(type);
781
+ }
782
+ let firstOffset = -1, lastOffset = 0, monotonic = true;
783
+ const tensors = []; // {name, offset} — kept for MoE expert-byte accounting
784
+ for (let i = 0; i < tensorCount; i++) {
785
+ const name = r.readString();
786
+ const nDims = r.u32();
787
+ for (let d = 0; d < nDims; d++) r.u64();
788
+ r.u32(); // ggml type
789
+ const offset = r.u64();
790
+ tensors.push({ name, offset });
791
+ if (i === 0) firstOffset = offset;
792
+ if (offset < lastOffset) monotonic = false;
793
+ lastOffset = offset;
794
+ }
795
+ const headerPos = r.pos;
796
+ let alignment = num(raw["general.alignment"]);
797
+ if (!alignment || alignment < 1) alignment = 32;
798
+ const dataStart = alignUp(headerPos, alignment);
799
+ const weightsBytes = r.size - dataStart;
800
+ if (weightsBytes <= 0 || weightsBytes > r.size) {
801
+ throw new Error("Failed to parse GGUF layout (computed weights size is invalid).");
802
+ }
803
+ const arch = typeof raw["general.architecture"] === "string" ? raw["general.architecture"] : null;
804
+ const p = (suffix) => (arch ? num(raw[arch + "." + suffix]) : null);
805
+ const nLayers = p("block_count");
806
+ const nEmbd = p("embedding_length");
807
+ const nHeads = p("attention.head_count");
808
+ const nKvHeadsRaw = p("attention.head_count_kv");
809
+ const keyLen = p("attention.key_length");
810
+ const valLen = p("attention.value_length");
811
+ // MLA (latent KV) models carry separate compressed key/value head
812
+ // lengths; llama.cpp's is_mla() keys off exactly these two.
813
+ const mlaKeyLen = p("attention.key_length_mla");
814
+ const mlaValLen = p("attention.value_length_mla");
815
+ // <arch>.vocab_size is optional and often absent (every Qwen3.5/3.8
816
+ // GGUF omits it); the tokenizer's own token list is the real count —
817
+ // llama.cpp's n_vocab is exactly that array's length.
818
+ const nVocab = p("vocab_size") ?? arrayCounts["tokenizer.ggml.tokens"] ?? null;
819
+ const contextLength = p("context_length");
820
+ const slidingWindow = p("attention.sliding_window");
821
+ const fullAttnInterval = p("full_attention_interval");
822
+ const nextnPredictLayers = p("nextn_predict_layers");
823
+ const expertCount = p("expert_count");
824
+ const expertUsedCount = p("expert_used_count");
825
+ const expertSharedCount = p("expert_shared_count");
826
+ const expertFfnLength = p("expert_feed_forward_length") ?? p("expert_chunk_feed_forward_length");
827
+ const moeEveryNLayers = p("moe_every_n_layers");
828
+ const leadingDenseBlocks = p("leading_dense_block_count");
829
+ const valueExpertCount = p("attention.value_expert_count");
830
+ const valueExpertUsedCount = p("attention.value_expert_used_count");
831
+ const ssmConvKernel = p("ssm.conv_kernel");
832
+ const ssmStateSize = p("ssm.state_size");
833
+ const ssmNGroup = p("ssm.group_count");
834
+ const ssmDtRank = p("ssm.time_step_rank");
835
+ const ssmInnerSize = p("ssm.inner_size");
836
+ const nKvHeads = nKvHeadsRaw ?? nHeads;
837
+ const isMla = mlaKeyLen != null && mlaValLen != null;
838
+ let headDim = keyLen ?? valLen ?? null;
839
+ if (headDim === null && nEmbd && nHeads) headDim = nEmbd / nHeads;
840
+ const warnings = [];
841
+ if (firstOffset !== 0) warnings.push("First tensor offset is " + firstOffset + " bytes into the data region — non-standard writer.");
842
+ if (!monotonic) warnings.push("Tensor offsets are not monotonically increasing (unusual layout).");
843
+ if (!arch) warnings.push("No architecture metadata found.");
844
+ if (nLayers === null) warnings.push("Could not read block_count (n_layers).");
845
+ if (headDim === null || nKvHeads === null) warnings.push("Could not read attention dimensions; KV cache estimate unavailable.");
846
+ if (nVocab === null) warnings.push("Could not read vocab size (<arch>.vocab_size and tokenizer.ggml.tokens both missing); the compute estimate assumes 32K tokens.");
847
+ if (isMla) warnings.push("MLA (latent KV) layout — llama.cpp requires the same cache type for K and V on this model.");
848
+ if (arch && /deepseek/i.test(arch)) warnings.push("Architecture looks like DeepSeek (MLA). The standard KV-cache formula may under-estimate real usage.");
849
+ if (slidingWindow != null && slidingWindow > 0) warnings.push("Hybrid sliding-window model (window = " + slidingWindow + " tokens). Local layers cap their KV cache at the window; a ~1/6 share of layers is assumed full-attention.");
850
+ // --- MoE detection ------------------------------------------------
851
+ // Primary signal: <arch>.expert_count > 0. Fallback: expert tensor
852
+ // names (llama.cpp maps every MoE arch's routed experts to
853
+ // blk.N.ffn_{gate,down,up}[_gate_up]_exps / *_chexps).
854
+ const tensorHasExperts = tensors.some((t) => isExpertTensorName(t.name));
855
+ const isMoe = (expertCount != null && expertCount > 0) || tensorHasExperts;
856
+ const moeSource = (expertCount != null && expertCount > 0) ? "metadata" : (tensorHasExperts ? "tensors" : null);
857
+ if (tensorHasExperts && !(expertCount != null && expertCount > 0)) {
858
+ warnings.push("Expert tensors found but no expert_count metadata — treating as MoE; expert-byte estimates are approximate.");
859
+ }
860
+ // Expert weights in bytes, via offset deltas (valid only for the
861
+ // standard dense-packed layout, i.e. monotonic offsets). Shared
862
+ // experts (*shexp*) stay resident — they are always active — so they
863
+ // are NOT counted here. Null when the layout is non-standard.
864
+ let expertBytes = null, expertBytesByLayer = null;
865
+ if (tensorHasExperts && monotonic) {
866
+ const ordered = [...tensors].sort((a, b) => a.offset - b.offset);
867
+ expertBytes = 0;
868
+ expertBytesByLayer = {};
869
+ for (let i = 0; i < ordered.length; i++) {
870
+ const t = ordered[i];
871
+ if (!isExpertTensorName(t.name)) continue;
872
+ const end = i + 1 < ordered.length ? ordered[i + 1].offset : weightsBytes;
873
+ const size = end - t.offset;
874
+ if (size <= 0) continue;
875
+ expertBytes += size;
876
+ const m = /^blk\.(\d+)\./.exec(t.name);
877
+ if (m) {
878
+ const layer = Number(m[1]);
879
+ expertBytesByLayer[layer] = (expertBytesByLayer[layer] ?? 0) + size;
880
+ }
881
+ }
882
+ }
883
+ const meta = {
884
+ version, arch,
885
+ name: typeof raw["general.name"] === "string" ? raw["general.name"] : null,
886
+ paramCount: num(raw["general.parameter_count"]),
887
+ fileType: num(raw["general.file_type"]),
888
+ fileTypeName: num(raw["general.file_type"]) != null ? (FILE_TYPE_NAMES[num(raw["general.file_type"])] ?? "type " + num(raw["general.file_type"])) : "unknown",
889
+ nLayers, nEmbd, nHeads, nKvHeads, headDim, nVocab, contextLength, slidingWindow,
890
+ isMla,
891
+ fullAttnInterval, nextnPredictLayers, ssmConvKernel, ssmStateSize, ssmNGroup, ssmDtRank, ssmInnerSize,
892
+ expertCount, expertUsedCount, expertSharedCount, expertFfnLength,
893
+ moeEveryNLayers, leadingDenseBlocks, valueExpertCount, valueExpertUsedCount,
894
+ isMoe, moeSource, expertBytes, expertBytesByLayer,
895
+ alignment, tensorCount, metadataKvCount, warnings,
896
+ };
897
+ return { fileName: basename(path), filePath: path, fileSize: r.size, weightsBytes, dataStart, meta };
898
+ } finally {
899
+ r.close();
900
+ }
901
+ }
902
+
903
+ const ggufCache = new Map();
904
+ export function parseGGUFCached(path) {
905
+ let st;
906
+ try {
907
+ st = statSync(path);
908
+ } catch {
909
+ throw new Error("cannot stat file: " + path);
910
+ }
911
+ if (!st.isFile()) throw new Error("not a file: " + path);
912
+ const hit = ggufCache.get(path);
913
+ if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) return hit.result;
914
+ const result = parseGGUF(path);
915
+ ggufCache.set(path, { size: st.size, mtimeMs: st.mtimeMs, result });
916
+ if (ggufCache.size > 8) {
917
+ const first = ggufCache.keys().next().value;
918
+ ggufCache.delete(first);
919
+ }
920
+ return result;
921
+ }
922
+ // ---------------------------------------------------------------------------
923
+ // Process manager.
924
+ // ---------------------------------------------------------------------------
925
+ function slug(name) {
926
+ return String(name)
927
+ .replace(/\.gguf$/i, "")
928
+ .replace(/[^a-zA-Z0-9_-]+/g, "-")
929
+ .replace(/^-+|-+$/g, "")
930
+ .toLowerCase()
931
+ .slice(0, 40) || "local-model";
932
+ }
933
+
934
+ // Reasoning-effort helpers (exported for tests). 'off' disables thinking;
935
+ // any other level is passed through to llama-server at launch time — this
936
+ // llama.cpp build does NOT parse per-request reasoning_effort, so the level
937
+ // is a server-launch setting.
938
+ //
939
+ // preserveThinking controls --reasoning-preserve vs --no-reasoning-preserve
940
+ // (upstream default is preserve ON; this plugin historically forced OFF, so
941
+ // the toggle defaults to false to keep existing profiles byte-identical).
942
+ // Accepts both camelCase (preserveThinking) and snake_case (preserve_thinking)
943
+ // profile keys.
944
+ export function normalizePreserveThinking(raw) {
945
+ return raw?.preserveThinking === true || raw?.preserve_thinking === true;
946
+ }
947
+
948
+ export function normalizeEffort(effort) {
949
+ return THINKING_LEVELS.includes(effort) ? effort : "medium";
950
+ }
951
+
952
+ // KV-cache helpers (exported for tests). The tab exposes the full set of types
953
+ // upstream llama-server accepts; an absent or unknown value falls back to the
954
+ // tuned default (Q5_0 K / Q4_1 V) so legacy profiles and API calls keep the
955
+ // historical launch args.
956
+ export function normalizeKvCacheType(value, fallback) {
957
+ return typeof value === "string" && KV_CACHE_TYPES.includes(value) ? value : fallback;
958
+ }
959
+
960
+ /** True when llama.cpp demands type_k === type_v for this model. The runtime
961
+ * check is `hparams.is_mla() || arch == LLM_ARCH_DEEPSEEK4` (llama-context.cpp:
962
+ * "model does not support different K and V cache types"); is_mla() is exactly
963
+ * "the GGUF carries both attention.key_length_mla and value_length_mla", which
964
+ * is what the parser stores as `isMla`. Deliberately narrow — a false positive
965
+ * would block a launch llama-server would have accepted, while a false
966
+ * negative only means llama-server reports the error itself. */
967
+ export function kvTypesMustMatch(meta) {
968
+ return meta?.isMla === true || meta?.arch === "deepseek4";
969
+ }
970
+
971
+ export function thinkingArgsFor(effort, preserveThinking = false) {
972
+ const level = normalizeEffort(effort);
973
+ const preserve = typeof preserveThinking === "object" && preserveThinking !== null
974
+ ? normalizePreserveThinking(preserveThinking)
975
+ : normalizePreserveThinking({ preserveThinking });
976
+ return level === "off"
977
+ ? ["--reasoning", "off"]
978
+ : ["--reasoning", "auto", "--reasoning-format", "deepseek", preserve ? "--reasoning-preserve" : "--no-reasoning-preserve", "--reasoning-effort", level];
979
+ }
980
+
981
+ /** --sleep-idle-seconds for launch args. Empty (upstream default = disabled)
982
+ * when the timer is off (0). The sleeping server keeps answering /health
983
+ * and /models and reloads automatically on the next request (one slow
984
+ * request), so this is VRAM eviction, not a shutdown. Exported for tests. */
985
+ /** Effective --sleep-idle-seconds for a timer value, or null when off. */
986
+ export function sleepIdleSecsFor(mins) {
987
+ return Number.isInteger(mins) && mins > 0 ? mins * 60 : null;
988
+ }
989
+
990
+ export function sleepIdleArgsFor(mins) {
991
+ const s = sleepIdleSecsFor(mins);
992
+ return s === null ? [] : ["--sleep-idle-seconds", String(s)];
993
+ }
994
+
995
+ // ---------------------------------------------------------------------------
996
+ // Multi-GPU helpers (exported for tests): how the model is spread over the
997
+ // GPUs llama.cpp sees. The tab only offers these when more than one GPU was
998
+ // detected; the API still accepts them, so a saved profile keeps launching the
999
+ // same way on a machine where detection differs.
1000
+ // ---------------------------------------------------------------------------
1001
+
1002
+ /** `--split-mode` values upstream accepts. "layer" is llama.cpp's own default
1003
+ * (layers pipelined across GPUs) and "row" is the usual "tensor parallelism"
1004
+ * setting; "tensor" is upstream's experimental full tensor split. */
1005
+ export const SPLIT_MODES = ["layer", "row", "tensor", "none"];
1006
+ export const SPLIT_MODE_DEFAULT = "layer";
1007
+
1008
+ export function normalizeSplitMode(value) {
1009
+ return typeof value === "string" && SPLIT_MODES.includes(value) ? value : SPLIT_MODE_DEFAULT;
1010
+ }
1011
+
1012
+ /** `--tensor-split` proportions: comma (or slash/space) separated positive
1013
+ * numbers, one per GPU, e.g. "3,1". Anything else becomes null = upstream's
1014
+ * own proportional-to-VRAM default. */
1015
+ export function normalizeTensorSplit(value) {
1016
+ if (typeof value !== "string") return null;
1017
+ const parts = value.trim().split(/[,/\s]+/).filter(Boolean);
1018
+ if (parts.length === 0) return null;
1019
+ const nums = [];
1020
+ for (const p of parts) {
1021
+ const n = Number(p);
1022
+ if (!Number.isFinite(n) || n <= 0) return null;
1023
+ nums.push(n);
1024
+ }
1025
+ return nums.join(",");
1026
+ }
1027
+
1028
+ /** Split-related launch args. The default (layer, no explicit split) adds
1029
+ * nothing, so an untouched tab spawns the same argv as before the control
1030
+ * existed. `tensorSplit` alone is still meaningful with layer/row. */
1031
+ export function splitArgsFor(splitMode, tensorSplit) {
1032
+ const mode = normalizeSplitMode(splitMode);
1033
+ const split = normalizeTensorSplit(tensorSplit);
1034
+ const args = mode === SPLIT_MODE_DEFAULT ? [] : ["--split-mode", mode];
1035
+ if (split) args.push("--tensor-split", split);
1036
+ return args;
1037
+ }
1038
+
1039
+ /** Even-ish default proportions for N GPUs, weighted by VRAM when known:
1040
+ * what the tab prefills in the tensor-split box. Returns "" for a single GPU
1041
+ * (nothing to split). */
1042
+ export function evenTensorSplit(gpus) {
1043
+ const list = Array.isArray(gpus) ? gpus.filter((g) => g && g.vramBytes > 0) : [];
1044
+ if (list.length < 2) return "";
1045
+ const min = Math.min(...list.map((g) => g.vramBytes));
1046
+ return list.map((g) => Math.max(1, Math.round(g.vramBytes / min))).join(",");
1047
+ }
1048
+
1049
+ // ---------------------------------------------------------------------------
1050
+ // MoE helpers (exported for tests). Dense models ignore every MoE option.
1051
+ // ---------------------------------------------------------------------------
1052
+
1053
+ /** Normalize the MoE launch config. cpuMoe wins over nCpuMoe; expertUsed is
1054
+ * the requested top-k override (null = stock value from the GGUF). */
1055
+ export function normalizeMoEConfig(raw) {
1056
+ const cpuMoe = raw?.cpuMoe === true;
1057
+ const nCpuMoe = Number.isInteger(raw?.nCpuMoe) && raw.nCpuMoe > 0 ? raw.nCpuMoe : 0;
1058
+ const expertUsed = Number.isInteger(raw?.expertUsed) && raw.expertUsed > 0 ? raw.expertUsed : null;
1059
+ const arch = typeof raw?.arch === "string" && raw.arch ? raw.arch : null;
1060
+ return { cpuMoe, nCpuMoe, expertUsed, arch };
1061
+ }
1062
+
1063
+ /** CLI args for the MoE config. Returns [] for dense models. Throws when the
1064
+ * requested top-k exceeds the model's expert count. */
1065
+ export function moeArgsFor(moe, meta) {
1066
+ const cfg = normalizeMoEConfig(moe);
1067
+ if (!meta?.isMoe) return [];
1068
+ const args = [];
1069
+ if (cfg.cpuMoe) args.push("--cpu-moe");
1070
+ else if (cfg.nCpuMoe > 0) args.push("--n-cpu-moe", String(cfg.nCpuMoe));
1071
+ if (cfg.expertUsed != null) {
1072
+ if (meta.expertCount != null && cfg.expertUsed > meta.expertCount) {
1073
+ throw new Error("top-k " + cfg.expertUsed + " exceeds this model's " + meta.expertCount + " experts");
1074
+ }
1075
+ const arch = cfg.arch || meta.arch;
1076
+ if (!arch) throw new Error("cannot override top-k: unknown architecture");
1077
+ args.push("--override-kv", arch + ".expert_used_count=int:" + cfg.expertUsed);
1078
+ }
1079
+ return args;
1080
+ }
1081
+
1082
+ /** VRAM-resident weights for the estimate: subtract the expert bytes kept on
1083
+ * CPU. Returns null when the split cannot be computed (unknown layout). */
1084
+ export function moeVramWeights(weightsBytes, meta, moe) {
1085
+ const cfg = normalizeMoEConfig(moe);
1086
+ if (!meta?.isMoe || (!cfg.cpuMoe && cfg.nCpuMoe <= 0)) return weightsBytes;
1087
+ if (meta.expertBytes == null) return null;
1088
+ if (cfg.cpuMoe) return Math.max(weightsBytes - meta.expertBytes, 0);
1089
+ const byLayer = meta.expertBytesByLayer ?? {};
1090
+ let cpuBytes = 0, known = false;
1091
+ for (const [layer, bytes] of Object.entries(byLayer)) {
1092
+ if (Number(layer) < cfg.nCpuMoe) { cpuBytes += bytes; known = true; }
1093
+ }
1094
+ if (!known) return null;
1095
+ return Math.max(weightsBytes - cpuBytes, 0);
1096
+ }
1097
+
1098
+ function createManager() {
1099
+ mkdirSync(dataDir(), { recursive: true });
1100
+ const state = {
1101
+ status: "idle", // idle | starting | ready | stopped
1102
+ pid: null,
1103
+ model: null,
1104
+ modelPath: null,
1105
+ alias: null,
1106
+ ctx: null,
1107
+ mtpHeads: 0,
1108
+ reasoningEffort: "medium",
1109
+ preserveThinking: false,
1110
+ splitMode: SPLIT_MODE_DEFAULT,
1111
+ tensorSplit: null,
1112
+ kvTypeK: KV_CACHE_DEFAULT_K,
1113
+ kvTypeV: KV_CACHE_DEFAULT_V,
1114
+ cpuMoe: false,
1115
+ nCpuMoe: 0,
1116
+ expertUsed: null,
1117
+ mode: "single", // single | router
1118
+ presetsPath: null,
1119
+ loadedModels: [],
1120
+ mmprojPath: null,
1121
+ mmprojCpu: MMPROJ_CPU,
1122
+ port: PORT,
1123
+ startedAt: null,
1124
+ error: null,
1125
+ logPath: LOG_PATH,
1126
+ binPath: null, // llama-server actually spawned (set by launch())
1127
+ sleepIdleSecs: null, // effective idle-eviction timer (null = off)
1128
+ };
1129
+ let child = null;
1130
+ let healthTimer = null;
1131
+ let runSeq = 0;
1132
+
1133
+ function stopHealthPolling() {
1134
+ if (healthTimer !== null) { clearInterval(healthTimer); healthTimer = null; }
1135
+ }
1136
+
1137
+ /** Resolve the binary for a launch, or explain what to fix. Re-resolved on
1138
+ * every launch (not cached at import) so pointing the Runtime card at a new
1139
+ * build takes effect without restarting dsh. */
1140
+ function requireBin() {
1141
+ const bin = currentBin();
1142
+ if (!bin.resolved) {
1143
+ const tried = bin.candidates.slice(0, 6).map((c) => c.path).join(", ");
1144
+ return { error: "llama-server not found — set the llama.cpp binaries folder in the Runtime card (searched: " + tried + (bin.candidates.length > 6 ? ", …" : "") + ")" };
1145
+ }
1146
+ // Last line of defense: a hand-edited settings.json must not make this
1147
+ // spawn an arbitrary executable.
1148
+ const bad = binIdentityError(bin.resolved);
1149
+ if (bad) return { error: "refusing to launch: " + bad };
1150
+ return { path: bin.resolved };
1151
+ }
1152
+
1153
+ async function stop() {
1154
+ stopHealthPolling();
1155
+ if (child !== null) {
1156
+ const c = child;
1157
+ child = null;
1158
+ killChild(c);
1159
+ state.status = "stopped";
1160
+ state.pid = null;
1161
+ return "child";
1162
+ }
1163
+ if (await portUp()) {
1164
+ const killed = killPortListener();
1165
+ state.status = "stopped";
1166
+ state.pid = null;
1167
+ return killed ? "port" : "none";
1168
+ }
1169
+ state.status = "idle";
1170
+ state.error = null;
1171
+ return "none";
1172
+ }
1173
+
1174
+ async function run(modelPath, ctxSize, mtpHeads, mmprojPath, effort, mmprojCpu = MMPROJ_CPU, moe = null, preserveThinking = false, kvTypeK = KV_CACHE_DEFAULT_K, kvTypeV = KV_CACHE_DEFAULT_V, splitMode = SPLIT_MODE_DEFAULT, tensorSplit = null) {
1175
+ const bin = requireBin();
1176
+ if (bin.error) return { ok: false, error: bin.error };
1177
+ if (!existsSync(modelPath)) return { ok: false, error: "model file missing: " + modelPath };
1178
+ if (mmprojPath && !existsSync(mmprojPath)) return { ok: false, error: "mmproj file missing: " + mmprojPath };
1179
+ if (await portUp()) return { ok: false, error: "a server is already listening on port " + PORT + " — Stop it first" };
1180
+
1181
+ const ctx = Number.isInteger(ctxSize) && ctxSize > 0 ? ctxSize : 8192;
1182
+ // Fixed MTP draft (upstream draft-mtp): any depth 0-MTP_MAX is valid;
1183
+ // upstream clamps the effective depth to the model's nextn depth.
1184
+ const mtp = Number.isInteger(mtpHeads) && mtpHeads > 0 ? Math.min(mtpHeads, MTP_MAX) : 0;
1185
+ const alias = slug(basename(modelPath));
1186
+ const mmproj = typeof mmprojPath === "string" && mmprojPath ? mmprojPath : null;
1187
+ const mmprojCpuFinal = typeof mmprojCpu === "boolean" ? mmprojCpu : MMPROJ_CPU;
1188
+ const effortLevel = normalizeEffort(effort);
1189
+ const preserve = normalizePreserveThinking({ preserveThinking });
1190
+ // MoE: read the header for arch/validation (best-effort — a parse
1191
+ // failure only forfeits the top-k override, never the launch).
1192
+ let moeMeta = null;
1193
+ try {
1194
+ moeMeta = parseGGUFCached(modelPath).meta;
1195
+ } catch {
1196
+ moeMeta = null;
1197
+ }
1198
+ if (moe?.arch && moeMeta) moeMeta = { ...moeMeta, arch: moe.arch };
1199
+ else if (moe?.arch) moeMeta = { isMoe: true, arch: moe.arch, expertCount: null };
1200
+ let moeArgs;
1201
+ try {
1202
+ moeArgs = moeArgsFor(moe, moeMeta);
1203
+ } catch (err) {
1204
+ return { ok: false, error: err?.message ?? String(err) };
1205
+ }
1206
+ const moeCfg = normalizeMoEConfig(moe);
1207
+
1208
+ // KV cache type comes from the tab's K/V selectors; the defaults
1209
+ // (Q5_0 K / Q4_1 V) are the 2026 ladder sweet spot for a 16 GiB card
1210
+ // (same tail precision as Q8_0/Q4_0 at 20% less VRAM; K-first, but
1211
+ // Q8_0 K only pays off with V >= Q5_1). Unknown values fall back to
1212
+ // that pair. Quantized V needs flash-attn (always on below); MLA /
1213
+ // DeepSeek4 models reject mixed K/V types, so refuse up front instead
1214
+ // of letting the child die on "does not support different K and V".
1215
+ const kvK = normalizeKvCacheType(kvTypeK, KV_CACHE_DEFAULT_K);
1216
+ const kvV = normalizeKvCacheType(kvTypeV, KV_CACHE_DEFAULT_V);
1217
+ // Multi-GPU placement. The default (layer, no explicit proportions)
1218
+ // emits nothing, so an untouched tab spawns the same argv as always.
1219
+ const split = normalizeSplitMode(splitMode);
1220
+ const splitArgs = splitArgsFor(split, tensorSplit);
1221
+ if (kvK !== kvV && kvTypesMustMatch(moeMeta)) {
1222
+ return { ok: false, error: "this model (MLA latent KV) needs the same cache type for K and V — set both to " + kvK };
1223
+ }
1224
+ // The draft is unconditional whenever MTP is on: there is no ctx
1225
+ // ceiling any more (the old "ignore softcap" toggle is gone, always-on).
1226
+ // Draft KV is pinned to Q4_0 — upstream has no draft auto-quant — and a
1227
+ // deep draft at large ctx can still OOM or collapse decode, so the tab
1228
+ // defaults to the measured sweet spot (3).
1229
+ const specArgs = mtp > 0
1230
+ ? ["--spec-type", "draft-mtp", "--spec-draft-n-max", String(mtp), "--spec-draft-p-min", String(MTP_P_MIN),
1231
+ "--spec-draft-type-k", "q4_0", "--spec-draft-type-v", "q4_0"]
1232
+ : [];
1233
+
1234
+ const args = [
1235
+ "-m", modelPath,
1236
+ "-ngl", "999",
1237
+ "-c", String(ctx),
1238
+ "-b", "2048", "-ub", "512",
1239
+ "-t", "4", "-np", "1", "--poll", "0",
1240
+ "--cont-batching",
1241
+ "--jinja",
1242
+ ...thinkingArgsFor(effortLevel, preserve),
1243
+ "--flash-attn", "on",
1244
+ "--kv-unified",
1245
+ "--cache-type-k", kvK,
1246
+ "--cache-type-v", kvV,
1247
+ ...splitArgs,
1248
+ ...specArgs,
1249
+ ...moeArgs,
1250
+ // Qwen-VL needs a 1024-token image minimum (load_hparams warning).
1251
+ ...(mmproj ? ["--mmproj", mmproj, "--image-min-tokens", "1024", ...(mmprojCpuFinal ? ["--no-mmproj-offload"] : [])] : []),
1252
+ "--alias", alias,
1253
+ "--host", "127.0.0.1",
1254
+ "--port", String(PORT),
1255
+ // Idle VRAM eviction (server-managed; wakes on next request).
1256
+ ...sleepIdleArgsFor(readSettings().autoUnloadMins),
1257
+ ];
1258
+
1259
+ return launch(args, {
1260
+ mode: "single",
1261
+ bin: bin.path,
1262
+ modelLabel: basename(modelPath),
1263
+ modelPath,
1264
+ alias,
1265
+ ctx,
1266
+ mtpHeads: mtp,
1267
+ reasoningEffort: effortLevel,
1268
+ preserveThinking: preserve,
1269
+ mmproj,
1270
+ mmprojCpu: mmprojCpuFinal,
1271
+ cpuMoe: moeCfg.cpuMoe,
1272
+ nCpuMoe: moeCfg.nCpuMoe,
1273
+ expertUsed: moeCfg.expertUsed,
1274
+ kvTypeK: kvK,
1275
+ kvTypeV: kvV,
1276
+ splitMode: split,
1277
+ tensorSplit: normalizeTensorSplit(tensorSplit),
1278
+ sleepIdleSecs: sleepIdleSecsFor(readSettings().autoUnloadMins),
1279
+ logNote: "starting " + basename(modelPath) + " (ctx " + ctx + ", mtp " + mtp + ", effort " + effortLevel + (preserve ? ", preserve" : ", no-preserve") + ", kv " + kvK + "/" + kvV + (moeMeta?.isMoe ? ", experts " + (moeCfg.cpuMoe ? "cpu" : moeCfg.nCpuMoe > 0 ? "cpu-first-" + moeCfg.nCpuMoe : "gpu") + (moeCfg.expertUsed != null ? " top-" + moeCfg.expertUsed : "") : "") + ", mmproj " + (mmproj ? basename(mmproj) + (mmprojCpuFinal ? " (cpu)" : " (gpu)") : "none") + (splitArgs.length > 0 ? ", split " + split + (normalizeTensorSplit(tensorSplit) ? " " + normalizeTensorSplit(tensorSplit) : "") : "") + ", bin " + bin.path + ")",
1280
+ });
1281
+ }
1282
+
1283
+ async function startRouter(presetsPath) {
1284
+ const bin = requireBin();
1285
+ if (bin.error) return { ok: false, error: bin.error };
1286
+ if (!existsSync(presetsPath)) return { ok: false, error: "presets file missing: " + presetsPath };
1287
+ if (await portUp()) return { ok: false, error: "a server is already listening on port " + PORT + " — Stop it first" };
1288
+ const args = [
1289
+ "--models-preset", presetsPath,
1290
+ "--host", "127.0.0.1",
1291
+ "--port", String(PORT),
1292
+ "--models-max", String(ROUTER_MAX),
1293
+ // Idle VRAM eviction (server-managed; wakes on next request).
1294
+ ...sleepIdleArgsFor(readSettings().autoUnloadMins),
1295
+ ];
1296
+ return launch(args, { mode: "router", bin: bin.path, presetsPath, modelLabel: "router (" + basename(presetsPath) + ")", sleepIdleSecs: sleepIdleSecsFor(readSettings().autoUnloadMins), logNote: "starting router (" + basename(presetsPath) + ", bin " + bin.path + ")" });
1297
+ }
1298
+
1299
+ async function launch(args, info) {
1300
+ appendFileSync(LOG_PATH, "\n=== " + new Date().toISOString() + " " + info.logNote + " ===\n");
1301
+
1302
+ const next = spawn(info.bin, args, {
1303
+ env: { ...process.env, RADV_PERFTEST: "nogttspill" },
1304
+ stdio: ["ignore", "pipe", "pipe"],
1305
+ });
1306
+
1307
+ const sink = (data) => {
1308
+ try { appendFileSync(LOG_PATH, data); } catch { /* ignore */ }
1309
+ };
1310
+ next.stdout.on("data", sink);
1311
+ next.stderr.on("data", sink);
1312
+ next.on("error", (err) => {
1313
+ stopHealthPolling();
1314
+ clearKillTimer(next);
1315
+ if (child === next) child = null;
1316
+ state.status = "stopped";
1317
+ state.error = "spawn failed: " + err.message;
1318
+ });
1319
+ next.on("exit", (code, signal) => {
1320
+ stopHealthPolling();
1321
+ clearKillTimer(next);
1322
+ if (child === next) child = null;
1323
+ if (state.status !== "stopped") {
1324
+ state.status = "stopped";
1325
+ state.error = state.error ?? "server exited before becoming healthy (" + (signal ?? "code " + code) + ")";
1326
+ }
1327
+ });
1328
+
1329
+ child = next;
1330
+ state.status = "starting";
1331
+ state.pid = next.pid;
1332
+ state.mode = info.mode;
1333
+ state.binPath = info.bin ?? null;
1334
+ state.model = info.modelLabel ?? null;
1335
+ if (info.modelPath !== undefined) state.modelPath = info.modelPath;
1336
+ if (info.alias !== undefined) state.alias = info.alias;
1337
+ if (info.ctx !== undefined) state.ctx = info.ctx;
1338
+ if (info.mtpHeads !== undefined) state.mtpHeads = info.mtpHeads;
1339
+ if (info.reasoningEffort !== undefined) state.reasoningEffort = info.reasoningEffort;
1340
+ if (info.preserveThinking !== undefined) state.preserveThinking = info.preserveThinking;
1341
+ if (info.splitMode !== undefined) state.splitMode = info.splitMode;
1342
+ if (info.tensorSplit !== undefined) state.tensorSplit = info.tensorSplit;
1343
+ if (info.kvTypeK !== undefined) state.kvTypeK = info.kvTypeK;
1344
+ if (info.kvTypeV !== undefined) state.kvTypeV = info.kvTypeV;
1345
+ if (info.mmproj !== undefined) state.mmprojPath = info.mmproj;
1346
+ if (info.mmprojCpu !== undefined) state.mmprojCpu = info.mmprojCpu;
1347
+ if (info.cpuMoe !== undefined) state.cpuMoe = info.cpuMoe;
1348
+ if (info.nCpuMoe !== undefined) state.nCpuMoe = info.nCpuMoe;
1349
+ if (info.expertUsed !== undefined) state.expertUsed = info.expertUsed;
1350
+ if (info.presetsPath !== undefined) state.presetsPath = info.presetsPath;
1351
+ if (info.sleepIdleSecs !== undefined) state.sleepIdleSecs = info.sleepIdleSecs;
1352
+ state.loadedModels = [];
1353
+ state.port = PORT;
1354
+ state.startedAt = Date.now();
1355
+ state.error = null;
1356
+
1357
+ const seq = ++runSeq;
1358
+ const deadline = Date.now() + START_TIMEOUT_S * 1000;
1359
+ healthTimer = setInterval(async () => {
1360
+ if (seq !== runSeq) { stopHealthPolling(); return; }
1361
+ if (await portUp()) {
1362
+ stopHealthPolling();
1363
+ state.status = "ready";
1364
+ state.error = null;
1365
+ return;
1366
+ }
1367
+ if (Date.now() > deadline) {
1368
+ stopHealthPolling();
1369
+ if (state.status === "starting") {
1370
+ state.status = "stopped";
1371
+ state.error = "server did not become healthy within " + START_TIMEOUT_S + "s — see " + LOG_PATH;
1372
+ const c = child;
1373
+ child = null;
1374
+ if (c) killChild(c);
1375
+ }
1376
+ }
1377
+ }, 1000);
1378
+
1379
+ return { ok: true, pid: next.pid };
1380
+ }
1381
+
1382
+ async function status() {
1383
+ const s = { ...state };
1384
+ const settings = readSettings();
1385
+ // Runtime card state: what the setting says, what it resolves to right
1386
+ // now, and which layer of the chain produced that path.
1387
+ s.binConfigured = settings.binPath;
1388
+ s.bin = currentBin();
1389
+ // Only a version we already probed (Runtime card's Check): status must
1390
+ // never spawn the binary, and the poll runs every 2 s.
1391
+ s.binVersion = s.bin.resolved ? cachedBinVersion(s.bin.resolved) : null;
1392
+ s.shortcuts = shortcutDirs().map((s) => {
1393
+ // exists is reported, never enforced: an unmounted disk must not make
1394
+ // its shortcut vanish from the list (or from the tab).
1395
+ let exists = false;
1396
+ try { exists = statSync(s.path).isDirectory(); } catch { exists = false; }
1397
+ return { ...s, exists };
1398
+ });
1399
+ s.shortcutsSource = settings.shortcuts !== null ? "settings" : (SHORTCUTS_ENV.length > 0 ? "env" : "none");
1400
+ // GPU inventory for the tab's VRAM budget + the multi-GPU controls.
1401
+ // peekGpuInventory never awaits nvidia-smi: a pending probe reports the
1402
+ // assumption and the next poll (2 s later) has the real numbers.
1403
+ const inventory = peekGpuInventory();
1404
+ const overrideBytes = settings.vramGb > 0 ? Math.round(settings.vramGb * 1024 ** 3) : 0;
1405
+ s.gpus = inventory.gpus;
1406
+ s.vramTotalBytes = overrideBytes || inventory.totalBytes || VRAM_ASSUMED_BYTES;
1407
+ s.vramSource = overrideBytes > 0
1408
+ ? "override"
1409
+ : inventory.pending === true ? "probing" : (inventory.assumed ? "assumed" : inventory.source);
1410
+ s.vramOverrideGb = settings.vramGb;
1411
+ s.gpuCount = inventory.gpus.length;
1412
+ s.portUp = await portUp();
1413
+ s.uptimeMs = state.startedAt ? Date.now() - state.startedAt : null;
1414
+ try {
1415
+ s.presetsCount = cachedProfiles().length;
1416
+ } catch (err) {
1417
+ // A corrupt store must not break the poll; the tab keeps working
1418
+ // and the message explains the 0.
1419
+ s.presetsCount = 0;
1420
+ s.profilesError = err?.message ?? String(err);
1421
+ }
1422
+ s.routerMax = ROUTER_MAX;
1423
+ s.autostartRouter = readSettings().autostartRouter;
1424
+ s.autoUnloadMins = readSettings().autoUnloadMins;
1425
+ s.sleepIdleSecs = state.sleepIdleSecs;
1426
+ s.logSize = 0;
1427
+ try { s.logSize = statSync(LOG_PATH).size; } catch { /* no log yet */ }
1428
+ if (state.mode === "router" && s.portUp) {
1429
+ try {
1430
+ const res = await fetch("http://127.0.0.1:" + PORT + "/models", { signal: AbortSignal.timeout(1500) });
1431
+ if (res.ok) {
1432
+ const list = await res.json();
1433
+ const items = Array.isArray(list) ? list : (Array.isArray(list?.data) ? list.data : []);
1434
+ s.routerModels = items.map((m) => {
1435
+ const id = typeof m?.id === "string" ? m.id : (typeof m?.model === "string" ? m.model : null);
1436
+ const value = typeof m?.status?.value === "string" ? m.status.value : "unknown";
1437
+ const modes = Array.isArray(m?.architecture?.input_modalities)
1438
+ ? m.architecture.input_modalities.filter((x) => x === "text" || x === "image")
1439
+ : null;
1440
+ return id === null ? null : { id, value, modes };
1441
+ }).filter(Boolean);
1442
+ s.loadedModels = s.routerModels.filter((m) => m.value !== "unloaded").map((m) => m.id);
1443
+ }
1444
+ } catch {
1445
+ s.loadedModels = [];
1446
+ s.routerModels = [];
1447
+ }
1448
+ }
1449
+ return s;
1450
+ }
1451
+
1452
+ function dispose() {
1453
+ stopHealthPolling();
1454
+ const c = child;
1455
+ child = null;
1456
+ if (c) killChild(c);
1457
+ }
1458
+
1459
+ return { run, stop, status, dispose, startRouter };
1460
+ }
1461
+ // ---------------------------------------------------------------------------
1462
+ // HTTP helpers.
1463
+ // ---------------------------------------------------------------------------
1464
+ function readBody(req) {
1465
+ return new Promise((resolve, reject) => {
1466
+ let data = "";
1467
+ req.on("data", (chunk) => { data += chunk; });
1468
+ req.on("end", () => resolve(data));
1469
+ req.on("error", reject);
1470
+ });
1471
+ }
1472
+
1473
+ function sendJson(res, code, body) {
1474
+ const payload = JSON.stringify(body);
1475
+ res.writeHead(code, {
1476
+ "content-type": "application/json; charset=utf-8",
1477
+ "cache-control": "no-store",
1478
+ });
1479
+ res.end(payload);
1480
+ }
1481
+
1482
+ /** Roots the file endpoints (browse/gguf-meta/run, and profile saves) may
1483
+ * touch: the user's home, the browser shortcut dirs (Runtime card, else
1484
+ * LOCAL_MODELS_SHORTCUTS), the directories of the models already declared in
1485
+ * saved profiles — a saved profile must always stay loadable, whatever the
1486
+ * shortcut list says — and the llama.cpp dir when it really holds a
1487
+ * llama-server. Resolves lexically and requires containment so inputs can't
1488
+ * wander the filesystem.
1489
+ *
1490
+ * Nothing here is taken on faith: a path only widens the roots once it is a
1491
+ * directory the user configured, a profile that already exists, or a binary
1492
+ * that passes isLlamaServerPath(). */
1493
+ function allowedRoots() {
1494
+ const roots = [homedir(), ...shortcutDirs().map((s) => s.path)];
1495
+ try {
1496
+ for (const profile of cachedProfiles()) {
1497
+ for (const declared of [profile?.modelPath, profile?.mmprojPath]) {
1498
+ if (typeof declared === "string" && declared) roots.push(dirname(declared));
1499
+ }
1500
+ }
1501
+ } catch {
1502
+ /* a corrupt store must not break the file endpoints */
1503
+ }
1504
+ // The llama.cpp tree: the configured directory (it must hold a llama-server
1505
+ // to have been saved) and the binary the chain actually resolves to, but
1506
+ // only when that binary is a real llama-server.
1507
+ const configured = readSettings().binPath;
1508
+ if (configured) {
1509
+ const abs = expandHome(configured, homedir());
1510
+ try {
1511
+ if (statSync(abs).isDirectory()) roots.push(abs);
1512
+ } catch {
1513
+ /* not on disk (yet) */
1514
+ }
1515
+ }
1516
+ const resolved = currentBin().resolved;
1517
+ if (resolved && isLlamaServerPath(resolved)) roots.push(resolved);
1518
+ // A root naming the binary itself (not a dir) also opens the folder holding
1519
+ // it, so "Up"/"Browse…" from the picked path keeps working. Checked on a
1520
+ // snapshot: pushing while iterating the live array never terminates.
1521
+ for (const r of [...roots]) {
1522
+ const abs = resolve(r);
1523
+ try {
1524
+ if (!statSync(abs).isDirectory()) roots.push(dirname(abs));
1525
+ } catch {
1526
+ /* nothing on disk to open */
1527
+ }
1528
+ }
1529
+ return roots;
1530
+ }
1531
+
1532
+ /** A path we are willing to register as a browsable root: absolute, no ".."
1533
+ * segment, and not a filesystem root (which would list everything). Exported
1534
+ * for tests. */
1535
+ export function isSafeRootPath(p) {
1536
+ if (typeof p !== "string" || !p.trim()) return false;
1537
+ const raw = p.trim();
1538
+ if (!isAbsolute(raw)) return false;
1539
+ // Check the raw segments: resolve() collapses ".." before we could see it.
1540
+ if (raw.split(/[\\/]+/).includes("..")) return false;
1541
+ const abs = resolve(raw);
1542
+ return dirname(abs) !== abs;
1543
+ }
1544
+
1545
+ function isPathAllowed(p) {
1546
+ if (typeof p !== "string" || !p) return false;
1547
+ let abs;
1548
+ try {
1549
+ abs = resolve(p);
1550
+ } catch {
1551
+ return false;
1552
+ }
1553
+ return allowedRoots().some((root) => {
1554
+ let r;
1555
+ try {
1556
+ r = resolve(root);
1557
+ } catch {
1558
+ return false;
1559
+ }
1560
+ return abs === r || abs.startsWith(r + sep);
1561
+ });
1562
+ }
1563
+
1564
+ function listDir(target, allFiles) {
1565
+ let st;
1566
+ try {
1567
+ st = statSync(target);
1568
+ } catch {
1569
+ return null;
1570
+ }
1571
+ if (!st.isDirectory()) return null;
1572
+ const entries = [];
1573
+ let truncated = false;
1574
+ for (const name of readdirSync(target)) {
1575
+ if (name.startsWith(".")) continue;
1576
+ const full = join(target, name);
1577
+ let s;
1578
+ try {
1579
+ s = statSync(full);
1580
+ } catch {
1581
+ continue;
1582
+ }
1583
+ if (s.isDirectory()) entries.push({ name, type: "dir" });
1584
+ else if (s.isFile() && (allFiles || /\.gguf$/i.test(name))) entries.push({ name, type: "file", size: s.size });
1585
+ if (entries.length > 2000) { truncated = true; break; }
1586
+ }
1587
+ entries.sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : (a.type === "dir" ? -1 : 1));
1588
+ return { path: target, parent: dirname(target) === target ? null : dirname(target), entries, truncated };
1589
+ }
1590
+
1591
+ // ---------------------------------------------------------------------------
1592
+ // Provider profile: what gets written into llm-pi-ai when the user clicks
1593
+ // "Register in dsh". Pure and exported so the vision-modality logic can be
1594
+ // tested without a running server.
1595
+ // ---------------------------------------------------------------------------
1596
+ export function buildProviderProfile(st, requestedRoute) {
1597
+ const modelId = st.alias || "local-model";
1598
+ const fallbackRoute = "local-" + String(modelId).replace(/[^a-zA-Z0-9_-]+/g, "-");
1599
+ const route = typeof requestedRoute === "string" && /^[a-zA-Z0-9_-]+$/.test(requestedRoute)
1600
+ ? requestedRoute
1601
+ : fallbackRoute;
1602
+ const vision = Boolean(st.mmprojPath);
1603
+ // Fall back to the single-mode launch default (8192), not a larger
1604
+ // window: the advertisement must never exceed what an equivalent run()
1605
+ // would actually get.
1606
+ const contextWindow = st.ctx ?? 8192;
1607
+ const profile = {
1608
+ api: "openai-completions",
1609
+ baseURL: "http://127.0.0.1:" + st.port + "/v1",
1610
+ apiKeyEnv: "LOCAL_MODELS_API_KEY",
1611
+ displayName: "Local llama-server — " + (st.model || modelId) + (vision ? " (vision)" : ""),
1612
+ // Provider-neutral default thinking level, kept in sync with the tab's
1613
+ // launch-time selection so dsh selectors show the same default.
1614
+ reasoning: normalizeEffort(st.reasoningEffort),
1615
+ // Route-level wire-compatibility switches. This llama-server build
1616
+ // honors per-request reasoning_effort and stream_options include_usage.
1617
+ compat: {
1618
+ supportsReasoningEffort: true,
1619
+ supportsUsageInStreaming: true,
1620
+ },
1621
+ models: [{
1622
+ id: modelId,
1623
+ name: modelId,
1624
+ contextWindow,
1625
+ // Advertise the full 131K window as max output: the qwen3.x series
1626
+ // is a heavy thinker and at xhigh its thinking block alone blows
1627
+ // past the old 32K ceiling, so long replies get truncated. Still
1628
+ // never more than the window itself — a small-ctx profile must not
1629
+ // offer more output than it has room for.
1630
+ maxTokens: Math.min(MAX_OUTPUT_TOKENS, contextWindow),
1631
+ // Declaring image input is what makes a hand-declared vision model
1632
+ // usable: without it dsh rejects image attachments ("model does not
1633
+ // declare image input"). mmprojPath is set when a --mmproj file was
1634
+ // loaded, i.e. the model is actually multimodal.
1635
+ input: vision ? ["text", "image"] : ["text"],
1636
+ // Probe-validated thinking levels: the chat template accepts only
1637
+ // low/medium/xhigh as reasoning_effort values ("none" disables
1638
+ // thinking); advertising others would make dsh send a value the
1639
+ // template rejects with HTTP 500.
1640
+ reasoningEfforts: {
1641
+ off: "none",
1642
+ low: "low",
1643
+ medium: "medium",
1644
+ xhigh: "xhigh",
1645
+ },
1646
+ compat: { supportsReasoningEffort: true },
1647
+ }],
1648
+ };
1649
+ if (vision) {
1650
+ // Vision guards: keep oversized base64 images from stalling sessions —
1651
+ // dsh degrades overlarge images to text placeholders instead of failing.
1652
+ profile.maxRequestImageBytes = IMAGE_MAX_BYTES;
1653
+ profile.requestImagePixelBudget = IMAGE_PIXEL_BUDGET;
1654
+ profile.requestImageMaxBytes = IMAGE_MAX_BYTES;
1655
+ }
1656
+ return { profile, modelId, route };
1657
+ }
1658
+
1659
+ // ---------------------------------------------------------------------------
1660
+ // Saved profiles: named configurations persisted to
1661
+ // $DSH_HOME/local-models/profiles.json. Pure + exported for tests.
1662
+ // ---------------------------------------------------------------------------
1663
+ export function slugProfile(name) {
1664
+ const base = String(name).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1665
+ if (base.length <= 40) return base || "profile";
1666
+ // Long names: truncating to 40 chars would let two distinct names that
1667
+ // share a prefix silently overwrite each other on upsert. Keep a prefix
1668
+ // plus a hash of the full name so the id stays deterministic and unique.
1669
+ let h = 0;
1670
+ for (let i = 0; i < base.length; i++) h = (h * 31 + base.charCodeAt(i)) >>> 0;
1671
+ return base.slice(0, 32) + "-" + h.toString(36);
1672
+ }
1673
+
1674
+ export function profilesFile() {
1675
+ return join(dataDir(), "profiles.json");
1676
+ }
1677
+
1678
+ export function readProfiles() {
1679
+ let raw;
1680
+ try {
1681
+ raw = readFileSync(profilesFile(), "utf8");
1682
+ } catch (err) {
1683
+ // No store yet = no profiles. Anything else (permissions, I/O) is
1684
+ // surfaced instead of masquerading as "no profiles".
1685
+ if (err?.code === "ENOENT") return [];
1686
+ throw new Error("cannot read profiles store: " + (err?.message ?? String(err)));
1687
+ }
1688
+ let list;
1689
+ try {
1690
+ list = JSON.parse(raw);
1691
+ } catch {
1692
+ throw new Error("profiles.json is corrupt: " + profilesFile());
1693
+ }
1694
+ return Array.isArray(list) ? list : [];
1695
+ }
1696
+
1697
+ function writeJsonAtomic(file, value) {
1698
+ mkdirSync(dirname(file), { recursive: true });
1699
+ const tmp = file + ".tmp-" + process.pid;
1700
+ writeFileSync(tmp, JSON.stringify(value, null, 2), "utf8");
1701
+ renameSync(tmp, file);
1702
+ }
1703
+
1704
+ function writeProfiles(list) {
1705
+ writeJsonAtomic(profilesFile(), list);
1706
+ presetsCache = list;
1707
+ }
1708
+
1709
+ // Plugin settings (dataDir/settings.json). Missing or corrupt file =
1710
+ // defaults; the toggle rewrites valid JSON on next change.
1711
+ export function settingsFile() {
1712
+ return join(dataDir(), "settings.json");
1713
+ }
1714
+
1715
+ export function normalizeSettings(raw) {
1716
+ // Idle-eviction timer (minutes, 0 = off). Default 30: an idle 27B pins
1717
+ // ~10 GB of the 16 GB card, while reloading costs tens of seconds —
1718
+ // 30 min is the "stepped away" boundary; shorter evicts between
1719
+ // back-to-back tasks, longer pins VRAM all evening.
1720
+ const mins = Number.isInteger(raw?.autoUnloadMins) && raw.autoUnloadMins >= 0
1721
+ ? raw.autoUnloadMins
1722
+ : 30;
1723
+ // llama.cpp binaries (the Runtime card). "" = auto-detect through the
1724
+ // candidate chain; stored as typed (a dir or the binary itself) and never
1725
+ // validated here, so a stale path can't brick the settings file — status
1726
+ // reports it as unresolved and Load refuses with a readable error.
1727
+ const binPath = typeof raw?.binPath === "string" ? raw.binPath.trim() : "";
1728
+ // File-browser shortcuts (the Runtime card's folder list). null = never
1729
+ // configured, so LOCAL_MODELS_SHORTCUTS still applies; [] = the user took
1730
+ // the list over and emptied it (removing the last env-derived folder must
1731
+ // stick, not silently fall back to the env var). Non-strings and blanks are
1732
+ // dropped instead of breaking the 2 s status poll.
1733
+ const shortcuts = raw?.shortcuts === undefined || raw?.shortcuts === null
1734
+ ? null
1735
+ : Array.isArray(raw.shortcuts)
1736
+ ? raw.shortcuts
1737
+ .filter((p) => typeof p === "string" && p.trim())
1738
+ .map((p) => p.trim())
1739
+ .filter((p, i, a) => a.indexOf(p) === i)
1740
+ .slice(0, 12)
1741
+ : null;
1742
+ // VRAM budget override in GiB (0 = auto-detect). Only needed on hardware the
1743
+ // probes cannot read (or to cap a multi-GPU total by hand): the tab's
1744
+ // estimator otherwise uses what detectGpus() found, falling back to the
1745
+ // historic 16 GiB assumption.
1746
+ const vramGb = Number.isFinite(raw?.vramGb) && raw.vramGb > 0 && raw.vramGb <= 1024
1747
+ ? Math.round(raw.vramGb * 100) / 100
1748
+ : 0;
1749
+ return { autostartRouter: raw?.autostartRouter === true, autoUnloadMins: mins, binPath, shortcuts, vramGb };
1750
+ }
1751
+
1752
+ export function readSettings() {
1753
+ try {
1754
+ return normalizeSettings(JSON.parse(readFileSync(settingsFile(), "utf8")));
1755
+ } catch {
1756
+ return normalizeSettings(null);
1757
+ }
1758
+ }
1759
+
1760
+ export function writeSettings(raw) {
1761
+ const next = normalizeSettings(raw);
1762
+ writeJsonAtomic(settingsFile(), next);
1763
+ return next;
1764
+ }
1765
+
1766
+ // Cached profile list for the status poll (every ~2s): invalidated by
1767
+ // writeProfiles. External edits (the ops skill) take effect on restart,
1768
+ // which is already the documented flow for node-half changes.
1769
+ let presetsCache = null;
1770
+ function cachedProfiles() {
1771
+ if (presetsCache === null) presetsCache = readProfiles();
1772
+ return presetsCache;
1773
+ }
1774
+
1775
+ /** Validate + normalize one profile config; throws on bad input. */
1776
+ export function normalizeProfileConfig(raw) {
1777
+ const modelPath = typeof raw?.modelPath === "string" && raw.modelPath ? raw.modelPath : "";
1778
+ if (!modelPath || !existsSync(modelPath)) throw new Error("profile needs a modelPath that exists on disk");
1779
+ if (!isPathAllowed(modelPath)) throw new Error("modelPath is outside the folders this tab may read: " + modelPath + " — add its folder under Model folders in the Runtime card first");
1780
+ const ctx = Number.isInteger(raw?.ctx) && raw.ctx > 0 ? raw.ctx : 8192;
1781
+ const mtpHeads = Number.isInteger(raw?.mtpHeads) && raw.mtpHeads >= 0 ? Math.min(raw.mtpHeads, MTP_MAX) : 0;
1782
+ const mmprojPath = typeof raw?.mmprojPath === "string" && raw.mmprojPath ? raw.mmprojPath : null;
1783
+ if (mmprojPath && !existsSync(mmprojPath)) throw new Error("mmprojPath does not exist: " + mmprojPath);
1784
+ if (mmprojPath && !isPathAllowed(mmprojPath)) throw new Error("mmprojPath is outside the folders this tab may read: " + mmprojPath + " — add its folder under Model folders in the Runtime card first");
1785
+ const effort = normalizeEffort(raw?.effort);
1786
+ const preserveThinking = normalizePreserveThinking(raw);
1787
+ // Multi-GPU placement. Legacy profiles have neither field and keep
1788
+ // llama.cpp's own default (layer split, proportional tensor split).
1789
+ const splitMode = normalizeSplitMode(raw?.splitMode);
1790
+ const tensorSplit = normalizeTensorSplit(raw?.tensorSplit);
1791
+ // KV cache quantization pair (--cache-type-k / --cache-type-v). Legacy
1792
+ // profiles without either field keep the tuned default pair.
1793
+ const kvTypeK = normalizeKvCacheType(raw?.kvTypeK, KV_CACHE_DEFAULT_K);
1794
+ const kvTypeV = normalizeKvCacheType(raw?.kvTypeV, KV_CACHE_DEFAULT_V);
1795
+ // Vision projector placement (--no-mmproj-offload). Profiles saved before
1796
+ // this field existed launched with the projector on CPU, so missing/legacy
1797
+ // values default to true; an explicit false keeps vision on the GPU.
1798
+ const mmprojCpu = raw?.mmprojCpu === false ? false : raw?.mmprojCpu === true ? true : MMPROJ_CPU;
1799
+ // MoE launch config (arch is informational: which <arch>.expert_used_count
1800
+ // key a top-k override targets; re-validated from the GGUF at launch).
1801
+ const moe = normalizeMoEConfig(raw);
1802
+ return { modelPath, ctx, mtpHeads, mmprojPath, mmprojCpu, effort, preserveThinking, kvTypeK, kvTypeV, splitMode, tensorSplit, ...moe };
1803
+ }
1804
+
1805
+ /** Save (or upsert by slugified name) one profile. Returns the saved profile. */
1806
+ export function saveProfile(name, config) {
1807
+ const trimmed = typeof name === "string" ? name.trim() : "";
1808
+ if (!trimmed) throw new Error("profile needs a non-empty name");
1809
+ const normalized = normalizeProfileConfig(config);
1810
+ const id = slugProfile(trimmed);
1811
+ const entry = { id, name: trimmed, ...normalized, updatedAt: new Date().toISOString() };
1812
+ const list = readProfiles();
1813
+ const next = [entry, ...list.filter((p) => (p?.id ?? slugProfile(p?.name ?? "")) !== id)];
1814
+ writeProfiles(next);
1815
+ return entry;
1816
+ }
1817
+
1818
+ /** Remove a profile by name (or id). Returns true if something was removed. */
1819
+ export function removeProfile(name) {
1820
+ const id = slugProfile(name);
1821
+ const list = readProfiles();
1822
+ const next = list.filter((p) => (p?.id ?? slugProfile(p?.name ?? "")) !== id);
1823
+ if (next.length === list.length) return false;
1824
+ writeProfiles(next);
1825
+ return true;
1826
+ }
1827
+
1828
+ // ---------------------------------------------------------------------------
1829
+ // Router mode: generate llama-server router presets from saved profiles and
1830
+ // build a multi-model dsh provider route. Pure + exported for tests.
1831
+ // ---------------------------------------------------------------------------
1832
+ export function generateRouterPresets(profiles) {
1833
+ const lines = [
1834
+ "[*]",
1835
+ "flash-attn = on",
1836
+ // Shared KV defaults; each profile may override them below (the preset
1837
+ // loader cascades [*] first, then the model's own section).
1838
+ "cache-type-k = " + KV_CACHE_DEFAULT_K,
1839
+ "cache-type-v = " + KV_CACHE_DEFAULT_V,
1840
+ "spec-draft-type-k = q4_0",
1841
+ "spec-draft-type-v = q4_0",
1842
+ "kv-unified = 1",
1843
+ "cont-batching = 1",
1844
+ "jinja = 1",
1845
+ "t = 4",
1846
+ // Single sequence: the daily use is one model at a time, and MTP
1847
+ // buffer sharing is cheapest at -np 1.
1848
+ "parallel = 1",
1849
+ "poll = 0",
1850
+ "",
1851
+ ];
1852
+ for (const p of profiles) {
1853
+ const id = p.id || slugProfile(p.name);
1854
+ lines.push("[" + id + "]");
1855
+ lines.push("model = " + p.modelPath);
1856
+ lines.push("ctx-size = " + (p.ctx || 8192));
1857
+ lines.push("alias = " + id);
1858
+ lines.push("batch-size = 2048");
1859
+ lines.push("ubatch-size = 512");
1860
+ lines.push("n-gpu-layers = 999");
1861
+ // KV cache quantization, explicit per model (legacy profiles without
1862
+ // the fields land on the same pair the [*] section carries).
1863
+ lines.push("cache-type-k = " + normalizeKvCacheType(p.kvTypeK, KV_CACHE_DEFAULT_K));
1864
+ lines.push("cache-type-v = " + normalizeKvCacheType(p.kvTypeV, KV_CACHE_DEFAULT_V));
1865
+ // MoE expert placement: all experts in RAM (--cpu-moe) or the first
1866
+ // N layers' experts (--n-cpu-moe); dense profiles skip both.
1867
+ if (p.cpuMoe === true) lines.push("cpu-moe = 1");
1868
+ else if (Number.isInteger(p.nCpuMoe) && p.nCpuMoe > 0) lines.push("n-cpu-moe = " + p.nCpuMoe);
1869
+ // MoE top-k override (needs the profile's stored arch for the key).
1870
+ if (Number.isInteger(p.expertUsed) && p.expertUsed > 0 && p.arch) {
1871
+ lines.push("override-kv = " + p.arch + ".expert_used_count=int:" + p.expertUsed);
1872
+ }
1873
+ if (p.mmprojPath) {
1874
+ lines.push("mmproj = " + p.mmprojPath);
1875
+ // Qwen-VL needs a 1024-token image minimum (load_hparams warning).
1876
+ lines.push("image-min-tokens = 1024");
1877
+ // Vision projector placement, same default as single mode
1878
+ // (--no-mmproj-offload): legacy profiles without the field stay
1879
+ // on CPU; an explicit mmprojCpu: false keeps vision on the GPU.
1880
+ if (p.mmprojCpu !== false) lines.push("no-mmproj-offload = 1");
1881
+ }
1882
+ // MTP draft spec: unconditional whenever MTP is on — the old ctx
1883
+ // ceiling / "ignore softcap" toggle is gone.
1884
+ const mtp = Number.isInteger(p.mtpHeads) && p.mtpHeads > 0 ? Math.min(p.mtpHeads, MTP_MAX) : 0;
1885
+ // Preset keys are CLI args without the leading dashes; the default split
1886
+ // (layer, no proportions) stays implicit exactly like in single mode.
1887
+ const split = normalizeSplitMode(p.splitMode);
1888
+ const tensorSplit = normalizeTensorSplit(p.tensorSplit);
1889
+ const ctxSize = Number.isInteger(p.ctx) && p.ctx > 0 ? p.ctx : 8192;
1890
+ if (mtp > 0) {
1891
+ lines.push("spec-type = draft-mtp");
1892
+ lines.push("spec-draft-n-max = " + mtp);
1893
+ lines.push("spec-draft-p-min = " + MTP_P_MIN);
1894
+ }
1895
+ if (split !== SPLIT_MODE_DEFAULT) lines.push("split-mode = " + split);
1896
+ if (tensorSplit) lines.push("tensor-split = " + tensorSplit);
1897
+ if (p.effort === "off") lines.push("reasoning = off");
1898
+ else if (p.effort) lines.push("chat-template-kwargs = " + JSON.stringify({ reasoning_effort: p.effort }));
1899
+ // Reasoning-trace preservation: explicit either way (upstream defaults
1900
+ // to preserve ON, while this plugin's historical default is OFF), so
1901
+ // existing profiles without the field stay byte-identical (0).
1902
+ // Skipped when reasoning is off — there is no trace to preserve.
1903
+ if (p.effort !== "off") {
1904
+ const preserve = normalizePreserveThinking(p);
1905
+ lines.push("reasoning-preserve = " + (preserve ? "1" : "0"));
1906
+ }
1907
+ lines.push("");
1908
+ }
1909
+ return lines.join("\n");
1910
+ }
1911
+
1912
+ /** Best-effort id -> input-modalities map from the running router. */
1913
+ export async function routerModalities() {
1914
+ try {
1915
+ const res = await fetch("http://127.0.0.1:" + PORT + "/models", { signal: AbortSignal.timeout(1500) });
1916
+ if (!res.ok) return {};
1917
+ const list = await res.json();
1918
+ const items = Array.isArray(list) ? list : (Array.isArray(list?.data) ? list.data : []);
1919
+ const out = {};
1920
+ for (const m of items) {
1921
+ const id = typeof m?.id === "string" ? m.id : null;
1922
+ if (id === null) continue;
1923
+ const modes = (m?.architecture?.input_modalities || []).filter((x) => x === "text" || x === "image");
1924
+ if (modes.length > 0) out[id] = modes;
1925
+ }
1926
+ return out;
1927
+ } catch {
1928
+ return {};
1929
+ }
1930
+ }
1931
+
1932
+ export function buildRouterProfile(profiles, modalitiesByModel) {
1933
+ const models = profiles.map((p) => {
1934
+ const id = p.id || slugProfile(p.name);
1935
+ const detected = modalitiesByModel && modalitiesByModel[id];
1936
+ const input = (detected && detected.length > 0)
1937
+ ? detected
1938
+ : p.mmprojPath ? ["text", "image"] : ["text"];
1939
+ return {
1940
+ id,
1941
+ name: id,
1942
+ contextWindow: p.ctx ?? 8192,
1943
+ maxTokens: Math.min(MAX_OUTPUT_TOKENS, p.ctx ?? 8192),
1944
+ input,
1945
+ reasoningEfforts: { off: "none", low: "low", medium: "medium", xhigh: "xhigh" },
1946
+ compat: { supportsReasoningEffort: true },
1947
+ };
1948
+ });
1949
+ return {
1950
+ modelId: "router",
1951
+ route: "local-router",
1952
+ profile: {
1953
+ api: "openai-completions",
1954
+ baseURL: "http://127.0.0.1:" + PORT + "/v1",
1955
+ apiKeyEnv: "LOCAL_MODELS_API_KEY",
1956
+ displayName: "Local llama-server (router — " + profiles.length + " models)",
1957
+ reasoning: "medium",
1958
+ compat: { supportsReasoningEffort: true, supportsUsageInStreaming: true },
1959
+ models,
1960
+ },
1961
+ };
1962
+ }
1963
+
1964
+ // ---------------------------------------------------------------------------
1965
+ // Plugin apply: register routes + effect-owned process.
1966
+ // ---------------------------------------------------------------------------
1967
+ export function apply(ctx) {
1968
+ const manager = createManager();
1969
+
1970
+ // Publish one provider route to dsh (shared by the register button and
1971
+ // boot autostart). Throws on settings failure.
1972
+ async function publishProvider(route, profile, modelId) {
1973
+ const API_KEY_REF = "LOCAL_MODELS_API_KEY";
1974
+ // Assumes ctx.settings.update() merges per provider key: a
1975
+ // wholesale replace here would wipe other providers, so if
1976
+ // registering a second route ever drops the first, this
1977
+ // call is the place to revisit.
1978
+ await ctx.settings.update("llm-pi-ai", { providers: { [route]: profile } });
1979
+ // llama-server accepts any key; provision a non-empty placeholder so the
1980
+ // provider is usable. Rejects if the live environment already supplies it.
1981
+ let keyNote = "";
1982
+ try {
1983
+ await ctx.credentials.set(API_KEY_REF, "local-llama-server");
1984
+ keyNote = "placeholder api key written to " + API_KEY_REF;
1985
+ } catch (ke) {
1986
+ keyNote = "api key from environment: " + (ke?.message ?? String(ke));
1987
+ }
1988
+ return { ok: true, route, modelId, profile, keyNote };
1989
+ }
1990
+
1991
+ const routeSpec = [
1992
+ {
1993
+ kind: "exact",
1994
+ path: "/local-models/browse",
1995
+ handler: async (req, res) => {
1996
+ const url = new URL(req.url ?? "/", "http://x");
1997
+ const dir = url.searchParams.get("dir");
1998
+ const allFiles = url.searchParams.get("all") === "1";
1999
+ const target = dir && dir.indexOf("/") === 0
2000
+ ? dir
2001
+ : dir ? join(homedir(), dir) : homedir();
2002
+ if (!isPathAllowed(target)) {
2003
+ sendJson(res, 400, { error: "path is outside the folders this tab may read (home + Model folders + the llama.cpp dir): " + target });
2004
+ return;
2005
+ }
2006
+ const listing = listDir(target, allFiles);
2007
+ if (listing === null) {
2008
+ sendJson(res, 400, { error: "not a readable directory: " + target });
2009
+ return;
2010
+ }
2011
+ sendJson(res, 200, listing);
2012
+ },
2013
+ },
2014
+ {
2015
+ kind: "exact",
2016
+ path: "/local-models/gguf-meta",
2017
+ handler: async (req, res) => {
2018
+ let body;
2019
+ try {
2020
+ body = JSON.parse((await readBody(req)) || "{}");
2021
+ } catch {
2022
+ sendJson(res, 400, { error: "invalid JSON body" });
2023
+ return;
2024
+ }
2025
+ const path = typeof body.path === "string" ? body.path : "";
2026
+ if (!path) {
2027
+ sendJson(res, 400, { error: "missing `path`" });
2028
+ return;
2029
+ }
2030
+ if (!isPathAllowed(path)) {
2031
+ sendJson(res, 400, { error: "path is outside the folders this tab may read (home + Model folders + the llama.cpp dir)" });
2032
+ return;
2033
+ }
2034
+ try {
2035
+ sendJson(res, 200, parseGGUFCached(path));
2036
+ } catch (err) {
2037
+ sendJson(res, 422, { error: err?.message ?? String(err) });
2038
+ }
2039
+ },
2040
+ },
2041
+ {
2042
+ kind: "exact",
2043
+ path: "/local-models/status",
2044
+ handler: async (_req, res) => sendJson(res, 200, await manager.status()),
2045
+ },
2046
+ {
2047
+ // Terminal tail: serve llama-server.log from a byte offset (no offset =
2048
+ // last `max` bytes). nextOffset is the cursor for the next poll.
2049
+ kind: "exact",
2050
+ path: "/local-models/logs",
2051
+ handler: async (req, res) => {
2052
+ const url = new URL(req.url ?? "/", "http://x");
2053
+ const maxParam = Number(url.searchParams.get("max"));
2054
+ const max = Number.isFinite(maxParam) && maxParam > 0
2055
+ ? Math.min(Math.floor(maxParam), 1024 * 1024)
2056
+ : 256 * 1024;
2057
+ const offsetParam = url.searchParams.get("offset");
2058
+ const offset = offsetParam === null || offsetParam === "" ? null : Number(offsetParam);
2059
+ let size = 0;
2060
+ try { size = statSync(LOG_PATH).size; } catch { size = 0; }
2061
+ let start = 0;
2062
+ let text = "";
2063
+ let got = 0;
2064
+ if (size > 0) {
2065
+ // No valid offset, or the file shrank below it (restarted/rotated) → rebase on the tail.
2066
+ if (offset === null || !Number.isFinite(offset) || offset < 0 || offset >= size) {
2067
+ start = Math.max(0, size - max);
2068
+ } else {
2069
+ start = Math.max(0, Math.floor(offset));
2070
+ }
2071
+ const len = Math.min(max, size - start);
2072
+ if (len > 0) {
2073
+ try {
2074
+ const fd = openSync(LOG_PATH, "r");
2075
+ try {
2076
+ const buf = Buffer.alloc(len);
2077
+ got = readSync(fd, buf, 0, len, start);
2078
+ text = buf.toString("utf8", 0, got);
2079
+ } finally {
2080
+ closeSync(fd);
2081
+ }
2082
+ } catch {
2083
+ text = "";
2084
+ got = 0;
2085
+ }
2086
+ }
2087
+ }
2088
+ // Cursor for the next poll: bytes consumed, not the byte length
2089
+ // of the decoded slice (a slice starting/ending mid-UTF-8-char
2090
+ // decodes to U+FFFD, whose 3 re-encoded bytes would drift us).
2091
+ const nextOffset = start + got;
2092
+ sendJson(res, 200, { text, offset: start, nextOffset, size, atEOF: nextOffset >= size });
2093
+ },
2094
+ },
2095
+ {
2096
+ kind: "exact",
2097
+ path: "/local-models/run",
2098
+ handler: async (req, res) => {
2099
+ let body;
2100
+ try {
2101
+ body = JSON.parse((await readBody(req)) || "{}");
2102
+ } catch {
2103
+ sendJson(res, 400, { error: "invalid JSON body" });
2104
+ return;
2105
+ }
2106
+ const path = typeof body.path === "string" ? body.path : "";
2107
+ const ctx = body.ctx === undefined ? undefined : Number(body.ctx);
2108
+ const mtp = body.mtp === undefined ? 0 : Number(body.mtp);
2109
+ const mmproj = typeof body.mmproj === "string" && body.mmproj ? body.mmproj : null;
2110
+ const effort = typeof body.effort === "string" ? body.effort : undefined;
2111
+ const kvTypeK = normalizeKvCacheType(body.kvTypeK, KV_CACHE_DEFAULT_K);
2112
+ const kvTypeV = normalizeKvCacheType(body.kvTypeV, KV_CACHE_DEFAULT_V);
2113
+ const moe = {
2114
+ cpuMoe: body.cpuMoe === true,
2115
+ nCpuMoe: body.nCpuMoe === undefined ? 0 : Number(body.nCpuMoe),
2116
+ expertUsed: body.expertUsed === undefined || body.expertUsed === null || body.expertUsed === "" ? null : Number(body.expertUsed),
2117
+ arch: typeof body.arch === "string" ? body.arch : null,
2118
+ };
2119
+ if (!path) {
2120
+ sendJson(res, 400, { error: "missing `path`" });
2121
+ return;
2122
+ }
2123
+ if (!isPathAllowed(path)) {
2124
+ sendJson(res, 400, { error: "model path is outside the folders this tab may read: " + path + " — add its folder under Model folders in the Runtime card" });
2125
+ return;
2126
+ }
2127
+ if (mmproj && !isPathAllowed(mmproj)) {
2128
+ sendJson(res, 400, { error: "mmproj path is outside the folders this tab may read: " + mmproj + " — add its folder under Model folders in the Runtime card" });
2129
+ return;
2130
+ }
2131
+ const result = await manager.run(path, Number.isFinite(ctx) ? ctx : 8192, Number.isFinite(mtp) ? mtp : 0, mmproj, effort, typeof body.mmprojCpu === "boolean" ? body.mmprojCpu : MMPROJ_CPU, moe, normalizePreserveThinking(body), kvTypeK, kvTypeV, normalizeSplitMode(body.splitMode), normalizeTensorSplit(body.tensorSplit));
2132
+ sendJson(res, result.ok ? 202 : 409, result);
2133
+ },
2134
+ },
2135
+ {
2136
+ kind: "exact",
2137
+ path: "/local-models/stop",
2138
+ handler: async (_req, res) => {
2139
+ const stopped = await manager.stop();
2140
+ sendJson(res, 200, { stopped });
2141
+ },
2142
+ },
2143
+ {
2144
+ kind: "exact",
2145
+ path: "/local-models/profiles",
2146
+ handler: async (req, res) => {
2147
+ if (req.method === "POST") {
2148
+ let body;
2149
+ try {
2150
+ body = JSON.parse((await readBody(req)) || "{}");
2151
+ } catch {
2152
+ sendJson(res, 400, { error: "invalid JSON body" });
2153
+ return;
2154
+ }
2155
+ try {
2156
+ const profile = saveProfile(
2157
+ typeof body.name === "string" ? body.name : "",
2158
+ {
2159
+ modelPath: body.modelPath,
2160
+ ctx: body.ctx,
2161
+ mtpHeads: body.mtpHeads,
2162
+ mmprojPath: body.mmprojPath,
2163
+ mmprojCpu: body.mmprojCpu,
2164
+ effort: body.effort,
2165
+ preserveThinking: normalizePreserveThinking(body),
2166
+ kvTypeK: body.kvTypeK,
2167
+ kvTypeV: body.kvTypeV,
2168
+ splitMode: body.splitMode,
2169
+ tensorSplit: body.tensorSplit,
2170
+ cpuMoe: body.cpuMoe,
2171
+ nCpuMoe: body.nCpuMoe,
2172
+ expertUsed: body.expertUsed,
2173
+ arch: body.arch,
2174
+ },
2175
+ );
2176
+ sendJson(res, 200, { ok: true, profile, profiles: readProfiles() });
2177
+ } catch (err) {
2178
+ sendJson(res, 400, { error: err?.message ?? String(err) });
2179
+ }
2180
+ return;
2181
+ }
2182
+ try {
2183
+ sendJson(res, 200, { profiles: readProfiles() });
2184
+ } catch (err) {
2185
+ sendJson(res, 500, { error: err?.message ?? String(err) });
2186
+ }
2187
+ },
2188
+ },
2189
+ {
2190
+ kind: "exact",
2191
+ path: "/local-models/profiles/remove",
2192
+ handler: async (req, res) => {
2193
+ if (req.method !== "POST") {
2194
+ sendJson(res, 405, { error: "POST required" });
2195
+ return;
2196
+ }
2197
+ let body;
2198
+ try {
2199
+ body = JSON.parse((await readBody(req)) || "{}");
2200
+ } catch {
2201
+ sendJson(res, 400, { error: "invalid JSON body" });
2202
+ return;
2203
+ }
2204
+ let removed;
2205
+ let profiles;
2206
+ try {
2207
+ removed = removeProfile(typeof body.name === "string" ? body.name : "");
2208
+ profiles = readProfiles();
2209
+ } catch (err) {
2210
+ sendJson(res, 500, { error: err?.message ?? String(err) });
2211
+ return;
2212
+ }
2213
+ sendJson(res, 200, { ok: true, removed, profiles });
2214
+ },
2215
+ },
2216
+ {
2217
+ kind: "exact",
2218
+ path: "/local-models/settings",
2219
+ handler: async (req, res) => {
2220
+ if (req.method === "POST") {
2221
+ let body;
2222
+ try {
2223
+ body = JSON.parse((await readBody(req)) || "{}");
2224
+ } catch {
2225
+ sendJson(res, 400, { error: "invalid JSON body" });
2226
+ return;
2227
+ }
2228
+ const patch = {};
2229
+ if (body.autostartRouter !== undefined) {
2230
+ if (body.autostartRouter !== true && body.autostartRouter !== false) {
2231
+ sendJson(res, 400, { error: "autostartRouter must be true or false" });
2232
+ return;
2233
+ }
2234
+ patch.autostartRouter = body.autostartRouter;
2235
+ }
2236
+ if (body.autoUnloadMins !== undefined) {
2237
+ if (!Number.isInteger(body.autoUnloadMins) || body.autoUnloadMins < 0) {
2238
+ sendJson(res, 400, { error: "autoUnloadMins must be a non-negative integer (minutes, 0 = off)" });
2239
+ return;
2240
+ }
2241
+ patch.autoUnloadMins = body.autoUnloadMins;
2242
+ }
2243
+ // llama.cpp binaries: "" = auto-detect. A non-empty value must
2244
+ // resolve to an existing llama-server, so a typo can't be saved
2245
+ // and then fail at the next Load. The Check button lets the user
2246
+ // test a path (and read its --version) before committing it.
2247
+ if (body.binPath !== undefined) {
2248
+ if (typeof body.binPath !== "string") {
2249
+ sendJson(res, 400, { error: "binPath must be a string (a directory or the llama-server binary; \"\" = auto-detect)" });
2250
+ return;
2251
+ }
2252
+ const wanted = body.binPath.trim();
2253
+ if (wanted) {
2254
+ const probe = resolveBin({
2255
+ configured: wanted,
2256
+ home: homedir(),
2257
+ search: false,
2258
+ });
2259
+ if (!probe.resolved) {
2260
+ sendJson(res, 400, { error: "no llama-server found at " + wanted + " — expected the directory holding it (e.g. ~/llama.cpp/build/bin) or the binary itself" });
2261
+ return;
2262
+ }
2263
+ // Existing is not enough: it must BE a llama-server. This is
2264
+ // also what keeps a stray file from becoming a browse root.
2265
+ const bad = binIdentityError(probe.resolved);
2266
+ if (bad) {
2267
+ sendJson(res, 400, { error: bad + " — point at the directory holding llama-server (e.g. ~/llama.cpp/build/bin) or at the binary itself" });
2268
+ return;
2269
+ }
2270
+ }
2271
+ patch.binPath = wanted;
2272
+ }
2273
+ // File-browser shortcuts are browsable roots, so they are the one
2274
+ // place where a bad value widens the file endpoints: absolute,
2275
+ // no "..", never the filesystem root, at most 12 entries.
2276
+ // Missing dirs are still kept (an unmounted disk must not
2277
+ // silently lose its shortcut); the tab marks them as missing.
2278
+ if (body.shortcuts !== undefined) {
2279
+ if (!Array.isArray(body.shortcuts) || body.shortcuts.some((p) => typeof p !== "string")) {
2280
+ sendJson(res, 400, { error: "shortcuts must be an array of directory paths" });
2281
+ return;
2282
+ }
2283
+ const list = body.shortcuts.map((p) => p.trim()).filter(Boolean);
2284
+ if (list.length > 12) {
2285
+ sendJson(res, 400, { error: "at most 12 shortcut folders (got " + list.length + ")" });
2286
+ return;
2287
+ }
2288
+ const bad = list.find((p) => !isSafeRootPath(p));
2289
+ if (bad) {
2290
+ sendJson(res, 400, { error: "not a usable folder: " + bad + " — shortcuts must be absolute paths, without \"..\", and not the filesystem root" });
2291
+ return;
2292
+ }
2293
+ patch.shortcuts = list;
2294
+ }
2295
+ // VRAM budget override in GiB (0 = auto-detect through the GPU
2296
+ // probes). Bounded so a typo cannot produce a nonsense budget.
2297
+ if (body.vramGb !== undefined) {
2298
+ if (typeof body.vramGb !== "number" || !Number.isFinite(body.vramGb) || body.vramGb < 0 || body.vramGb > 1024) {
2299
+ sendJson(res, 400, { error: "vramGb must be a number between 0 (auto) and 1024" });
2300
+ return;
2301
+ }
2302
+ patch.vramGb = body.vramGb;
2303
+ }
2304
+ sendJson(res, 200, { ok: true, settings: writeSettings({ ...readSettings(), ...patch }) });
2305
+ return;
2306
+ }
2307
+ sendJson(res, 200, { settings: readSettings() });
2308
+ },
2309
+ },
2310
+ {
2311
+ // Runtime card's "Check": resolve + `<bin> --version`, without saving.
2312
+ // Body { binPath } ("" = whatever is in effect now).
2313
+ kind: "exact",
2314
+ path: "/local-models/runtime/check",
2315
+ handler: async (req, res) => {
2316
+ if (req.method !== "POST") { sendJson(res, 405, { error: "POST required" }); return; }
2317
+ let body;
2318
+ try {
2319
+ body = JSON.parse((await readBody(req)) || "{}");
2320
+ } catch {
2321
+ sendJson(res, 400, { error: "invalid JSON body" });
2322
+ return;
2323
+ }
2324
+ if (body.binPath !== undefined && typeof body.binPath !== "string") {
2325
+ sendJson(res, 400, { error: "binPath must be a string" });
2326
+ return;
2327
+ }
2328
+ const override = typeof body.binPath === "string" ? body.binPath.trim() : "";
2329
+ // An explicit override is tested on its own (no fallback to PATH
2330
+ // or the home build dirs), otherwise the user could "verify" a
2331
+ // path that is not the one that would be launched.
2332
+ const bin = override
2333
+ ? resolveBin({ configured: override, home: homedir(), search: false })
2334
+ : currentBin();
2335
+ // Identity first: a green check has to mean "this is a llama-server
2336
+ // that starts", not "some file printed a banner".
2337
+ const identity = bin.resolved
2338
+ ? binIdentityError(bin.resolved)
2339
+ : "no " + BIN_NAME + " found in " + (override || "the search path");
2340
+ const version = identity ? null : await probeBin(bin.resolved);
2341
+ sendJson(res, 200, {
2342
+ ok: !identity && (!version || version.ok),
2343
+ error: identity,
2344
+ bin,
2345
+ version,
2346
+ });
2347
+ },
2348
+ },
2349
+ {
2350
+ kind: "exact",
2351
+ path: "/local-models/router/start",
2352
+ handler: async (req, res) => {
2353
+ if (req.method !== "POST") { sendJson(res, 405, { error: "POST required" }); return; }
2354
+ let profiles;
2355
+ try {
2356
+ profiles = readProfiles();
2357
+ } catch (err) {
2358
+ sendJson(res, 500, { error: err?.message ?? String(err) });
2359
+ return;
2360
+ }
2361
+ if (profiles.length === 0) { sendJson(res, 400, { error: "save at least one profile first" }); return; }
2362
+ try {
2363
+ mkdirSync(dataDir(), { recursive: true });
2364
+ writeFileSync(ROUTER_PRESETS_FILE, generateRouterPresets(profiles), "utf8");
2365
+ const result = await manager.startRouter(ROUTER_PRESETS_FILE);
2366
+ sendJson(res, result.ok ? 200 : 409, { ok: result.ok, error: result.error, presets: profiles.length, iniPath: ROUTER_PRESETS_FILE });
2367
+ } catch (err) {
2368
+ sendJson(res, 500, { error: err?.message ?? String(err) });
2369
+ }
2370
+ },
2371
+ },
2372
+ {
2373
+ kind: "exact",
2374
+ path: "/local-models/router/unload-all",
2375
+ handler: async (req, res) => {
2376
+ if (req.method !== "POST") { sendJson(res, 405, { error: "POST required" }); return; }
2377
+ try {
2378
+ const resp = await fetch("http://127.0.0.1:" + PORT + "/models", { signal: AbortSignal.timeout(1500) });
2379
+ const list = await resp.json().catch(() => []);
2380
+ const items = Array.isArray(list) ? list : (Array.isArray(list?.data) ? list.data : []);
2381
+ const results = [];
2382
+ for (const m of items) {
2383
+ if (typeof m?.status?.value !== "string" || m.status.value === "unloaded") continue;
2384
+ const id = typeof m?.id === "string" ? m.id : null;
2385
+ if (!id) continue;
2386
+ try {
2387
+ const r = await fetch("http://127.0.0.1:" + PORT + "/models/unload", {
2388
+ method: "POST",
2389
+ headers: { "content-type": "application/json" },
2390
+ body: JSON.stringify({ model: id }),
2391
+ signal: AbortSignal.timeout(15000),
2392
+ });
2393
+ results.push({ id, ok: r.ok });
2394
+ } catch (err) {
2395
+ results.push({ id, ok: false, error: err?.message ?? String(err) });
2396
+ }
2397
+ }
2398
+ sendJson(res, 200, { ok: true, unloaded: results.filter((r) => r.ok).length, results });
2399
+ } catch (err) {
2400
+ sendJson(res, 502, { error: err?.message ?? String(err) });
2401
+ }
2402
+ },
2403
+ },
2404
+ {
2405
+ kind: "exact",
2406
+ path: "/local-models/router/unload",
2407
+ handler: async (req, res) => {
2408
+ if (req.method !== "POST") { sendJson(res, 405, { error: "POST required" }); return; }
2409
+ let body;
2410
+ try { body = JSON.parse((await readBody(req)) || "{}"); } catch { sendJson(res, 400, { error: "invalid JSON body" }); return; }
2411
+ const model = typeof body.model === "string" && body.model ? body.model : "";
2412
+ if (!model) { sendJson(res, 400, { error: "missing model id" }); return; }
2413
+ try {
2414
+ const r = await fetch("http://127.0.0.1:" + PORT + "/models/unload", {
2415
+ method: "POST",
2416
+ headers: { "content-type": "application/json" },
2417
+ body: JSON.stringify({ model }),
2418
+ signal: AbortSignal.timeout(15000),
2419
+ });
2420
+ const payload = await r.json().catch(() => ({}));
2421
+ sendJson(res, r.ok ? 200 : 502, { ok: r.ok, payload });
2422
+ } catch (err) {
2423
+ sendJson(res, 502, { error: err?.message ?? String(err) });
2424
+ }
2425
+ },
2426
+ },
2427
+ {
2428
+ kind: "exact",
2429
+ path: "/local-models/register",
2430
+ handler: async (req, res) => {
2431
+ let body;
2432
+ try {
2433
+ body = JSON.parse((await readBody(req)) || "{}");
2434
+ } catch {
2435
+ sendJson(res, 400, { error: "invalid JSON body" });
2436
+ return;
2437
+ }
2438
+ const st = await manager.status();
2439
+ if (st.status !== "ready") {
2440
+ sendJson(res, 409, { error: "the server is not ready — wait for /health then register" });
2441
+ return;
2442
+ }
2443
+ let savedProfiles = [];
2444
+ if (st.mode === "router") {
2445
+ try {
2446
+ savedProfiles = readProfiles();
2447
+ } catch (err) {
2448
+ sendJson(res, 500, { error: err?.message ?? String(err) });
2449
+ return;
2450
+ }
2451
+ }
2452
+ const { profile, modelId, route } = st.mode === "router"
2453
+ ? buildRouterProfile(savedProfiles, await routerModalities())
2454
+ : buildProviderProfile(st, typeof body.route === "string" ? body.route : undefined);
2455
+ try {
2456
+ sendJson(res, 200, await publishProvider(route, profile, modelId));
2457
+ } catch (err) {
2458
+ sendJson(res, 500, { error: "settings update failed: " + (err?.message ?? err) });
2459
+ }
2460
+ },
2461
+ },
2462
+ ];
2463
+
2464
+ ctx.effect(() => {
2465
+ const disposers = routeSpec.map((route) => ctx.webServer.register(route));
2466
+ return () => {
2467
+ for (const dispose of disposers) dispose();
2468
+ manager.dispose();
2469
+ };
2470
+ }, "local-models: routes");
2471
+
2472
+ // Boot autostart: when the user enabled "start router with dsh", launch
2473
+ // the router in the background and register its route once healthy, so
2474
+ // models are usable without opening the tab. Fire-and-forget: every step
2475
+ // is logged to llama-server.log (visible in the tab's terminal tail).
2476
+ (async () => {
2477
+ const note = (msg) => {
2478
+ try { appendFileSync(LOG_PATH, "[autostart] " + msg + "\n"); } catch { /* ignore */ }
2479
+ };
2480
+ try {
2481
+ if (!readSettings().autostartRouter) return;
2482
+ let profiles;
2483
+ try {
2484
+ profiles = readProfiles();
2485
+ } catch (err) {
2486
+ note("aborted: " + (err?.message ?? String(err)));
2487
+ return;
2488
+ }
2489
+ if (profiles.length === 0) {
2490
+ note("aborted: autostart enabled but no profiles saved");
2491
+ return;
2492
+ }
2493
+ note("starting router (" + profiles.length + " profiles)...");
2494
+ mkdirSync(dataDir(), { recursive: true });
2495
+ writeFileSync(ROUTER_PRESETS_FILE, generateRouterPresets(profiles), "utf8");
2496
+ const result = await manager.startRouter(ROUTER_PRESETS_FILE);
2497
+ if (!result.ok) {
2498
+ note("router failed to start: " + (result.error ?? "unknown error"));
2499
+ return;
2500
+ }
2501
+ const deadline = Date.now() + (START_TIMEOUT_S + 30) * 1000;
2502
+ for (;;) {
2503
+ const st = await manager.status();
2504
+ if (st.status === "ready") break;
2505
+ if (st.status === "stopped" || Date.now() > deadline) {
2506
+ note("router never became ready (status=" + st.status + (st.error ? ": " + st.error : "") + ")");
2507
+ return;
2508
+ }
2509
+ await new Promise((r) => setTimeout(r, 2000));
2510
+ }
2511
+ const built = buildRouterProfile(profiles, await routerModalities());
2512
+ await publishProvider(built.route, built.profile, built.modelId);
2513
+ note("router ready and registered as " + built.route);
2514
+ } catch (err) {
2515
+ note("failed: " + (err?.message ?? String(err)));
2516
+ }
2517
+ })();
2518
+ }
2519
+ // Self-test: node lib/index.js /path/to/model.gguf
2520
+ // Compares against the resolved invocation path (not string-concatenated
2521
+ // argv[1], which mismatches on relative paths and symlinks).
2522
+ const invokedAsScript = (() => {
2523
+ try {
2524
+ return !!process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
2525
+ } catch {
2526
+ return false;
2527
+ }
2528
+ })();
2529
+ if (invokedAsScript && process.argv[2]) {
2530
+ try {
2531
+ console.log(JSON.stringify(parseGGUFCached(process.argv[2]), null, 2));
2532
+ } catch (e) {
2533
+ console.error(e?.message ?? String(e));
2534
+ process.exitCode = 2;
2535
+ }
2536
+ }