@otto-code/brain 0.8.8 → 0.8.10

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 (45) hide show
  1. package/dist/commands/bench.js +19 -5
  2. package/dist/commands/catalog.d.ts +1 -0
  3. package/dist/commands/catalog.js +1 -0
  4. package/dist/commands/pull.js +14 -33
  5. package/dist/commands/repo-download.d.ts +14 -0
  6. package/dist/commands/repo-download.js +29 -0
  7. package/dist/commands/search.js +6 -14
  8. package/dist/config/builtin-hosting-profiles.d.ts +8 -0
  9. package/dist/config/builtin-hosting-profiles.js +32 -0
  10. package/dist/config/hosting-profiles.d.ts +33 -0
  11. package/dist/config/hosting-profiles.js +71 -0
  12. package/dist/config/index.d.ts +1 -0
  13. package/dist/config/index.js +1 -0
  14. package/dist/config/paths.d.ts +2 -0
  15. package/dist/config/paths.js +1 -0
  16. package/dist/config/profile-edit.d.ts +4 -2
  17. package/dist/config/profile-edit.js +94 -4
  18. package/dist/config/profiles.js +25 -2
  19. package/dist/config/schema.d.ts +494 -24
  20. package/dist/config/schema.js +55 -0
  21. package/dist/config/store.js +12 -2
  22. package/dist/gguf.d.ts +1 -0
  23. package/dist/gguf.js +1 -0
  24. package/dist/models/download.js +5 -0
  25. package/dist/models/enrich.d.ts +6 -0
  26. package/dist/models/enrich.js +50 -2
  27. package/dist/ops/calibrate.d.ts +4 -1
  28. package/dist/ops/calibrate.js +10 -7
  29. package/dist/ops/sweep.d.ts +3 -1
  30. package/dist/ops/sweep.js +3 -3
  31. package/dist/runtime/args.d.ts +2 -2
  32. package/dist/runtime/args.js +13 -1
  33. package/dist/runtime/index.d.ts +7 -0
  34. package/dist/runtime/index.js +10 -0
  35. package/dist/service/host-api.d.ts +17 -1
  36. package/dist/service/host-api.js +196 -13
  37. package/dist/service/router.d.ts +18 -0
  38. package/dist/service/router.js +89 -4
  39. package/dist/service/serve.js +184 -12
  40. package/dist/service/supervisor.d.ts +32 -4
  41. package/dist/service/supervisor.js +30 -6
  42. package/dist/tui/app.js +1 -1
  43. package/dist/types.d.ts +4 -0
  44. package/dist/vram.js +3 -3
  45. package/package.json +1 -1
@@ -47,25 +47,38 @@ export const MAX_PARALLEL_SLOTS = 16;
47
47
  export const MAX_GPU_LAYERS = 999;
48
48
  export const MIN_CONTEXT_SIZE = 1024;
49
49
  export const CONTEXT_STEP = 8192;
50
+ export const CONTEXT_MULTIPLIERS = [1, 2, 4];
50
51
  /** The context ceiling: the model's native window, or a generous bound if unknown. */
51
52
  export function nativeContextLimit(model) {
52
53
  const native = model?.metadata?.contextLength;
53
54
  return typeof native === "number" && native > 0 ? native : 1000000;
54
55
  }
56
+ export function contextLimit(model, multiplier = 1) {
57
+ return nativeContextLimit(model) * multiplier;
58
+ }
55
59
  /** The editable fields, resolved against one model's capabilities. */
