@otto-code/brain 0.8.7 → 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/cli.js +2 -1
  2. package/dist/commands/bench.js +19 -5
  3. package/dist/commands/catalog.d.ts +3 -0
  4. package/dist/commands/catalog.js +2 -0
  5. package/dist/commands/pull.d.ts +1 -0
  6. package/dist/commands/pull.js +47 -14
  7. package/dist/commands/repo-download.d.ts +14 -0
  8. package/dist/commands/repo-download.js +29 -0
  9. package/dist/commands/runtime.d.ts +3 -0
  10. package/dist/commands/runtime.js +65 -18
  11. package/dist/commands/search.d.ts +3 -0
  12. package/dist/commands/search.js +38 -17
  13. package/dist/config/builtin-hosting-profiles.d.ts +8 -0
  14. package/dist/config/builtin-hosting-profiles.js +32 -0
  15. package/dist/config/hosting-profiles.d.ts +33 -0
  16. package/dist/config/hosting-profiles.js +71 -0
  17. package/dist/config/index.d.ts +1 -0
  18. package/dist/config/index.js +1 -0
  19. package/dist/config/paths.d.ts +2 -0
  20. package/dist/config/paths.js +1 -0
  21. package/dist/config/profile-edit.d.ts +5 -3
  22. package/dist/config/profile-edit.js +134 -14
  23. package/dist/config/profiles.js +66 -3
  24. package/dist/config/schema.d.ts +998 -0
  25. package/dist/config/schema.js +79 -0
  26. package/dist/config/store.js +28 -16
  27. package/dist/gguf.d.ts +1 -0
  28. package/dist/gguf.js +1 -0
  29. package/dist/models/download.d.ts +7 -0
  30. package/dist/models/download.js +165 -17
  31. package/dist/models/enrich.d.ts +6 -19
  32. package/dist/models/enrich.js +138 -4
  33. package/dist/models/hf.d.ts +14 -1
  34. package/dist/models/hf.js +239 -6
  35. package/dist/models/index.d.ts +2 -2
  36. package/dist/models/index.js +8 -5
  37. package/dist/models/manage.d.ts +4 -0
  38. package/dist/models/manage.js +34 -4
  39. package/dist/models/scan.js +8 -42
  40. package/dist/ops/calibrate.d.ts +4 -1
  41. package/dist/ops/calibrate.js +10 -7
  42. package/dist/ops/sweep.d.ts +3 -1
  43. package/dist/ops/sweep.js +3 -3
  44. package/dist/runtime/args.d.ts +2 -2
  45. package/dist/runtime/args.js +18 -1
  46. package/dist/runtime/index.d.ts +8 -1
  47. package/dist/runtime/index.js +11 -1
  48. package/dist/runtime/managed.d.ts +40 -0
  49. package/dist/runtime/managed.js +146 -7
  50. package/dist/service/host-api.d.ts +20 -3
  51. package/dist/service/host-api.js +313 -20
  52. package/dist/service/router.d.ts +18 -0
  53. package/dist/service/router.js +89 -4
  54. package/dist/service/serve.js +221 -22
  55. package/dist/service/supervisor.d.ts +32 -4
  56. package/dist/service/supervisor.js +30 -6
  57. package/dist/tui/app.js +1 -1
  58. package/dist/types.d.ts +24 -0
  59. package/dist/vram.d.ts +3 -0
  60. package/dist/vram.js +24 -6
  61. package/package.json +1 -1
@@ -1,11 +1,24 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { calibrationInfo, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, } from "../config/profile-edit.js";
3
+ import { familyHostingProfileId, hostingFamily, removeHostingProfileMaterialization, } from "../config/hosting-profiles.js";
2
4
  import { forModel, getCalibration, put } from "../config/profiles.js";
