@otto-code/brain 0.8.7 → 0.8.8

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.
@@ -63,8 +63,75 @@ import { buildEnv } from "./args.js";
63
63
  */
64
64
  export const DEFAULT_LLAMA_BUILD = "b10265";
65
65
  const LLAMA_RELEASE_BASE = "https://github.com/ggml-org/llama.cpp/releases/download";
66
+ const LLAMA_RELEASE_API = "https://api.github.com/repos/ggml-org/llama.cpp/releases";
67
+ /**
68
+ * How many releases to read when resolving "latest".
69
+ *
70
+ * Not 1: the newest release is not necessarily a numbered build (upstream also
71
+ * publishes differently tagged and pre-release entries), and a single
72
+ * non-matching entry would otherwise read as "llama.cpp published no release
73
+ * builds" while the very next one qualified.
74
+ */
75
+ const LATEST_BUILD_SCAN_PAGE = 30;
76
+ /** Official llama.cpp builds, newest first. Only numbered build releases are actionable. */
77
+ export async function listRuntimeReleases(limit = 100) {
78
+ const response = await fetch(`${LLAMA_RELEASE_API}?per_page=${Math.min(Math.max(limit, 1), 100)}`, {
79
+ headers: { Accept: "application/vnd.github+json" },
80
+ });
81
+ if (!response.ok)
82
+ throw new Error(`could not fetch llama.cpp releases (${response.status})`);
83
+ const releases = await response.json();
84
+ if (!Array.isArray(releases))
85
+ throw new Error("llama.cpp releases response was invalid");
86
+ return releases.flatMap((release) => {
87
+ if (!release || typeof release !== "object")
88
+ return [];
89
+ const value = release;
90
+ const build = typeof value.tag_name === "string" ? value.tag_name : "";
91
+ return /^b\d+$/.test(build)
92
+ ? [{ build, publishedAt: typeof value.published_at === "string" ? value.published_at : null }]
93
+ : [];
94
+ });
95
+ }
96
+ /** Resolve the latest official build at the last responsible moment. */
97
+ export async function latestRuntimeBuild() {
98
+ const [latest] = await listRuntimeReleases(LATEST_BUILD_SCAN_PAGE);
99
+ if (!latest)
100
+ throw new Error("llama.cpp published no release builds");
101
+ return latest.build;
102
+ }
103
+ /**
104
+ * The build to install for a "latest" request, with the pin as the safety net.
105
+ *
106
+ * Asking upstream is a best effort, never a precondition. `listRuntimeReleases`
107
+ * hits api.github.com unauthenticated, which is rate limited to 60 requests per
108
+ * hour per IP: behind NAT, on a corporate egress or on a CI runner that is a 403
109
+ * on an address that has spent its budget on something else entirely. An install
110
+ * the pinned build can serve must not fail because a version lookup did.
111
+ *
112
+ * The warning is deliberately one line. The daemon's BrainOpsManager keeps the
113
+ * *last* stderr line as the job's message, so a wrapped warning would surface in
114
+ * the GUI as a dangling fragment.
115
+ */
116
+ export async function resolveLatestBuildOrPin() {
117
+ try {
118
+ return { build: await latestRuntimeBuild(), warning: null };
119
+ }
120
+ catch (error) {
121
+ return {
122
+ build: DEFAULT_LLAMA_BUILD,
123
+ warning: `could not look up the latest llama.cpp build (${oneLine(error)}), so Otto installed` +
124
+ ` the pinned build ${DEFAULT_LLAMA_BUILD} instead.`,
125
+ };
126
+ }
127
+ }
128
+ /** Flatten a message to one line, so a warning survives the last-line rule. */
129
+ function oneLine(error) {
130
+ return (error instanceof Error ? error.message : String(error)).replace(/\s+/gu, " ").trim();
131
+ }
66
132
  /** The CUDA toolkit version whose Windows assets we pin. */
67
133
  const WINDOWS_CUDA = "12.4";
134
+ const MANAGED_RUNTIME_METADATA_FILE = ".otto-runtime.json";
68
135
  /** The binary name llama.cpp ships for a platform. */