56
- export function profileFieldDescriptors(model) {
60
+ export function profileFieldDescriptors(model, profile) {
57
61
  const projector = model?.components?.find((component) => component.role === "vision_projector");
58
62
  const hasProjector = model?.components
59
63
  ? Boolean(projector?.available)
60
64
  : Boolean(model?.mmprojPath);
61
65
  const fields = [
66
+ {
67
+ key: "contextMultiplier",
68
+ label: "Context multiplier",
69
+ kind: "cycle",
70
+ options: CONTEXT_MULTIPLIERS,
71
+ optionLabels: ["Off", "2× (YaRN)", "4× (YaRN)"],
72
+ available: Boolean(model?.metadata?.contextLength),
73
+ ...(model?.metadata?.contextLength ? {} : { unavailableReason: "native context unknown" }),
74
+ },
62
75
  {
63
76
  key: "contextSize",
64
77
  label: "Context",
65
78
  kind: "number",
66
79
  step: CONTEXT_STEP,
67
80
  min: MIN_CONTEXT_SIZE,
68
- max: nativeContextLimit(model),
81
+ max: contextLimit(model, profile?.contextMultiplier ?? 1),
69
82
  available: true,
70
83
  },
71
84
  {
@@ -160,6 +173,17 @@ export function profileWarnings(profile, model, store) {
160
173
  blocksStart: false,
161
174
  });
162
175
  }
176
+ if (profile.contextMultiplier > 1) {
177
+ warnings.push({
178
+ field: "contextMultiplier",
179
+ severity: "warn",
180
+ message: `YaRN ×${profile.contextMultiplier} extrapolates beyond the native context. Recalibrate before relying on this profile.`,
181
+ blocksStart: false,
182
+ });
183
+ }
184
+ // Distinct from `calibrationRequired`, which says this profile has never been
185
+ // measured in its current shape. This says a measurement exists but was taken
186
+ // for other cache types, so it names the reason rather than just the verdict.
163
187
  if (model && store && hasStaleCalibration(store, model, profile)) {
164
188
  warnings.push({
165
189
  field: "cacheTypeK",
@@ -176,6 +200,17 @@ export function profileWarnings(profile, model, store) {
176
200
  * model's layer count, which the UI must never present as measured on this file.
177
201
  */
178
202
  export function calibrationInfo(store, model, profile) {
203
+ // A historical measurement is invalid as soon as any VRAM-affecting setting
204
+ // changes. Keep the data for comparison, but do not present or use it as the
205
+ // current model budget until a calibration commits the new profile.
206
+ if (profile.calibrationRequired) {
207
+ return {
208
+ state: "theoretical",
209
+ kvBytesPerToken: null,
210
+ measuredAt: null,
211
+ measuredOn: null,
212
+ };
213
+ }
179
214
  const calibration = getCalibration(store, model, profile);
180
215
  if (!calibration) {
181
216
  return {
@@ -195,6 +230,25 @@ export function calibrationInfo(store, model, profile) {
195
230
  function clamp(value, min, max) {
196
231
  return Math.max(min, Math.min(max, value));
197
232
  }
233
+ /** The settings whose value changes what a calibration would measure. */
234
+ const CALIBRATION_INPUTS = [
235
+ "contextMultiplier",
236
+ "contextSize",
237
+ "cacheTypeK",
238
+ "cacheTypeV",
239
+ "flashAttention",
240
+ "gpuLayers",
241
+ "parallelSlots",
242
+ "vision",
243
+ "enabledComponents",
244
+ ];
245
+ /** Every calibration input is a scalar except the component id list, which is a set. */
246
+ function sameCalibrationInput(before, after) {
247
+ if (Array.isArray(before) && Array.isArray(after)) {
248
+ return [...before].sort().join("\0") === [...after].sort().join("\0");
249
+ }
250
+ return before === after;
251
+ }
198
252
  /**
199
253
  * Apply an editable patch to a profile, clamping every field to its range and
200
254
  * dropping anything the model cannot use.
@@ -206,7 +260,7 @@ function clamp(value, min, max) {
206
260
  * CLI-only: they have no measured effect worth exposing and `extraArgs` is
207
261
  * arbitrary process arguments.
208
262
  */
209
- export function sanitizeProfilePatch(current, patch, model) {
263
+ export function sanitizeProfilePatch(current, patch, model, runtimeBuild = null) {
210
264
  const adjustments = [];
211
265
  const next = { ...current };
212
266
  if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
@@ -245,7 +299,14 @@ export function sanitizeProfilePatch(current, patch, model) {
245
299
  }
246
300
  next[key] = value;
247
301
  };
248
- takeNumber("contextSize", MIN_CONTEXT_SIZE, nativeContextLimit(model));
302
+ if ("contextMultiplier" in p) {
303
+ const raw = p.contextMultiplier;
304
+ if (typeof raw !== "number" || !CONTEXT_MULTIPLIERS.includes(raw)) {
305
+ throw new Error("contextMultiplier must be one of 1, 2, or 4");
306
+ }
307
+ next.contextMultiplier = raw;
308
+ }
309
+ takeNumber("contextSize", MIN_CONTEXT_SIZE, contextLimit(model, next.contextMultiplier));
249
310
  takeCacheType("cacheTypeK");
250
311
  takeCacheType("cacheTypeV");
251
312
  takeBoolean("flashAttention");
@@ -283,6 +344,14 @@ export function sanitizeProfilePatch(current, patch, model) {
283
344
  throw new Error("enabledComponents must be an array of component ids");
284
345
  }
285
346
  const requested = [...new Set(p.enabledComponents)];
347
+ const requiredBuild = requested
348
+ .map((id) => model?.components?.find((component) => component.id === id))
349
+ .find((component) => component?.minRuntimeBuild !== undefined &&
350
+ (runtimeBuild === null || runtimeBuild < component.minRuntimeBuild))?.minRuntimeBuild;
351
+ if (requiredBuild !== undefined) {
352
+ const active = runtimeBuild === null ? "unknown" : `b${runtimeBuild}`;
353
+ throw new Error(`components require llama.cpp build b${requiredBuild} or newer (active build: ${active})`);
354
+ }
286
355
  const available = new Set(model?.components
287
356
  ?.filter((component) => component.available)
288
357
  .map((component) => component.id) ?? []);
@@ -294,6 +363,27 @@ export function sanitizeProfilePatch(current, patch, model) {
294
363
  if (model?.components)
295
364
  next.vision = Boolean(projector);
296
365
  }
366
+ // A partial patch may lower the multiplier without naming contextSize. Clamp
367
+ // the saved value after every field has settled so launch args can never keep
368
+ // an extended context after YaRN has been turned off.
369
+ const contextSize = clamp(next.contextSize, MIN_CONTEXT_SIZE, contextLimit(model, next.contextMultiplier));
370
+ if (contextSize !== next.contextSize) {
371
+ next.contextSize = contextSize;
372
+ adjustments.push(`contextSize clamped to ${contextSize}`);
373
+ }
374
+ // The saved profile carries the calibration verdict. Do not infer it from
375
+ // whether an old measurement happens to exist: a person who changes settings,
376
+ // leaves, and returns must still be told to calibrate this new configuration.
377
+ //
378
+ // Compare values, never key presence. The editor autosaves the whole draft on
379
+ // every edit, so every one of these keys is in `p` every time; keying off
380
+ // presence threw away a real measurement whenever any unrelated field - or a
381
+ // hosting-profile choice, which does not touch VRAM at all - was saved.
382
+ const before = current;
383
+ const after = next;
384
+ if (CALIBRATION_INPUTS.some((key) => !sameCalibrationInput(before[key], after[key]))) {
385
+ next.calibrationRequired = true;
386
+ }
297
387
  return { profile: next, adjustments };
298
388
  }
299
389
  //# sourceMappingURL=profile-edit.js.map
@@ -17,6 +17,8 @@ export function defaultProfile(model, defaults) {
17
17
  // Long context is the point of this hardware; start at the native limit and
18
18
  // let the VRAM budget pull it down.
19
19
  contextSize: Math.min(nativeContext, contextCap),
20
+ contextMultiplier: 1,
21
+ calibrationRequired: true,
20
22
  cacheTypeK: defaults?.cacheTypeK ?? "q8_0",
21
23
  cacheTypeV: defaults?.cacheTypeV ?? "q8_0",
22
24
  flashAttention: defaults?.flashAttention ?? true, // required for a quantised V cache
@@ -34,6 +36,13 @@ export function defaultProfile(model, defaults) {
34
36
  batchSize: null,
35
37
  ubatchSize: null,
36
38
  extraArgs: [],
39
+ hostingProfileId: null,
40
+ // Inherit, not off: a new model in a family that has a default should use
41
+ // it. With no family default configured this resolves to nothing anyway.
42
+ hostingProfileMode: "inherit",
43
+ chatTemplateFile: null,
44
+ chatTemplateKwargs: {},
45
+ chatSystemAddendum: null,
37
46
  };
38
47
  }
39
48
  /** Stored profile for a model, falling back to computed defaults. */
@@ -58,6 +67,15 @@ export function forModel(store, model, defaults) {
58
67
  return {
59
68
  ...base,
60
69
  ...stored,
70
+ // COMPAT(hostingProfileMode): added in v0.8.8, remove after 2027-02-12.
71
+ // The first hosting-profile store only had an id, and a non-null id there
72
+ // was an explicit custom choice. Such a profile parses as `inherit` (the
73
+ // schema default), so promote it. Writers hold the inverse invariant - the
74
+ // id is nulled whenever the mode is not `custom` - so a stored id can only
75
+ // mean a legacy record, never a stale leftover of a newer explicit choice.
76
+ hostingProfileMode: stored.hostingProfileId && stored.hostingProfileMode === "inherit"
77
+ ? "custom"
78
+ : stored.hostingProfileMode,
61
79
  enabledComponents,
62
80
  modelPath: model.modelPath,
63
81
  mmprojPath: mmproj?.path ?? (model.components ? null : model.mmprojPath),
@@ -72,12 +90,13 @@ export function put(store, model, profile) {
72
90
  /** Calibration is keyed by cache types, since those change bytes/token. */
73
91
  export function calibrationKey(profile) {
74
92
  const components = [...(profile.enabledComponents ?? [])].sort();
93
+ const multiplier = profile.contextMultiplier > 1 ? `:contextMultiplier=${profile.contextMultiplier}` : "";
75
94
  // COMPAT(bundleCalibrationKey): added in v0.8.7, remove after 2027-02-11.
76
95
  // A main-model-only load remains the historical identity; any enabled bundle
77
96
  // artifact gets a distinct key and therefore cannot claim that measurement.
78
97
  return components.length
79
- ? `${profile.cacheTypeK}:${profile.cacheTypeV}:components=${components.join(",")}`
80
- : `${profile.cacheTypeK}:${profile.cacheTypeV}`;
98
+ ? `${profile.cacheTypeK}:${profile.cacheTypeV}${multiplier}:components=${components.join(",")}`
99
+ : `${profile.cacheTypeK}:${profile.cacheTypeV}${multiplier}`;
81
100
  }
82
101
  /**
83
102
  * True when this model has a stored calibration, but for different cache types
@@ -167,6 +186,10 @@ export function putCalibration(store, model, profile, measurement) {
167
186
  writable: true,
168
187
  });
169
188
  }
189
+ // Calibration is a durable verdict about the persisted model profile, not a
190
+ // transient UI hint. Every completion path (CLI, TUI, and host job) shares
191
+ // this helper, so clearing it here keeps the state consistent everywhere.
192
+ put(store, model, { ...profile, calibrationRequired: false });
170
193
  return store;
171
194
  }
172
195
  //# sourceMappingURL=profiles.js.map