3
- import { deleteModelFiles, diskUsage, planDelete, totalModelBytes } from "../models/manage.js";
5
+ import { HostingProfileSchema, } from "../config/schema.js";
6
+ import { deleteComponentFile, deleteModelFiles, diskUsage, planDelete, totalModelBytes, } from "../models/manage.js";
4
7
  import { deleteDisplayName, updateDisplayName } from "../models/rename-map.js";
8
+ import { runtimeBuild } from "../runtime/index.js";
5
9
  import * as vram from "../vram.js";
6
10
  import { errorMessage, readJsonBody, sendError, sendJson } from "./http-util.js";
7
11
  const MAX_PATCH_BYTES = 256 * 1024;
8
12
  const MAX_DISPLAY_NAME = 200;
13
+ const MAX_HOSTING_PROFILE_NAME = 80;
14
+ const MAX_HOSTING_PROFILE_TEXT = 128 * 1024;
15
+ const MAX_HOSTING_PROFILES = 100;
16
+ const MAX_HOSTING_PROFILE_ID = 80;
17
+ const HOSTING_PROFILE_ID = /^[a-zA-Z0-9_-]+$/u;
18
+ // Keep product-owned records immutable through this API. Their source lives in
19
+ // builtin-hosting-profiles.ts, so an update would otherwise look successful but
20
+ // be replaced at the next Brain start with no user-facing restore action.
21
+ const BUILTIN_HOSTING_PROFILE_IDS = new Set(["qwen-sharp-v21.3"]);
9
22
  const DEFAULT_LOG_LINES = 200;
10
23
  /**
11
24
  * The management API's own version, additive to the capability flags.
@@ -24,6 +37,139 @@ export const HOST_API_VERSION = 3;
24
37
  * fine. Comments are ignored by every SSE parser, so no reader sees them.
25
38
  */
26
39
  const SSE_KEEPALIVE_MS = 20000;