69
136
  export function serverExeName(platform = process.platform) {
70
137
  return platform === "win32" ? "llama-server.exe" : "llama-server";
@@ -181,6 +248,34 @@ function slug(spec) {
181
248
  .replace(/[^a-z0-9]+/g, "-")
182
249
  .replace(/(^-|-$)/g, "");
183
250
  }
251
+ function displayNameForManagedRuntime(label, version) {
252
+ return `${label.replace(/\s*\(managed\)$/iu, "")} · ${version} (Otto managed)`;
253
+ }
254
+ function legacyManagedDisplayName(dirName, version) {
255
+ const cuda = /^cuda-(\d+)-(\d+)-managed(?:-|$)/iu.exec(dirName);
256
+ if (cuda)
257
+ return `CUDA ${cuda[1]}.${cuda[2]} · ${version} (Otto managed)`;
258
+ if (/^vulkan-managed(?:-|$)/iu.test(dirName))
259
+ return `Vulkan · ${version} (Otto managed)`;
260
+ if (/^metal-managed(?:-|$)/iu.test(dirName))
261
+ return `Metal · ${version} (Otto managed)`;
262
+ if (/^cpu-managed(?:-|$)/iu.test(dirName))
263
+ return `CPU · ${version} (Otto managed)`;
264
+ return `${version} (Otto managed)`;
265
+ }
266
+ function readManagedRuntimeDisplayName(root, dirName, version) {
267
+ try {
268
+ const parsed = JSON.parse(fs.readFileSync(path.join(root, MANAGED_RUNTIME_METADATA_FILE), "utf8"));
269
+ if (typeof parsed.displayName === "string" && parsed.displayName.trim()) {
270
+ return parsed.displayName;
271
+ }
272
+ }
273
+ catch {
274
+ // Existing installations predate metadata; their directory name remains a
275
+ // backend-only migration source, never a UI presentation contract.
276
+ }
277
+ return legacyManagedDisplayName(dirName, version);
278
+ }
184
279
  /** Recursively find the first file named `name` under `dir`. */
185
280
  function findFile(dir, name) {
186
281
  const entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -230,9 +325,11 @@ export function listManagedRuntimes(runtimesDir, platform = process.platform) {
230
325
  const exe = findFile(root, exeName);
231
326
  if (!exe)
232
327
  continue;
328
+ const version = entry.name.replace(/^.*-/, "");
233
329
  found.push({
234
330
  label: entry.name,
235
- version: entry.name.replace(/^.*-/, ""),
331
+ displayName: readManagedRuntimeDisplayName(root, entry.name, version),
332
+ version,
236
333
  dir: path.dirname(exe),
237
334
  exe,
238
335
  vendorDir: null,
@@ -242,8 +339,24 @@ export function listManagedRuntimes(runtimesDir, platform = process.platform) {
242
339
  return found.sort((a, b) => managedVariantRank(a.label) - managedVariantRank(b.label) ||
243
340
  buildNumber(b.version) - buildNumber(a.version));
244
341
  }
342
+ /**
343
+ * An asset upstream does not serve at all, which is what a renamed asset or a
344
+ * tag without the expected build looks like from here. Distinguished from every
345
+ * other download failure because it is the one a caller can answer by retrying
346
+ * a build whose asset names are pinned and tested, rather than by retrying the
347
+ * same URL later.
348
+ */
349
+ export class MissingAssetError extends Error {
350
+ constructor(url) {
351
+ super(`download failed (404) for ${url}`);
352
+ this.url = url;
353
+ this.name = "MissingAssetError";
354
+ }
355
+ }
245
356
  async function downloadFile(url, dest, onProgress) {
246
357
  const response = await fetch(url);
358
+ if (response.status === 404)
359
+ throw new MissingAssetError(url);
247
360
  if (!response.ok || !response.body) {
248
361
  throw new Error(`download failed (${response.status}) for ${url}`);
249
362
  }
@@ -480,13 +593,26 @@ export async function extractArchive(archivePath, destDir, platform = process.pl
480
593
  export async function installManagedRuntime(spec, runtimesDir, onProgress) {
481
594
  const platform = spec.platform ?? process.platform;
482
595
  const targetDir = path.join(runtimesDir, slug(spec));
596
+ const preexisting = fs.existsSync(targetDir);
483
597
  fs.mkdirSync(targetDir, { recursive: true });
484
- for (const url of spec.assets) {
485
- const archivePath = path.join(targetDir, path.basename(new URL(url).pathname));
486
- await downloadFile(url, archivePath, onProgress);
487
- onProgress?.({ phase: "extracting", asset: url });
488
- await extractArchive(archivePath, targetDir, platform);
489
- fs.rmSync(archivePath, { force: true });
598
+ try {
599
+ for (const url of spec.assets) {
600
+ const archivePath = path.join(targetDir, path.basename(new URL(url).pathname));
601
+ await downloadFile(url, archivePath, onProgress);
602
+ onProgress?.({ phase: "extracting", asset: url });
603
+ await extractArchive(archivePath, targetDir, platform);
604
+ fs.rmSync(archivePath, { force: true });
605
+ }
606
+ }
607
+ catch (error) {
608
+ // A half-installed directory is worse than no directory: Windows CUDA needs
609
+ // two archives, so a 404 on the companion leaves a llama-server with no CUDA
610
+ // runtime beside it, and `listManagedRuntimes` ranks by build number - that
611
+ // newer, broken build would then outrank the working runtime it replaced.
612
+ // Only a directory this call created is removed; an existing install stands.
613
+ if (!preexisting)
614
+ fs.rmSync(targetDir, { recursive: true, force: true });
615
+ throw error;
490
616
  }
491
617
  const exeName = serverExeName(platform);
492
618
  const exe = findFile(targetDir, exeName);
@@ -499,6 +625,7 @@ export async function installManagedRuntime(spec, runtimesDir, onProgress) {
499
625
  }
500
626
  const runtime = {
501
627
  label: spec.label,
628
+ displayName: displayNameForManagedRuntime(spec.label, spec.version),
502
629
  version: spec.version,
503
630
  dir: path.dirname(exe),
504
631
  exe,
@@ -508,7 +635,19 @@ export async function installManagedRuntime(spec, runtimesDir, onProgress) {
508
635
  // Before reporting success. An install that cannot exec is not an install, and
509
636
  // the loader error names the cause far better than the later spawn failure does.
510
637
  await verifyRuntimeExecutable(runtime, platform);
638
+ fs.writeFileSync(path.join(targetDir, MANAGED_RUNTIME_METADATA_FILE), `${JSON.stringify({ displayName: runtime.displayName })}\n`);
511
639
  onProgress?.({ phase: "done" });
512
640
  return runtime;
513
641
  }
642
+ /** Remove one Otto-managed runtime. LM Studio files are outside this root. */
643
+ export function removeManagedRuntime(runtimesDir, name) {
644
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(name))
645
+ throw new Error("invalid runtime name");
646
+ const root = path.resolve(runtimesDir);
647
+ const target = path.resolve(root, name);
648
+ if (path.dirname(target) !== root || !fs.existsSync(target)) {
649
+ throw new Error(`managed runtime not found: ${name}`);
650
+ }
651
+ fs.rmSync(target, { recursive: true, force: false });
652
+ }
514
653
  //# sourceMappingURL=managed.js.map
@@ -82,11 +82,11 @@ export interface HostCapabilities {
82
82
  /** A long-running operation owned by this brain host, not its caller. */
83
83
  export interface HostJob {
84
84
  id: string;
85
- kind: "pull" | "runtime-install" | "calibrate" | "sweep" | "bench";
85
+ kind: "pull" | "runtime-install" | "runtime-remove" | "calibrate" | "sweep" | "bench";
86
86
  label: string;
87
87
  target: string | null;
88
88
  status: "running" | "succeeded" | "failed" | "canceled";
89
- percent: null;
89
+ percent: number | null;
90
90
  message: string | null;
91
91
  error: string | null;
92
92
  startedAt: string;
@@ -153,6 +153,7 @@ export interface InventoryRow {
153
153
  score: RankedModel | null;
154
154
  state: "loaded" | "loading" | "not-loaded";
155
155
  warnings: ReturnType<typeof profileWarnings>;
156
+ components: NonNullable<Model["components"]> | null;
156
157
  }
157
158
  /**
158
159
  * Join one model's scan row, GGUF metadata, saved profile, calibration, VRAM
@@ -1,6 +1,6 @@
1
1
  import { calibrationInfo, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, } from "../config/profile-edit.js";
2
2
  import { forModel, getCalibration, put } from "../config/profiles.js";
3
- import { deleteModelFiles, diskUsage, planDelete, totalModelBytes } from "../models/manage.js";
3
+ import { deleteComponentFile, deleteModelFiles, diskUsage, planDelete, totalModelBytes, } from "../models/manage.js";
4
4
  import { deleteDisplayName, updateDisplayName } from "../models/rename-map.js";
5
5
  import * as vram from "../vram.js";
6
6
  import { errorMessage, readJsonBody, sendError, sendJson } from "./http-util.js";
@@ -60,7 +60,11 @@ export function buildInventoryRow(params) {
60
60
  contextLength: model.metadata?.contextLength ?? null,
61
61
  blockCount: model.metadata?.blockCount ?? null,
62
62
  headCountKv: model.metadata?.headCountKv ?? null,
63
- hasProjector: Boolean(model.mmprojPath),
63
+ // A bundle declares vision capability even before its projector is
64
+ // downloaded. The badge describes what the model supports, while the
65
+ // profile's component row describes whether that artifact is ready.
66
+ hasProjector: Boolean(model.mmprojPath) ||
67
+ Boolean(model.components?.some((component) => component.role === "vision_projector")),
64
68
  reasoning: Boolean(model.metadata?.reasoning ?? model.thinking),
65
69
  mtp: Boolean(model.features?.mtp),
66
70
  distilled: Boolean(model.features?.distilled),
@@ -73,8 +77,24 @@ export function buildInventoryRow(params) {
73
77
  score: ranked,
74
78
  state: stateOf(supervisor, model),
75
79
  warnings: profileWarnings(profile, model, store),
80
+ components: model.components ?? null,
76
81
  };
77
82
  }
83
+ /**
84
+ * Reject a request value that the CLI would read as a flag rather than as a
85
+ * value.
86
+ *
87
+ * An option value has to sit before the `--` separator by construction - the
88
+ * separator only ends *positional* parsing - so unlike `model` and `repo` these
89
+ * cannot be moved out of harm's way, and the argv is unambiguous only if the
90
+ * value itself is. The throw surfaces as the route's 400, next to the type
91
+ * checks in each `makeArgs`.
92
+ */
93
+ function optionValue(label, value) {
94
+ if (value.startsWith("-"))
95
+ throw new Error(`${label} must not start with "-"`);
96
+ return value;
97
+ }
78
98
  /** Resolve a model by id or display name, the same way the completion path does. */
79
99
  function resolveModel(catalog, needle) {
80
100
  if (!needle)
@@ -352,6 +372,24 @@ export function createHostApi(deps) {
352
372
  sendError(res, 500, `could not delete ${model.displayName}: ${errorMessage(error)}`);
353
373
  }
354
374
  };
375
+ const handleComponentDelete = (res, model, componentId) => {
376
+ if (deps.supervisor.model?.id === model.id && deps.supervisor.state !== "stopped") {
377
+ sendError(res, 409, "stop the model before removing a bundle component");
378
+ return;
379
+ }
380
+ try {
381
+ const plan = deleteComponentFile(model, componentId);
382
+ deps.rescan();
383
+ sendJson(res, {
384
+ deleted: plan.files,
385
+ freedBytes: plan.bytes,
386
+ componentIds: plan.componentIds,
387
+ });
388
+ }
389
+ catch (error) {
390
+ sendError(res, 409, errorMessage(error));
391
+ }
392
+ };
355
393
  const handleLogs = (res, params) => {
356
394
  const raw = Number(params.get("limit"));
357
395
  const limit = Number.isFinite(raw) && raw > 0 ? Math.min(Math.round(raw), 1000) : DEFAULT_LOG_LINES;
@@ -482,7 +520,7 @@ export function createHostApi(deps) {
482
520
  sendJson(res, {
483
521
  job: deps.jobs?.start("bench", model ?? null, [
484
522
  "bench",
485
- ...(model ? ["--model", model] : []),
523
+ ...(model ? ["--model", optionValue("model", model)] : []),
486
524
  ]) ?? null,
487
525
  });
488
526
  }
@@ -499,7 +537,25 @@ export function createHostApi(deps) {
499
537
  const model = body.model;
500
538
  if (typeof model !== "string" || !model)
501
539
  throw new Error("model is required");
502
- return { target: model, args: ["pull", "--json", "--", model] };
540
+ const components = body.components;
541
+ const quant = body.quant;
542
+ if (components !== undefined &&
543
+ (!Array.isArray(components) || !components.every((id) => typeof id === "string"))) {
544
+ throw new Error("components must be component ids");
545
+ }
546
+ if (quant !== undefined && typeof quant !== "string")
547
+ throw new Error("quant must be a string");
548
+ return {
549
+ target: model,
550
+ args: [
551
+ "pull",
552
+ ...(typeof quant === "string" ? ["--quant", optionValue("quant", quant)] : []),
553
+ ...(components ?? []).flatMap((id) => ["--component", optionValue("component", id)]),
554
+ "--json",
555
+ "--",
556
+ model,
557
+ ],
558
+ };
503
559
  },
504
560
  },
505
561
  "/__host/jobs/add": {
@@ -507,11 +563,24 @@ export function createHostApi(deps) {
507
563
  makeArgs: (body) => {
508
564
  const repo = body.repo;
509
565
  const quant = body.quant;
566
+ const components = body.components;
510
567
  if (typeof repo !== "string" || !repo || typeof quant !== "string" || !quant)
511
568
  throw new Error("repo and quant are required");
569
+ if (components !== undefined &&
570
+ (!Array.isArray(components) || !components.every((id) => typeof id === "string")))
571
+ throw new Error("components must be component ids");
512
572
  return {
513
573
  target: `${repo}#${quant}`,
514
- args: ["add", "--quant", quant, "--json", "--", repo],
574
+ args: [
575
+ "add",
576
+ "--quant",
577
+ optionValue("quant", quant),
578
+ ...(components === undefined ? [] : ["--primary-only"]),
579
+ ...(components ?? []).flatMap((id) => ["--component", optionValue("component", id)]),
580
+ "--json",
581
+ "--",
582
+ repo,
583
+ ],
515
584
  };
516
585
  },
517
586
  },
@@ -527,18 +596,36 @@ export function createHostApi(deps) {
527
596
  "runtime",
528
597
  "install",
529
598
  "--json",
530
- ...(typeof build === "string" ? ["--build", build] : []),
599
+ ...(typeof build === "string" ? ["--build", optionValue("build", build)] : []),
531
600
  ],
532
601
  };
533
602
  },
534
603
  },
604
+ // Removal is host-owned for the same reason installation is: the runtimes
605
+ // directory belongs to this machine. A daemon in brain.mode=remote that ran
606
+ // this locally would delete a same-named runtime out of its own OTTO_HOME.
607
+ "/__host/jobs/runtime-remove": {
608
+ kind: "runtime-remove",
609
+ makeArgs: (body) => {
610
+ const name = body.name;
611
+ if (typeof name !== "string" || !name)
612
+ throw new Error("name is required");
613
+ // Pass the name as an operand, not as a flag candidate. The CLI still
614
+ // owns the real safety check (the name regex plus the parent-dir
615
+ // assertion in removeManagedRuntime).
616
+ return { target: name, args: ["runtime", "remove", "--json", "--", name] };
617
+ },
618
+ },
535
619
  "/__host/jobs/calibrate": {
536
620
  kind: "calibrate",
537
621
  makeArgs: (body) => {
538
622
  const model = body.model;
539
623
  if (typeof model !== "string" || !model)
540
624
  throw new Error("model is required");
541
- return { target: model, args: ["calibrate", "--model", model, "--json"] };
625
+ return {
626
+ target: model,
627
+ args: ["calibrate", "--model", optionValue("model", model), "--json"],
628
+ };
542
629
  },
543
630
  },
544
631
  "/__host/jobs/sweep": {
@@ -547,7 +634,10 @@ export function createHostApi(deps) {
547
634
  const model = body.model;
548
635
  if (typeof model !== "string" || !model)
549
636
  throw new Error("model is required");
550
- return { target: model, args: ["sweep", "--model", model, "--json"] };
637
+ return {
638
+ target: model,
639
+ args: ["sweep", "--model", optionValue("model", model), "--json"],
640
+ };
551
641
  },
552
642
  },
553
643
  };
@@ -636,6 +726,14 @@ export function createHostApi(deps) {
636
726
  handleModelsList(res);
637
727
  return true;
638
728
  }
729
+ // A local daemon may run downloads in its own tracked job process while
730
+ // this service owns the in-memory inventory. Reconcile that disk mutation
731
+ // without restarting the host or unloading its resident model.
732
+ if (route === "/__host/models/rescan" && method === "POST") {
733
+ const models = deps.rescan();
734
+ sendJson(res, { models: models.length });
735
+ return true;
736
+ }
639
737
  if (route === "/__host/model/unload" && method === "POST") {
640
738
  if (!guardWrite(res))
641
739
  return true;
@@ -651,6 +749,7 @@ export function createHostApi(deps) {
651
749
  "/__host/model/fields",
652
750
  "/__host/model/rename",
653
751
  "/__host/model/rename/reset",
752
+ "/__host/model/component",
654
753
  ]);
655
754
  if (!modelRoutes.has(route))
656
755
  return false;
@@ -702,6 +801,17 @@ export function createHostApi(deps) {
702
801
  handleDelete(res, model);
703
802
  return true;
704
803
  }
804
+ if (route === "/__host/model/component" && method === "DELETE") {
805
+ if (!guardWrite(res))
806
+ return true;
807
+ const componentId = params.get("component");
808
+ if (!componentId) {
809
+ sendError(res, 400, "a component query parameter is required");
810
+ return true;
811
+ }
812
+ handleComponentDelete(res, model, componentId);
813
+ return true;
814
+ }
705
815
  if (route === "/__host/model" && method === "GET") {
706
816
  // A single inventory row, for a detail pane that does not want the whole list.
707
817
  void (async () => {
@@ -24,6 +24,7 @@ import { resolveVersion } from "../version.js";
24
24
  import * as results from "../ops/results.js";
25
25
  import { createCpuSampler, sample as sampleSystem } from "../sysmon.js";
26
26
  import { createHostApi } from "./host-api.js";
27
+ import { errorMessage } from "./http-util.js";
27
28
  import { createRouter, Telemetry } from "./router.js";
28
29
  import { BrainStatusPublisher } from "./status-events.js";
29
30
  import { Supervisor } from "./supervisor.js";
@@ -63,7 +64,8 @@ const REMOTE_JOB_RETENTION_MS = 5 * 60000;
63
64
  * directory and GPU all belong to the host that is being benchmarked.
64
65
  */
65
66
  class ServiceJobRunner {
66
- constructor() {
67
+ constructor(onPullCompleted) {
68
+ this.onPullCompleted = onPullCompleted;
67
69
  this.jobs = new Map();
68
70
  }
69
71
  start(kind, target, args) {
@@ -101,16 +103,24 @@ class ServiceJobRunner {
101
103
  job.child = child;
102
104
  this.jobs.set(job.id, job);
103
105
  child.stderr?.setEncoding("utf8");
104
- child.stderr?.on("data", (chunk) => {
105
- const last = chunk.trim().split(/\r?\n/).at(-1)?.trim();
106
- if (last)
107
- job.message = last.slice(-1000);
108
- });
106
+ child.stderr?.on("data", (chunk) => this.ingestOutput(job, chunk));
109
107
  child.once("error", (error) => this.finish(job, "failed", error.message));
110
108
  child.once("close", (code) => {
111
109
  if (job.status !== "running")
112
110
  return;
113
- this.finish(job, code === 0 ? "succeeded" : "failed", code === 0 ? null : `Exited with code ${code}.`);
111
+ if (code === 0 && kind === "pull") {
112
+ try {
113
+ // Downloads happen in a child process, but inventory is served from
114
+ // this process's in-memory scan. Reconcile before reporting success
115
+ // so a newly downloaded bundle component is immediately available.
116
+ this.onPullCompleted();
117
+ }
118
+ catch (error) {
119
+ this.finish(job, "failed", `Downloaded files, but could not refresh inventory: ${errorMessage(error)}`);
120
+ return;
121
+ }
122
+ }
123
+ this.finish(job, code === 0 ? "succeeded" : "failed", code === 0 ? null : (job.message ?? `Exited with code ${code}.`));
114
124
  });
115
125
  return this.publicJob(job);
116
126
  }
@@ -168,6 +178,20 @@ class ServiceJobRunner {
168
178
  this.finish(job, "canceled", "Canceled.");
169
179
  return this.list();
170
180
  }
181
+ ingestOutput(job, chunk) {
182
+ for (const line of chunk
183
+ .split(/[\r\n]+/u)
184
+ .map((value) => value.trim())
185
+ .filter(Boolean)) {
186
+ const progress = /(\d{1,3})\s*%/u.exec(line);
187
+ if (progress)
188
+ job.percent = Math.max(0, Math.min(100, Number(progress[1])));
189
+ // The final JSON result is not a useful status label. Keep progress and
190
+ // actionable text instead, so a failed bundle pull tells the user why.
191
+ if (line !== "[" && !/^[\]{}",]+$/u.test(line))
192
+ job.message = line.slice(-1000);
193
+ }
194
+ }
171
195
  finish(job, status, error) {
172
196
  if (job.status !== "running")
173
197
  return;
@@ -175,6 +199,8 @@ class ServiceJobRunner {
175
199
  job.status = status;
176
200
  job.error = error;
177
201
  job.finishedAt = new Date().toISOString();
202
+ if (status === "succeeded")
203
+ job.percent = 100;
178
204
  }
179
205
  publicJob({ child: _child, ...job }) {
180
206
  return job;
@@ -266,6 +292,10 @@ export async function startService({ config, modelNeedle, env = process.env, onL
266
292
  // Not const: deleting a model through the management API re-scans and replaces
267
293
  // this, and every reader goes through a getter so nobody holds a stale array.
268
294
  let catalog = scanModels(config, env);
295
+ const rescanCatalog = () => {
296
+ catalog = scanModels(config, env);
297
+ return catalog;
298
+ };
269
299
  const needle = modelNeedle ?? config.defaultModel ?? store.lastModelId ?? undefined;
270
300
  let model = null;
271
301
  if (catalog.length > 0) {
@@ -389,17 +419,14 @@ export async function startService({ config, modelNeedle, env = process.env, onL
389
419
  // /__host/events. One instance, so `capabilities.events` and the stream can
390
420
  // never disagree about whether this brain publishes.
391
421
  const statusEvents = new BrainStatusPublisher();
392
- const jobs = new ServiceJobRunner();
422
+ const jobs = new ServiceJobRunner(rescanCatalog);
393
423
  // Assigned once `stop` exists below. This indirection lets the management API
394
424
  // answer a remote restart request before closing its own socket.
395
425
  let requestRestart = () => { };
396
426
  const hostApi = createHostApi({
397
427
  supervisor,
398
428
  getCatalog: () => catalog,
399
- rescan: () => {
400
- catalog = scanModels(config, env);
401
- return catalog;
402
- },
429
+ rescan: rescanCatalog,
403
430
  getProfilesStore: () => store,
404
431
  saveProfiles: (next) => saveProfilesStore(next, paths),
405
432
  getProfileDefaults: () => config.defaults,
package/dist/types.d.ts CHANGED
@@ -26,6 +26,22 @@ export interface ModelFeatures {
26
26
  imatrix: boolean;
27
27
  distilled: boolean;
28
28
  }
29
+ export type ModelComponentRole = "vision_projector" | "speculative_drafter";
30
+ /** A catalog-declared companion artifact resolved against local disk. */
31
+ export interface ModelComponent {
32
+ id: string;
33
+ label: string;
34
+ description: string;
35
+ role: ModelComponentRole;
36
+ path: string | null;
37
+ bytes: number;
38
+ required: boolean;
39
+ defaultDownload: boolean;
40
+ defaultLoad: boolean;
41
+ available: boolean;
42
+ unavailableReason?: string;
43
+ minRuntimeBuild?: number;
44
+ }
29
45
  /** A GGUF model discovered on disk (or resolvable from the download catalog). */
30
46
  export interface Model {
31
47
  id: string;
@@ -57,10 +73,14 @@ export interface Model {
57
73
  catalogId?: string;
58
74
  /** Back-reference: the hfRepo of the reconciled catalog entry, if matched. */
59
75
  catalogHfRepo?: string;
76
+ /** Present only when this catalog entry declares a component manifest. */
77
+ components?: ModelComponent[];
60
78
  }
61
79
  /** A resolved llama.cpp runtime: an executable paired with its vendor DLL dir. */
62
80
  export interface Runtime {
63
81
  label: string;
82
+ /** Human-readable runtime identity, separate from the filesystem-safe label. */
83
+ displayName?: string;
64
84
  version: string;
65
85
  dir: string;
66
86
  exe: string;
package/dist/vram.d.ts CHANGED
@@ -21,6 +21,9 @@ export type BudgetSource = "measured" | "theoretical" | "unknown";
21
21
  export interface Budget {
22
22
  weightsBytes: number;
23
23
  mmprojBytes: number;
24
+ componentBytes: number;
25
+ drafterKvBytes: number;
26
+ imageProcessingBytes: number;
24
27
  kvBytes: number;
25
28
  overheadBytes: number;
26
29
  totalBytes: number;
package/dist/vram.js CHANGED
@@ -35,7 +35,17 @@ export function theoreticalKvBytesPerToken(metadata, cacheTypeK, cacheTypeV) {
35
35
  /** Compute a VRAM budget for a profile. */
36
36
  export function budget({ model, profile, calibration = null, totalVramBytes, reserveBytes = 1.5 * GIB, }) {
37
37
  const weights = model.sizeBytes;
38
- const mmproj = profile.vision && model.mmprojPath ? model.mmprojBytes : 0;
38
+ const enabled = new Set(profile.enabledComponents ?? []);
39
+ const components = model.components ?? [];
40
+ const projector = components.filter((component) => component.role === "vision_projector" && enabled.has(component.id) && component.available);
41
+ const drafters = components.filter((component) => component.role === "speculative_drafter" && enabled.has(component.id) && component.available);
42
+ const componentBytes = components.length
43
+ ? [...projector, ...drafters].reduce((total, component) => total + component.bytes, 0)
44
+ : profile.vision && model.mmprojPath
45
+ ? model.mmprojBytes
46
+ : 0;
47
+ const mmproj = projector.reduce((total, component) => total + component.bytes, 0) ||
48
+ (components.length ? 0 : componentBytes);
39
49
  const theoretical = theoreticalKvBytesPerToken(model.metadata, profile.cacheTypeK, profile.cacheTypeV);
40
50
  let kvBytesPerToken;
41
51
  let source;
@@ -52,14 +62,22 @@ export function budget({ model, profile, calibration = null, totalVramBytes, res
52
62
  source = "unknown";
53
63
  }
54
64
  const kv = kvBytesPerToken * profile.contextSize;
65
+ // The speculative decoder keeps its own KV cache. Until component-specific
66
+ // calibration exists, reserve the same conservative per-token cost.
67
+ const drafterKv = drafters.length * kv;
68
+ // Vision preprocessing needs transient GPU buffers in addition to weights.
69
+ const imageProcessing = projector.length * 256 * 1024 * 1024;
55
70
  // Compute buffers and the CUDA context; measured at ~0.6 GB for a 27B and
56
71
  // superseded by the calibrated value when available.
57
72
  const overhead = calibration && calibration.baseOverheadBytes > 0 ? calibration.baseOverheadBytes : 0.6 * GIB;
58
- const total = weights + mmproj + kv + overhead;
73
+ const total = weights + componentBytes + kv + drafterKv + imageProcessing + overhead;
59
74
  const usable = totalVramBytes - reserveBytes;
60
75
  return {
61
76
  weightsBytes: weights,
62
77
  mmprojBytes: mmproj,
78
+ componentBytes,
79
+ drafterKvBytes: drafterKv,
80
+ imageProcessingBytes: imageProcessing,
63
81
  kvBytes: kv,
64
82
  overheadBytes: overhead,
65
83
  totalBytes: total,
@@ -85,7 +103,7 @@ export function maxContextThatFits({ model, profile, calibration, totalVramBytes
85
103
  });
86
104
  if (probe.kvBytesPerToken <= 0)
87
105
  return null;
88
- const fixed = probe.weightsBytes + probe.mmprojBytes + probe.overheadBytes;
106
+ const fixed = probe.weightsBytes + probe.componentBytes + probe.imageProcessingBytes + probe.overheadBytes;
89
107
  const room = probe.usableBytes - fixed;
90
108
  if (room <= 0)
91
109
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otto-code/brain",
3
- "version": "0.8.7",
3
+ "version": "0.8.8",
4
4
  "description": "Otto Brain - self-contained host for local GGUF models, with measured VRAM budgeting and reasoning-budget control",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "bin": {