40
+ /**
41
+ * Apply the hosting-profile half of a profile patch, mutating both `profile`
42
+ * (this model's selection) and `store` (the shared library and family default).
43
+ *
44
+ * Separate from `sanitizeProfilePatch` because these keys are not profile
45
+ * fields: three of the five write to the store rather than the profile.
46
+ * Exported for testing - the ordering and the cross-profile cleanup are the
47
+ * parts worth pinning down, and they are unreachable through the HTTP surface
48
+ * without standing up a service.
49
+ *
50
+ * Throws on any invalid input; the caller turns that into a 400.
51
+ */
52
+ export function applyHostingProfilePatch(store, model, profile, patch, onDelete = undefined) {
53
+ const family = hostingFamily(model.family);
54
+ if ("hostingProfileId" in patch) {
55
+ const selected = patch.hostingProfileId;
56
+ if (selected !== null && typeof selected !== "string") {
57
+ throw new Error("hostingProfileId must be a string or null");
58
+ }
59
+ if (selected && !store.hostingProfiles[selected]) {
60
+ throw new Error("selected hosting profile does not exist");
61
+ }
62
+ profile.hostingProfileId = selected || null;
63
+ profile.hostingProfileMode = selected ? "custom" : "off";
64
+ }
65
+ // After the id, so a client can send both and have the explicit mode win.
66
+ if ("hostingProfileMode" in patch) {
67
+ const mode = patch.hostingProfileMode;
68
+ if (mode !== "inherit" && mode !== "off" && mode !== "custom") {
69
+ throw new Error("hostingProfileMode must be inherit, off, or custom");
70
+ }
71
+ if (mode === "custom" && !profile.hostingProfileId) {
72
+ throw new Error("select a custom profile before using custom mode");
73
+ }
74
+ profile.hostingProfileMode = mode;
75
+ }
76
+ if ("familyHostingProfileId" in patch) {
77
+ const selected = patch.familyHostingProfileId;
78
+ if (selected !== null && typeof selected !== "string") {
79
+ throw new Error("familyHostingProfileId must be a string or null");
80
+ }
81
+ if (selected && !store.hostingProfiles[selected]) {
82
+ throw new Error("selected family hosting profile does not exist");
83
+ }
84
+ if (selected && store.hostingProfiles[selected].family !== family) {
85
+ throw new Error("selected family hosting profile must match the selected model");
86
+ }
87
+ // Null is a real instruction: it is the only way off a family default.
88
+ store.familyHostingProfileIds[family] = selected || null;
89
+ }
90
+ if ("hostingProfile" in patch) {
91
+ const candidate = HostingProfileSchema.safeParse(patch.hostingProfile);
92
+ if (!candidate.success)
93
+ throw new Error("hosting profile is invalid");
94
+ const item = candidate.data;
95
+ // Name the field that is wrong. One shared "name or text is too long" for an
96
+ // empty name, a missing template and an oversized addendum told a remote or
97
+ // CLI caller nothing about what to change.
98
+ if (item.name.trim().length === 0)
99
+ throw new Error("hosting profile needs a name");
100
+ if (item.name.length > MAX_HOSTING_PROFILE_NAME) {
101
+ throw new Error(`hosting profile name must be ${MAX_HOSTING_PROFILE_NAME} characters or fewer`);
102
+ }
103
+ if (!item.template?.trim())
104
+ throw new Error("hosting profile needs a Jinja chat template");
105
+ if (item.template.length > MAX_HOSTING_PROFILE_TEXT) {
106
+ throw new Error("hosting profile chat template is too long");
107
+ }
108
+ if ((item.systemPromptAddendum?.length ?? 0) > MAX_HOSTING_PROFILE_TEXT) {
109
+ throw new Error("hosting profile system prompt is too long");
110
+ }
111
+ if (item.family !== family) {
112
+ throw new Error("hosting profile family must match the selected model");
113
+ }
114
+ const isNew = item.id.length === 0;
115
+ const id = isNew ? `hosting_${randomUUID()}` : item.id;
116
+ if (!isNew && BUILTIN_HOSTING_PROFILE_IDS.has(id)) {
117
+ throw new Error("built-in hosting profiles cannot be edited");
118
+ }
119
+ if (!isNew && (id.length > MAX_HOSTING_PROFILE_ID || !HOSTING_PROFILE_ID.test(id))) {
120
+ throw new Error(`hosting profile id must use only letters, numbers, underscores, or hyphens and be ${MAX_HOSTING_PROFILE_ID} characters or fewer`);
121
+ }
122
+ if (!isNew && !store.hostingProfiles[id]) {
123
+ throw new Error("hosting profile does not exist; create profiles without an id");
124
+ }
125
+ if (isNew && Object.keys(store.hostingProfiles).length >= MAX_HOSTING_PROFILES) {
126
+ throw new Error(`hosting profile limit of ${MAX_HOSTING_PROFILES} reached`);
127
+ }
128
+ store.hostingProfiles[id] = { ...item, id, name: item.name.trim() };
129
+ // Only a freshly created profile is auto-selected. Editing the text of an
130
+ // existing one must not silently convert a model that was on System default
131
+ // into a custom override of it.
132
+ if (isNew) {
133
+ profile.hostingProfileId = id;
134
+ profile.hostingProfileMode = "custom";
135
+ }
136
+ }
137
+ if (typeof patch.deleteHostingProfileId === "string") {
138
+ const id = patch.deleteHostingProfileId;
139
+ if (BUILTIN_HOSTING_PROFILE_IDS.has(id)) {
140
+ throw new Error("built-in hosting profiles cannot be deleted");
141
+ }
142
+ delete store.hostingProfiles[id];
143
+ onDelete?.(id);
144
+ if (profile.hostingProfileId === id) {
145
+ profile.hostingProfileId = null;
146
+ profile.hostingProfileMode = "off";
147
+ }
148
+ // Clear the mode alongside the id on every other model. Leaving `custom`
149
+ // behind with no id left those models unsavable: the mode guard above
150
+ // rejects the next write each of them makes.
151
+ for (const saved of Object.values(store.profiles)) {
152
+ if (saved.hostingProfileId !== id)
153
+ continue;
154
+ saved.hostingProfileId = null;
155
+ saved.hostingProfileMode = "off";
156
+ }
157
+ for (const [key, selected] of Object.entries(store.familyHostingProfileIds)) {
158
+ if (selected === id)
159
+ store.familyHostingProfileIds[key] = null;
160
+ }
161
+ }
162
+ // One invariant, enforced after every branch: an id belongs only to `custom`.
163
+ // It is what lets `forModel`'s legacy migration read a stored id as an
164
+ // unambiguous "this profile predates hostingProfileMode".
165
+ if (profile.hostingProfileMode !== "custom")
166
+ profile.hostingProfileId = null;
167
+ }
168
+ /** The hosting profiles a model may choose from: its family's bucket, nothing else. */
169
+ function hostingProfilesFor(store, model) {
170
+ const family = hostingFamily(model.family);
171
+ return Object.values(store.hostingProfiles).filter((candidate) => candidate.family === family);
172
+ }
27
173
  function stateOf(supervisor, model) {
28
174
  if (!supervisor.model || supervisor.model.id !== model.id)
29
175
  return "not-loaded";
@@ -41,9 +187,9 @@ function stateOf(supervisor, model) {
41
187
  * client would otherwise have to correlate three unrelated lists by display name.
42
188
  */
43
189
  export function buildInventoryRow(params) {
44
- const { model, store, defaults, gpu, ranking, supervisor } = params;
190
+ const { model, store, defaults, gpu, ranking, supervisor, runtimeBuild: activeRuntimeBuild = null, } = params;
45
191
  const profile = forModel(store, model, defaults);
46
- const calibration = getCalibration(store, model, profile);
192
+ const calibration = profile.calibrationRequired ? null : getCalibration(store, model, profile);
47
193
  const budgetOptions = gpu
48
194
  ? { model, profile, calibration, totalVramBytes: gpu.totalBytes }
49
195
  : null;
@@ -51,6 +197,7 @@ export function buildInventoryRow(params) {
51
197
  return {
52
198
  id: model.id,
53
199
  displayName: model.displayName,
200
+ family: model.family ?? null,
54
201
  publisher: model.publisher ?? null,
55
202
  quant: model.quant,
56
203
  sizeBytes: model.sizeBytes,
@@ -60,7 +207,11 @@ export function buildInventoryRow(params) {
60
207
  contextLength: model.metadata?.contextLength ?? null,
61
208
  blockCount: model.metadata?.blockCount ?? null,
62
209
  headCountKv: model.metadata?.headCountKv ?? null,
63
- hasProjector: Boolean(model.mmprojPath),
210
+ // A bundle declares vision capability even before its projector is
211
+ // downloaded. The badge describes what the model supports, while the
212
+ // profile's component row describes whether that artifact is ready.
213
+ hasProjector: Boolean(model.mmprojPath) ||
214
+ Boolean(model.components?.some((component) => component.role === "vision_projector")),
64
215
  reasoning: Boolean(model.metadata?.reasoning ?? model.thinking),
65
216
  mtp: Boolean(model.features?.mtp),
66
217
  distilled: Boolean(model.features?.distilled),
@@ -73,8 +224,36 @@ export function buildInventoryRow(params) {
73
224
  score: ranked,
74
225
  state: stateOf(supervisor, model),
75
226
  warnings: profileWarnings(profile, model, store),
227
+ components: model.components?.map((component) => {
228
+ if (component.minRuntimeBuild === undefined ||
229
+ (activeRuntimeBuild !== null && activeRuntimeBuild >= component.minRuntimeBuild)) {
230
+ return component;
231
+ }
232
+ const active = activeRuntimeBuild === null ? "unknown" : `b${activeRuntimeBuild}`;
233
+ return {
234
+ ...component,
235
+ available: false,
236
+ unavailableReason: `Requires llama.cpp build b${component.minRuntimeBuild} or newer ` +
237
+ `(active build: ${active})`,
238
+ };
239
+ }) ?? null,
76
240
  };
77
241
  }
242
+ /**
243
+ * Reject a request value that the CLI would read as a flag rather than as a
244
+ * value.
245
+ *
246
+ * An option value has to sit before the `--` separator by construction - the
247
+ * separator only ends *positional* parsing - so unlike `model` and `repo` these
248
+ * cannot be moved out of harm's way, and the argv is unambiguous only if the
249
+ * value itself is. The throw surfaces as the route's 400, next to the type
250
+ * checks in each `makeArgs`.
251
+ */
252
+ function optionValue(label, value) {
253
+ if (value.startsWith("-"))
254
+ throw new Error(`${label} must not start with "-"`);
255
+ return value;
256
+ }
78
257
  /** Resolve a model by id or display name, the same way the completion path does. */
79
258
  function resolveModel(catalog, needle) {
80
259
  if (!needle)
@@ -106,7 +285,7 @@ function profileFromQuery(base, params, model) {
106
285
  }
107
286
  if (Object.keys(patch).length === 0)
108
287
  return base;
109
- return sanitizeProfilePatch(base, patch, model).profile;
288
+ return sanitizeProfilePatch(base, patch, model, runtimeBuild(null)).profile;
110
289
  }
111
290
  /**
112
291
  * Build the `/__host/*` management handler.
@@ -142,9 +321,15 @@ export function createHostApi(deps) {
142
321
  const [gpu, store] = [await deps.queryGpuInfo(), deps.getProfilesStore()];
143
322
  const defaults = deps.getProfileDefaults();
144
323
  const ranking = deps.getRanking();
145
- return deps
146
- .getCatalog()
147
- .map((model) => buildInventoryRow({ model, store, defaults, gpu, ranking, supervisor: deps.supervisor }));
324
+ return deps.getCatalog().map((model) => buildInventoryRow({
325
+ model,
326
+ store,
327
+ defaults,
328
+ gpu,
329
+ ranking,
330
+ supervisor: deps.supervisor,
331
+ runtimeBuild: runtimeBuild(deps.supervisor.runtime),
332
+ }));
148
333
  };
149
334
  const handleModelsList = (res) => {
150
335
  void (async () => {
@@ -167,9 +352,12 @@ export function createHostApi(deps) {
167
352
  const profile = forModel(store, model, deps.getProfileDefaults());
168
353
  sendJson(res, {
169
354
  profile,
170
- fields: profileFieldDescriptors(model),
355
+ fields: profileFieldDescriptors(model, profile),
171
356
  warnings: profileWarnings(profile, model, store),
172
357
  calibration: calibrationInfo(store, model, profile),
358
+ requiresRestart: store.pendingReloadModelIds[model.id] === true,
359
+ hostingProfiles: hostingProfilesFor(store, model),
360
+ familyHostingProfileId: familyHostingProfileId(store, model.family),
173
361
  });
174
362
  };
175
363
  const handleProfileSet = (req, res, model) => {
@@ -182,24 +370,38 @@ export function createHostApi(deps) {
182
370
  try {
183
371
  const store = deps.getProfilesStore();
184
372
  const current = forModel(store, model, deps.getProfileDefaults());
185
- const { profile, adjustments } = sanitizeProfilePatch(current, result.body, model);
186
- deps.saveProfiles(put(store, model, profile));
373
+ const activeRuntimeBuild = runtimeBuild(deps.supervisor.runtime);
374
+ const { profile, adjustments } = sanitizeProfilePatch(current, result.body, model, activeRuntimeBuild);
375
+ applyHostingProfilePatch(store, model, profile, result.body, (id) => removeHostingProfileMaterialization(deps.supervisor.paths, id));
376
+ put(store, model, profile);
377
+ // A setting is only unapplied when it was changed on the currently
378
+ // resident model. Edits to an unloaded model take effect naturally
379
+ // when it is next loaded and do not earn a misleading reload badge.
380
+ const requiresRestart = deps.supervisor.model?.id === model.id;
381
+ if (requiresRestart)
382
+ store.pendingReloadModelIds[model.id] = true;
383
+ deps.saveProfiles(store);
187
384
  // Return the recomputed budget so an edit costs one round trip rather
188
385
  // than a write followed by a read the UI has to sequence.
189
386
  const gpu = await deps.queryGpuInfo();
190
- const calibration = getCalibration(store, model, profile);
387
+ const calibration = profile.calibrationRequired
388
+ ? null
389
+ : getCalibration(store, model, profile);
191
390
  const options = gpu
192
391
  ? { model, profile, calibration, totalVramBytes: gpu.totalBytes }
193
392
  : null;
194
393
  sendJson(res, {
195
394
  profile,
395
+ fields: profileFieldDescriptors(model, profile),
196
396
  adjustments,
197
397
  warnings: profileWarnings(profile, model, store),
198
398
  calibration: calibrationInfo(store, model, profile),
199
399
  budget: options ? vram.budget(options) : null,
200
400
  maxContextThatFits: options ? vram.maxContextThatFits(options) : null,
201
401
  /** True when the running model is the one just edited: a restart applies it. */
202
- requiresRestart: deps.supervisor.model?.id === model.id,
402
+ requiresRestart,
403
+ hostingProfiles: hostingProfilesFor(store, model),
404
+ familyHostingProfileId: familyHostingProfileId(store, model.family),
203
405
  });
204
406
  }
205
407
  catch (error) {
@@ -281,7 +483,7 @@ export function createHostApi(deps) {
281
483
  const options = {
282
484
  model,
283
485
  profile,
284
- calibration: getCalibration(store, model, profile),
486
+ calibration: profile.calibrationRequired ? null : getCalibration(store, model, profile),
285
487
  totalVramBytes: gpu.totalBytes,
286
488
  };
287
489
  sendJson(res, {
@@ -352,6 +554,24 @@ export function createHostApi(deps) {
352
554
  sendError(res, 500, `could not delete ${model.displayName}: ${errorMessage(error)}`);
353
555
  }
354
556
  };
557
+ const handleComponentDelete = (res, model, componentId) => {
558
+ if (deps.supervisor.model?.id === model.id && deps.supervisor.state !== "stopped") {
559
+ sendError(res, 409, "stop the model before removing a bundle component");
560
+ return;
561
+ }
562
+ try {
563
+ const plan = deleteComponentFile(model, componentId);
564
+ deps.rescan();
565
+ sendJson(res, {
566
+ deleted: plan.files,
567
+ freedBytes: plan.bytes,
568
+ componentIds: plan.componentIds,
569
+ });
570
+ }
571
+ catch (error) {
572
+ sendError(res, 409, errorMessage(error));
573
+ }
574
+ };
355
575
  const handleLogs = (res, params) => {
356
576
  const raw = Number(params.get("limit"));
357
577
  const limit = Number.isFinite(raw) && raw > 0 ? Math.min(Math.round(raw), 1000) : DEFAULT_LOG_LINES;
@@ -482,7 +702,7 @@ export function createHostApi(deps) {
482
702
  sendJson(res, {
483
703
  job: deps.jobs?.start("bench", model ?? null, [
484
704
  "bench",
485
- ...(model ? ["--model", model] : []),
705
+ ...(model ? ["--model", optionValue("model", model)] : []),
486
706
  ]) ?? null,
487
707
  });
488
708
  }
@@ -499,7 +719,25 @@ export function createHostApi(deps) {
499
719
  const model = body.model;
500
720
  if (typeof model !== "string" || !model)
501
721
  throw new Error("model is required");
502
- return { target: model, args: ["pull", "--json", "--", model] };
722
+ const components = body.components;
723
+ const quant = body.quant;
724
+ if (components !== undefined &&
725
+ (!Array.isArray(components) || !components.every((id) => typeof id === "string"))) {
726
+ throw new Error("components must be component ids");
727
+ }
728
+ if (quant !== undefined && typeof quant !== "string")
729
+ throw new Error("quant must be a string");
730
+ return {
731
+ target: model,
732
+ args: [
733
+ "pull",
734
+ ...(typeof quant === "string" ? ["--quant", optionValue("quant", quant)] : []),
735
+ ...(components ?? []).flatMap((id) => ["--component", optionValue("component", id)]),
736
+ "--json",
737
+ "--",
738
+ model,
739
+ ],
740
+ };
503
741
  },
504
742
  },
505
743
  "/__host/jobs/add": {
@@ -507,11 +745,24 @@ export function createHostApi(deps) {
507
745
  makeArgs: (body) => {
508
746
  const repo = body.repo;
509
747
  const quant = body.quant;
748
+ const components = body.components;
510
749
  if (typeof repo !== "string" || !repo || typeof quant !== "string" || !quant)
511
750
  throw new Error("repo and quant are required");
751
+ if (components !== undefined &&
752
+ (!Array.isArray(components) || !components.every((id) => typeof id === "string")))
753
+ throw new Error("components must be component ids");
512
754
  return {
513
755
  target: `${repo}#${quant}`,
514
- args: ["add", "--quant", quant, "--json", "--", repo],
756
+ args: [
757
+ "add",
758
+ "--quant",
759
+ optionValue("quant", quant),
760
+ ...(components === undefined ? [] : ["--primary-only"]),
761
+ ...(components ?? []).flatMap((id) => ["--component", optionValue("component", id)]),
762
+ "--json",
763
+ "--",
764
+ repo,
765
+ ],
515
766
  };
516
767
  },
517
768
  },
@@ -527,18 +778,36 @@ export function createHostApi(deps) {
527
778
  "runtime",
528
779
  "install",
529
780
  "--json",
530
- ...(typeof build === "string" ? ["--build", build] : []),
781
+ ...(typeof build === "string" ? ["--build", optionValue("build", build)] : []),
531
782
  ],
532
783
  };
533
784
  },
534
785
  },
786
+ // Removal is host-owned for the same reason installation is: the runtimes
787
+ // directory belongs to this machine. A daemon in brain.mode=remote that ran
788
+ // this locally would delete a same-named runtime out of its own OTTO_HOME.
789
+ "/__host/jobs/runtime-remove": {
790
+ kind: "runtime-remove",
791
+ makeArgs: (body) => {
792
+ const name = body.name;
793
+ if (typeof name !== "string" || !name)
794
+ throw new Error("name is required");
795
+ // Pass the name as an operand, not as a flag candidate. The CLI still
796
+ // owns the real safety check (the name regex plus the parent-dir
797
+ // assertion in removeManagedRuntime).
798
+ return { target: name, args: ["runtime", "remove", "--json", "--", name] };
799
+ },
800
+ },
535
801
  "/__host/jobs/calibrate": {
536
802
  kind: "calibrate",
537
803
  makeArgs: (body) => {
538
804
  const model = body.model;
539
805
  if (typeof model !== "string" || !model)
540
806
  throw new Error("model is required");
541
- return { target: model, args: ["calibrate", "--model", model, "--json"] };
807
+ return {
808
+ target: model,
809
+ args: ["calibrate", "--model", optionValue("model", model), "--json"],
810
+ };
542
811
  },
543
812
  },
544
813
  "/__host/jobs/sweep": {
@@ -547,7 +816,10 @@ export function createHostApi(deps) {
547
816
  const model = body.model;
548
817
  if (typeof model !== "string" || !model)
549
818
  throw new Error("model is required");
550
- return { target: model, args: ["sweep", "--model", model, "--json"] };
819
+ return {
820
+ target: model,
821
+ args: ["sweep", "--model", optionValue("model", model), "--json"],
822
+ };
551
823
  },
552
824
  },
553
825
  };
@@ -636,6 +908,14 @@ export function createHostApi(deps) {
636
908
  handleModelsList(res);
637
909
  return true;
638
910
  }
911
+ // A local daemon may run downloads in its own tracked job process while
912
+ // this service owns the in-memory inventory. Reconcile that disk mutation
913
+ // without restarting the host or unloading its resident model.
914
+ if (route === "/__host/models/rescan" && method === "POST") {
915
+ const models = deps.rescan();
916
+ sendJson(res, { models: models.length });
917
+ return true;
918
+ }
639
919
  if (route === "/__host/model/unload" && method === "POST") {
640
920
  if (!guardWrite(res))
641
921
  return true;
@@ -651,6 +931,7 @@ export function createHostApi(deps) {
651
931
  "/__host/model/fields",
652
932
  "/__host/model/rename",
653
933
  "/__host/model/rename/reset",
934
+ "/__host/model/component",
654
935
  ]);
655
936
  if (!modelRoutes.has(route))
656
937
  return false;
@@ -702,6 +983,17 @@ export function createHostApi(deps) {
702
983
  handleDelete(res, model);
703
984
  return true;
704
985
  }
986
+ if (route === "/__host/model/component" && method === "DELETE") {
987
+ if (!guardWrite(res))
988
+ return true;
989
+ const componentId = params.get("component");
990
+ if (!componentId) {
991
+ sendError(res, 400, "a component query parameter is required");
992
+ return true;
993
+ }
994
+ handleComponentDelete(res, model, componentId);
995
+ return true;
996
+ }
705
997
  if (route === "/__host/model" && method === "GET") {
706
998
  // A single inventory row, for a detail pane that does not want the whole list.
707
999
  void (async () => {
@@ -714,6 +1006,7 @@ export function createHostApi(deps) {
714
1006
  gpu,
715
1007
  ranking: deps.getRanking(),
716
1008
  supervisor: deps.supervisor,
1009
+ runtimeBuild: runtimeBuild(deps.supervisor.runtime),
717
1010
  }));
718
1011
  }
719
1012
  catch (error) {
@@ -73,6 +73,7 @@ export interface ModelEntry {
73
73
  id: string;
74
74
  /** Brain's editable human-facing name; `id` remains the stable model key. */
75
75
  name: string;
76
+ family?: string;
76
77
  object: "model";
77
78
  created: number;
78
79
  owned_by: string;
@@ -104,6 +105,23 @@ export declare function describeModel(model: Model | null, options?: DescribeOpt
104
105
  * model when no catalog provider is wired in.
105
106
  */
106
107
  export declare function buildModelList(supervisor: Supervisor, getCatalog: GetCatalog): ModelEntry[];
108
+ /** Which shape a completion path uses to carry its system turn. */
109
+ export type CompletionShape = "anthropic" | "openai";
110
+ export declare function completionShape(url: string | null | undefined): CompletionShape;
111
+ /**
112
+ * Append the active hosting profile's system-prompt addendum to a buffered
113
+ * completion body.
114
+ *
115
+ * Appending rather than prepending or replacing is the whole point: the agent's
116
+ * own system prompt still leads, and the profile's instructions are read last.
117
+ * A body this cannot understand is forwarded untouched - a malformed or
118
+ * unfamiliar request must still reach llama-server and get llama-server's own
119
+ * error, not a 400 invented here.
120
+ *
121
+ * Cost is one extra parse/serialize per request, paid only by models whose
122
+ * profile actually sets an addendum.
123
+ */
124
+ export declare function injectSystemAddendum(body: Buffer, addendum: string | null, shape: CompletionShape): Buffer;
107
125
  export type ModelGateResult = {
108
126
  ok: true;
109
127
  model: Model;