@otto-code/brain 0.8.10 → 0.8.13

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 (65) hide show
  1. package/dist/commands/bench.js +2 -2
  2. package/dist/commands/calibrate.js +11 -2
  3. package/dist/commands/catalog.d.ts +1 -0
  4. package/dist/commands/catalog.js +1 -0
  5. package/dist/commands/pull.d.ts +1 -0
  6. package/dist/commands/pull.js +12 -3
  7. package/dist/commands/search.d.ts +1 -0
  8. package/dist/commands/search.js +12 -2
  9. package/dist/config/index.d.ts +2 -2
  10. package/dist/config/index.js +2 -2
  11. package/dist/config/profile-edit.d.ts +88 -1
  12. package/dist/config/profile-edit.js +294 -43
  13. package/dist/config/profiles.d.ts +19 -3
  14. package/dist/config/profiles.js +52 -4
  15. package/dist/config/schema.d.ts +616 -0
  16. package/dist/config/schema.js +65 -3
  17. package/dist/config/store.js +7 -4
  18. package/dist/gguf.d.ts +7 -0
  19. package/dist/gguf.js +15 -2
  20. package/dist/models/download.d.ts +1 -1
  21. package/dist/models/download.js +2 -2
  22. package/dist/models/enrich.d.ts +6 -0
  23. package/dist/models/enrich.js +27 -1
  24. package/dist/models/index.d.ts +1 -1
  25. package/dist/models/index.js +4 -3
  26. package/dist/ops/archive.d.ts +14 -1
  27. package/dist/ops/archive.js +9 -5
  28. package/dist/ops/calibrate.d.ts +38 -3
  29. package/dist/ops/calibrate.js +68 -19
  30. package/dist/ops/report.js +51 -1
  31. package/dist/ops/results.d.ts +77 -11
  32. package/dist/ops/results.js +84 -14
  33. package/dist/ops/sweep.d.ts +38 -1
  34. package/dist/ops/sweep.js +61 -10
  35. package/dist/runtime/args.d.ts +15 -2
  36. package/dist/runtime/args.js +60 -5
  37. package/dist/runtime/managed.js +2 -2
  38. package/dist/service/activity.d.ts +19 -0
  39. package/dist/service/activity.js +47 -4
  40. package/dist/service/host-api.d.ts +28 -4
  41. package/dist/service/host-api.js +109 -28
  42. package/dist/service/log-format.d.ts +18 -0
  43. package/dist/service/log-format.js +32 -0
  44. package/dist/service/process-pool.d.ts +45 -0
  45. package/dist/service/process-pool.js +271 -0
  46. package/dist/service/router.d.ts +74 -3
  47. package/dist/service/router.js +277 -51
  48. package/dist/service/run-log.d.ts +6 -1
  49. package/dist/service/run-log.js +46 -4
  50. package/dist/service/scheduler.d.ts +250 -31
  51. package/dist/service/scheduler.js +408 -63
  52. package/dist/service/serve.d.ts +4 -0
  53. package/dist/service/serve.js +376 -142
  54. package/dist/service/status-events.d.ts +14 -1
  55. package/dist/service/status-events.js +112 -12
  56. package/dist/service/supervisor.d.ts +9 -7
  57. package/dist/service/supervisor.js +37 -12
  58. package/dist/sysmon.d.ts +15 -0
  59. package/dist/sysmon.js +56 -9
  60. package/dist/tui/app.d.ts +8 -2
  61. package/dist/tui/app.js +83 -26
  62. package/dist/types.d.ts +18 -0
  63. package/dist/vram.d.ts +37 -0
  64. package/dist/vram.js +57 -18
  65. package/package.json +1 -1
@@ -17,8 +17,33 @@
17
17
  * - Changing a cache type invalidates a measured calibration, because KV
18
18
  * bytes/token is a function of the cache types (see `vram.ts`).
19
19
  */
20
- import { CACHE_TYPE_BYTES } from "../vram.js";
21
- import { getCalibration, hasStaleCalibration } from "./profiles.js";
20
+ import os from "node:os";
21
+ import { CACHE_TYPE_BYTES, formatGiB, promptCacheSize } from "../vram.js";
22
+ import { getCalibration, getCalibrationForBudget, hasStaleCalibration } from "./profiles.js";
23
+ /**
24
+ * Sampler ranges. llama.cpp itself bounds almost none of these - it will take a
25
+ * temperature of 50 - so the bounds are the useful range rather than the legal
26
+ * one, which is what a stepper wants. The write path clamps to exactly these,
27
+ * so a value the editor cannot reach is a value the brain will not store.
28
+ */
29
+ export const SAMPLING_RANGES = {
30
+ temperature: { min: 0, max: 2, step: 0.05, precision: 2 },
31
+ topP: { min: 0, max: 1, step: 0.05, precision: 2 },
32
+ topK: { min: 0, max: 200, step: 1 },
33
+ minP: { min: 0, max: 1, step: 0.01, precision: 2 },
34
+ presencePenalty: { min: -2, max: 2, step: 0.05, precision: 2 },
35
+ repeatPenalty: { min: 0.5, max: 2, step: 0.01, precision: 2 },
36
+ };
37
+ /** Tri-state preservation, in the order a cycle should offer it. */
38
+ export const PRESERVE_REASONING_CYCLE = ["default", "on", "off"];
39
+ /** The stored value a cycle option maps to. */
40
+ export function preserveReasoningFromOption(option) {
41
+ return option === "on" ? true : option === "off" ? false : null;
42
+ }
43
+ /** The cycle option a stored value maps to. */
44
+ export function preserveReasoningOption(value) {
45
+ return value === true ? "on" : value === false ? "off" : "default";
46
+ }
22
47
  /**
23
48
  * The cache types the editor cycles through. `CACHE_TYPE_BYTES` knows more
24
49
  * (f32, bf16, q5_0, q4_1) and a write naming one of those is accepted; these
@@ -44,6 +69,19 @@ export function formatReasoningBudget(budget) {
44
69
  return budget === UNRESTRICTED_REASONING_BUDGET ? "unrestricted" : String(budget);
45
70
  }
46
71
  export const MAX_PARALLEL_SLOTS = 16;
72
+ /**
73
+ * Ceiling on `cachedChats`. Generous on purpose: entries are small for a small
74
+ * model and huge for a large one, so the honest guard is the RAM figure the
75
+ * warning shows, not an arbitrary count.
76
+ */
77
+ export const MAX_CACHED_CHATS = 64;
78
+ /**
79
+ * What llama.cpp allows by default when no `--cache-ram` is emitted, in bytes.
80
+ * `cachedChats` of 0 emits no flag, so the estimate for the Default option is
81
+ * this fixed figure against the installed RAM - not the model's measured KV
82
+ * cost, which is irrelevant here because the size does not depend on the model.
83
+ */
84
+ export const ENGINE_DEFAULT_CACHE_RAM_BYTES = 8192 * 1024 * 1024;
47
85
  export const MAX_GPU_LAYERS = 999;
48
86
  export const MIN_CONTEXT_SIZE = 1024;
49
87
  export const CONTEXT_STEP = 8192;
@@ -62,13 +100,25 @@ export function profileFieldDescriptors(model, profile) {
62
100
  const hasProjector = model?.components
63
101
  ? Boolean(projector?.available)
64
102
  : Boolean(model?.mmprojPath);
103
+ // The template exposes a thinking channel at all - the gate for both reasoning
104
+ // controls. Preservation used to be gated on `reasoningPreservation
105
+ // .templateArgument` instead, which is detected by grepping the chat template
106
+ // for a literal `preserve_thinking`/`preserve_reasoning` kwarg. Almost no
107
+ // template spells it that way: llama.cpp decides the same question by probing
108
+ // the rendered template, so it happily logs "chat template supports preserving
109
+ // reasoning, consider enabling it via --reasoning-preserve" for a model whose
110
+ // toggle Otto was hiding. The flag exists on every build we ship and defaults
111
+ // to the template's own behavior when absent, so offering it wherever there is
112
+ // reasoning to preserve costs nothing and stops hiding a working setting.
113
+ const hasReasoning = Boolean(model?.metadata?.reasoning || model?.reasoningPreservation);
65
114
  const fields = [
66
115
  {
67
116
  key: "contextMultiplier",
68
117
  label: "Context multiplier",
69
118
  kind: "cycle",
119
+ description: "Stretches the context window past the model's native size with RoPE scaling.",
70
120
  options: CONTEXT_MULTIPLIERS,
71
- optionLabels: ["Off", "2× (YaRN)", "4× (YaRN)"],
121
+ optionLabels: ["Off", "2×", "4×"],
72
122
  available: Boolean(model?.metadata?.contextLength),
73
123
  ...(model?.metadata?.contextLength ? {} : { unavailableReason: "native context unknown" }),
74
124
  },
@@ -76,6 +126,7 @@ export function profileFieldDescriptors(model, profile) {
76
126
  key: "contextSize",
77
127
  label: "Context",
78
128
  kind: "number",
129
+ description: "How many tokens of conversation the model can hold at once.",
79
130
  step: CONTEXT_STEP,
80
131
  min: MIN_CONTEXT_SIZE,
81
132
  max: contextLimit(model, profile?.contextMultiplier ?? 1),
@@ -85,6 +136,7 @@ export function profileFieldDescriptors(model, profile) {
85
136
  key: "cacheTypeK",
86
137
  label: "KV cache K",
87
138
  kind: "cycle",
139
+ description: "Precision of the cached keys; lower saves VRAM and costs a little accuracy.",
88
140
  options: CACHE_TYPE_CYCLE,
89
141
  available: true,
90
142
  },
@@ -92,54 +144,147 @@ export function profileFieldDescriptors(model, profile) {
92
144
  key: "cacheTypeV",
93
145
  label: "KV cache V",
94
146
  kind: "cycle",
147
+ description: "Precision of the cached values; lower saves VRAM and costs a little accuracy.",
95
148
  options: CACHE_TYPE_CYCLE,
96
149
  available: true,
97
150
  },
98
- { key: "flashAttention", label: "Flash attention", kind: "toggle", available: true },
151
+ // The five KV/context settings that size and split the cache sit together,
152
+ // in the order the math reads them: the window (multiplier, size), its
153
+ // quantisation (K, V), how it is split (slots), and the RAM fallback
154
+ // (cached chats). Flash attention is related but leaves the KV byte total
155
+ // untouched, so it stays with the remaining options below.
156
+ {
157
+ key: "parallelSlots",
158
+ label: "Parallel slots",
159
+ kind: "number",
160
+ description: "How many chats the model serves at once, each taking a share of the context.",
161
+ step: 1,
162
+ min: 1,
163
+ max: MAX_PARALLEL_SLOTS,
164
+ available: true,
165
+ },
166
+ {
167
+ key: "cachedChats",
168
+ label: "Cached KVs",
169
+ kind: "number",
170
+ description: "How many idle chats keep their state in system RAM so returning to one skips a re-read.",
171
+ step: 1,
172
+ min: 0,
173
+ max: MAX_CACHED_CHATS,
174
+ available: true,
175
+ },
176
+ {
177
+ key: "flashAttention",
178
+ label: "Flash attention",
179
+ kind: "toggle",
180
+ description: "A faster attention kernel, and what a quantised value cache requires.",
181
+ available: true,
182
+ },
183
+ // Bundle models expose the projector in the component section below. Keep
184
+ // the legacy profile field for hand-scanned single-file models, but do not
185
+ // render two controls that write the same vision setting for bundles. It
186
+ // belongs with the other options, right after flash attention.
187
+ ...(model?.components
188
+ ? []
189
+ : [
190
+ {
191
+ key: "vision",
192
+ label: "Vision",
193
+ kind: "toggle",
194
+ description: "Loads the vision projector so the model can read images.",
195
+ available: hasProjector,
196
+ ...(hasProjector
197
+ ? {}
198
+ : {
199
+ unavailableReason: projector
200
+ ? "download the vision component first"
201
+ : "no projector",
202
+ }),
203
+ },
204
+ ]),
99
205
  {
100
206
  key: "reasoningBudget",
101
207
  label: "Reasoning budget",
102
208
  kind: "cycle",
209
+ description: "Caps how many tokens the model may think before it is told to answer.",
103
210
  options: REASONING_BUDGET_CYCLE,
104
211
  optionLabels: ["Thinking Off", "512", "1024", "1536", "3072", "Unrestricted"],
105
212
  min: -1,
106
213
  available: true,
107
214
  },
215
+ // Extends the reasoning group, immediately after the budget it qualifies.
216
+ {
217
+ key: "preserveReasoning",
218
+ label: "Preserve reasoning",
219
+ kind: "cycle",
220
+ description: "Keeps earlier thinking in the history instead of only the latest reply's.",
221
+ options: [...PRESERVE_REASONING_CYCLE],
222
+ optionLabels: ["Template default", "On", "Off"],
223
+ optionValues: PRESERVE_REASONING_CYCLE.map(preserveReasoningFromOption),
224
+ available: hasReasoning,
225
+ ...(hasReasoning ? {} : { unavailableReason: "no thinking channel in this template" }),
226
+ },
108
227
  {
109
228
  key: "gpuLayers",
110
229
  label: "GPU layers",
111
230
  kind: "number",
231
+ description: "How many layers run on the GPU; any remainder runs on the CPU.",
112
232
  step: 1,
113
233
  min: 0,
114
234
  max: MAX_GPU_LAYERS,
115
235
  available: true,
116
236
  },
237
+ // Sampling. These change what the model writes rather than what it costs, so
238
+ // they sit after the hosting fields and contribute nothing to the budget.
117
239
  {
118
- key: "parallelSlots",
119
- label: "Parallel slots",
240
+ key: "temperature",
241
+ label: "Temperature",
120
242
  kind: "number",
121
- step: 1,
122
- min: 1,
123
- max: MAX_PARALLEL_SLOTS,
243
+ description: "How adventurous each next-token choice is; lower is more predictable.",
244
+ ...SAMPLING_RANGES.temperature,
245
+ available: true,
246
+ },
247
+ {
248
+ key: "topP",
249
+ label: "Top P",
250
+ kind: "number",
251
+ description: "Considers only the likeliest tokens whose probabilities add up to this share.",
252
+ ...SAMPLING_RANGES.topP,
253
+ available: true,
254
+ },
255
+ {
256
+ key: "topK",
257
+ label: "Top K",
258
+ kind: "number",
259
+ description: "Considers only this many of the likeliest tokens; 0 turns the limit off.",
260
+ ...SAMPLING_RANGES.topK,
261
+ available: true,
262
+ },
263
+ {
264
+ key: "minP",
265
+ label: "Min P",
266
+ kind: "number",
267
+ description: "Drops tokens less likely than this fraction of the best one; 0 turns it off.",
268
+ ...SAMPLING_RANGES.minP,
269
+ available: true,
270
+ },
271
+ {
272
+ key: "presencePenalty",
273
+ label: "Presence penalty",
274
+ kind: "number",
275
+ description: "Pushes toward new subjects by penalising tokens already used; 0 turns it off.",
276
+ ...SAMPLING_RANGES.presencePenalty,
277
+ available: true,
278
+ },
279
+ {
280
+ key: "repeatPenalty",
281
+ label: "Repetition penalty",
282
+ kind: "number",
283
+ description: "Discourages repeating recent tokens; 1 turns it off.",
284
+ ...SAMPLING_RANGES.repeatPenalty,
124
285
  available: true,
125
286
  },
126
287
  ];
127
- // Bundle models expose the projector in the component section below. Keep
128
- // the legacy profile field for hand-scanned single-file models, but do not
129
- // render two controls that write the same vision setting for bundles.
130
- if (!model?.components) {
131
- fields.splice(4, 0, {
132
- key: "vision",
133
- label: "Vision",
134
- kind: "toggle",
135
- available: hasProjector,
136
- ...(hasProjector
137
- ? {}
138
- : {
139
- unavailableReason: projector ? "download the vision component first" : "no projector",
140
- }),
141
- });
142
- }
143
288
  return fields;
144
289
  }
145
290
  /** Whether a KV cache type is quantised (anything that is not a float type). */
@@ -166,10 +311,49 @@ export function profileWarnings(profile, model, store) {
166
311
  });
167
312
  }
168
313
  if (profile.parallelSlots > 1) {
314
+ const perSlot = profile.contextSize > 0 ? Math.floor(profile.contextSize / profile.parallelSlots) : null;
169
315
  warnings.push({
170
316
  field: "parallelSlots",
171
317
  severity: "info",
172
- message: `${profile.parallelSlots} concurrent requests, sharing one KV pool.`,
318
+ message: perSlot !== null
319
+ ? `${profile.parallelSlots} concurrent chats: ~${Math.round(perSlot / 1000)}K context each, all resident.`
320
+ : `${profile.parallelSlots} concurrent requests, sharing one KV pool.`,
321
+ blocksStart: false,
322
+ });
323
+ }
324
+ // The estimate is shown whenever this field is available, including at the
325
+ // Default of 0. That is the one value the user could not otherwise price:
326
+ // 0 emits no flag, and llama.cpp then parks up to its own 8 GiB cache-ram
327
+ // default in system RAM, which is not the same as caching nothing.
328
+ //
329
+ // A count above 0 can only be priced from a measurement: without one the
330
+ // theoretical KV cost runs to multiples of the real one, and naming a figure
331
+ // derived from it would invite reserving several times the RAM actually
332
+ // needed. Once a model has been measured, keep its last value while edits are
333
+ // pending recalibration; the stale calibration state makes the reduced trust
334
+ // visible without making the number jump.
335
+ const calibration = model && store ? getCalibrationForBudget(store, model, profile) : null;
336
+ const cache = model ? promptCacheSize(model, profile, calibration) : null;
337
+ const hasCount = (profile.cachedChats ?? 0) > 0;
338
+ if (hasCount && (!cache || cache.source !== "measured")) {
339
+ warnings.push({
340
+ field: "cachedChats",
341
+ severity: "warn",
342
+ message: "Calibrate this model first: without a measured KV cost the RAM this needs cannot be sized, so llama.cpp's own cache limit stays in effect.",
343
+ blocksStart: false,
344
+ });
345
+ }
346
+ else {
347
+ const totalBytes = hasCount ? cache.totalBytes : ENGINE_DEFAULT_CACHE_RAM_BYTES;
348
+ const installed = formatGiB(os.totalmem());
349
+ warnings.push({
350
+ field: "cachedChats",
351
+ // Yellow at half the machine's RAM, red once the parked state would use
352
+ // at least all of it.
353
+ severity: totalBytes >= os.totalmem() ? "error" : totalBytes >= os.totalmem() / 2 ? "warn" : "info",
354
+ message: hasCount
355
+ ? `${formatGiB(cache.perChatBytes)} each becomes ${formatGiB(totalBytes)} of ${installed}.`
356
+ : `llama.cpp's own limit applies: about ${formatGiB(totalBytes)} of ${installed}.`,
173
357
  blocksStart: false,
174
358
  });
175
359
  }
@@ -177,7 +361,7 @@ export function profileWarnings(profile, model, store) {
177
361
  warnings.push({
178
362
  field: "contextMultiplier",
179
363
  severity: "warn",
180
- message: `YaRN ×${profile.contextMultiplier} extrapolates beyond the native context. Recalibrate before relying on this profile.`,
364
+ message: "YaRN extrapolates beyond the native context.",
181
365
  blocksStart: false,
182
366
  });
183
367
  }
@@ -188,7 +372,7 @@ export function profileWarnings(profile, model, store) {
188
372
  warnings.push({
189
373
  field: "cacheTypeK",
190
374
  severity: "info",
191
- message: "Cache types changed since the last measurement. Recalibrate for a real budget.",
375
+ message: "Cache types changed since the last measurement.",
192
376
  blocksStart: false,
193
377
  });
194
378
  }
@@ -200,24 +384,22 @@ export function profileWarnings(profile, model, store) {
200
384
  * model's layer count, which the UI must never present as measured on this file.
201
385
  */
202
386
  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) {
387
+ const current = getCalibration(store, model, profile);
388
+ const calibration = getCalibrationForBudget(store, model, profile);
389
+ if (!calibration) {
207
390
  return {
208
- state: "theoretical",
391
+ state: hasStaleCalibration(store, model, profile) ? "stale" : "theoretical",
209
392
  kvBytesPerToken: null,
210
393
  measuredAt: null,
211
394
  measuredOn: null,
212
395
  };
213
396
  }
214
- const calibration = getCalibration(store, model, profile);
215
- if (!calibration) {
397
+ if (profile.calibrationRequired || !current) {
216
398
  return {
217
- state: hasStaleCalibration(store, model, profile) ? "stale" : "theoretical",
218
- kvBytesPerToken: null,
219
- measuredAt: null,
220
- measuredOn: null,
399
+ state: "stale",
400
+ kvBytesPerToken: calibration.kvBytesPerToken,
401
+ measuredAt: calibration.measuredAt ?? null,
402
+ measuredOn: calibration.measuredOn ?? null,
221
403
  };
222
404
  }
223
405
  return {
@@ -230,15 +412,37 @@ export function calibrationInfo(store, model, profile) {
230
412
  function clamp(value, min, max) {
231
413
  return Math.max(min, Math.min(max, value));
232
414
  }
233
- /** The settings whose value changes what a calibration would measure. */
415
+ /**
416
+ * The settings whose value changes what a calibration would measure - the KV
417
+ * cache system (cache types, attention configuration, loaded components) or the
418
+ * evaluation (the extended RoPE shape). Only these may set
419
+ * `calibrationRequired`.
420
+ *
421
+ * `contextSize` is deliberately absent. A calibration measures bytes *per
422
+ * token* - the context size is the independent variable it varies to get the
423
+ * slope, so the result is by construction the same at any context, and
424
+ * `calibrationKey` does not record it either. Listing it here invalidated the
425
+ * measurement on every context edit, including the one "Fit to VRAM" makes
426
+ * itself: the fit picked a context from the measured figure, the write that
427
+ * saved it dropped back to the (~4x higher) theoretical estimate, and the
428
+ * saved context was then far past the budget it had just been sized against.
429
+ *
430
+ * `gpuLayers` and `parallelSlots` are absent for the same differential reason:
431
+ * they only move weights between devices or split the one KV pool across slots.
432
+ * The calibration is the GPU-delta between two loads at different contexts, so
433
+ * every fixed term - weights wherever they sit, the CUDA context, compute
434
+ * buffers - cancels out of the slope, and a load whose KV split to CPU is
435
+ * rejected as unusable rather than measured low. Neither the bytes/token nor the
436
+ * evaluation changes, so neither may discard a real measurement.
437
+ *
438
+ * `cachedChats` is absent because it spends host RAM, not VRAM, and never
439
+ * reaches the load a calibration measures.
440
+ */
234
441
  const CALIBRATION_INPUTS = [
235
442
  "contextMultiplier",
236
- "contextSize",
237
443
  "cacheTypeK",
238
444
  "cacheTypeV",
239
445
  "flashAttention",
240
- "gpuLayers",
241
- "parallelSlots",
242
446
  "vision",
243
447
  "enabledComponents",
244
448
  ];
@@ -287,6 +491,27 @@ export function sanitizeProfilePatch(current, patch, model, runtimeBuild = null)
287
491
  throw new Error(`${key} must be a boolean`);
288
492
  next[key] = p[key];
289
493
  };
494
+ /**
495
+ * A sampler value: clamped to its useful range and rounded to the precision
496
+ * the editor advertises. Rounding matters as much as clamping - a stepper that
497
+ * added 0.05 six times sends 0.30000000000000004, and without this the profile
498
+ * would store that and hand it to llama-server on the command line.
499
+ */
500
+ const takeSampling = (key) => {
501
+ if (!(key in p))
502
+ return;
503
+ const raw = p[key];
504
+ if (typeof raw !== "number" || !Number.isFinite(raw)) {
505
+ throw new Error(`${key} must be a number`);
506
+ }
507
+ const range = SAMPLING_RANGES[key];
508
+ const precision = "precision" in range ? range.precision : 0;
509
+ const rounded = Number(raw.toFixed(precision));
510
+ const clamped = clamp(rounded, range.min, range.max);
511
+ if (clamped !== rounded)
512
+ adjustments.push(`${key} clamped to ${clamped}`);
513
+ next[key] = clamped;
514
+ };
290
515
  const takeCacheType = (key) => {
291
516
  if (!(key in p))
292
517
  return;
@@ -312,6 +537,7 @@ export function sanitizeProfilePatch(current, patch, model, runtimeBuild = null)
312
537
  takeBoolean("flashAttention");
313
538
  takeNumber("gpuLayers", 0, MAX_GPU_LAYERS);
314
539
  takeNumber("parallelSlots", 1, MAX_PARALLEL_SLOTS);
540
+ takeNumber("cachedChats", 0, MAX_CACHED_CHATS);
315
541
  // -1 (unrestricted) is the floor; anything below it is meaningless to
316
542
  // llama-server. There is no upper bound worth inventing: a budget larger than
317
543
  // the context is simply never reached.
@@ -326,6 +552,31 @@ export function sanitizeProfilePatch(current, patch, model, runtimeBuild = null)
326
552
  adjustments.push(`reasoningBudget clamped to ${value}`);
327
553
  next.reasoningBudget = value;
328
554
  }
555
+ // Tri-state, and the third state is the point: null means "leave the template's
556
+ // own behavior alone", which is not the same as false ("trim the trace"). The
557
+ // string forms are accepted because the editor renders this as a cycle whose
558
+ // options are strings on the wire.
559
+ if ("preserveReasoning" in p) {
560
+ const raw = p.preserveReasoning;
561
+ if (raw === null || raw === "default") {
562
+ next.preserveReasoning = null;
563
+ }
564
+ else if (typeof raw === "boolean") {
565
+ next.preserveReasoning = raw;
566
+ }
567
+ else if (raw === "on" || raw === "off") {
568
+ next.preserveReasoning = preserveReasoningFromOption(raw);
569
+ }
570
+ else {
571
+ throw new Error('preserveReasoning must be a boolean, null, or one of "default"/"on"/"off"');
572
+ }
573
+ }
574
+ takeSampling("temperature");
575
+ takeSampling("topP");
576
+ takeSampling("topK");
577
+ takeSampling("minP");
578
+ takeSampling("presencePenalty");
579
+ takeSampling("repeatPenalty");
329
580
  // Vision is only real when the model actually has a projector paired with it.
330
581
  if ("vision" in p) {
331
582
  if (typeof p.vision !== "boolean")
@@ -14,9 +14,9 @@ export declare function put(store: ProfilesStore, model: Model, profile: Profile
14
14
  /** Calibration is keyed by cache types, since those change bytes/token. */
15
15
  export declare function calibrationKey(profile: Profile): string;
16
16
  /**
17
- * True when this model has a stored calibration, but for different cache types
18
- * than the profile currently uses - i.e. the measurement is stale and the budget
19
- * has fallen back to the theoretical estimate. Drives the "recalibrate" prompt.
17
+ * True when this model has stored calibrations but none for the profile's
18
+ * current calibration key. Historical entries do not make an exact current
19
+ * measurement stale when the matching key is also present.
20
20
  */
21
21
  export declare function hasStaleCalibration(store: ProfilesStore, model: Model, profile: Profile): boolean;
22
22
  /**
@@ -30,5 +30,21 @@ export declare function hasStaleCalibration(store: ProfilesStore, model: Model,
30
30
  */
31
31
  export declare function geometryKey(model: Model, profile: Profile): string | null;
32
32
  export declare function getCalibration(store: ProfilesStore, model: Model, profile: Profile): Calibration | null;
33
+ /**
34
+ * Return the most recent direct calibration stored for a model, regardless of
35
+ * which profile key produced it. A stale measurement is still the stable value
36
+ * the UI and budget math should keep showing until a new calibration replaces
37
+ * it; callers use the calibration state to make its reduced trust visible.
38
+ */
39
+ export declare function getLastCalibration(store: ProfilesStore, model: Model): Calibration | null;
40
+ /**
41
+ * Resolve the calibration used by VRAM budgeting and launch arguments.
42
+ *
43
+ * An exact or inherited current-profile calibration remains preferred while the
44
+ * profile is current. Once an edit requires recalibration, retain the model's
45
+ * most recent direct measurement instead of jumping to the theoretical formula.
46
+ * The profile/calibration state still tells the caller that this value is stale.
47
+ */
48
+ export declare function getCalibrationForBudget(store: ProfilesStore, model: Model, profile: Profile): Calibration | null;
33
49
  export declare function putCalibration(store: ProfilesStore, model: Model, profile: Profile, measurement: Calibration): ProfilesStore;
34
50
  //# sourceMappingURL=profiles.d.ts.map
@@ -32,7 +32,20 @@ export function defaultProfile(model, defaults) {
32
32
  : Boolean(model?.mmprojPath),
33
33
  reasoningBudget: defaults?.reasoningBudget ?? 1536,
34
34
  reasoningBudgetMessage: DEFAULT_REASONING_MESSAGE,
35
+ // Null, not false: llama-server's own default is "whatever the template
36
+ // wants", and a profile that has never been touched must not override it.
37
+ // A template that declares a preservation kwarg still gets its own default
38
+ // through that path (see buildArgs).
39
+ preserveReasoning: model?.reasoningPreservation?.default ?? null,
40
+ // llama.cpp's sampler defaults; see the note on ProfileSchema.
41
+ temperature: 0.8,
42
+ topP: 0.95,
43
+ topK: 40,
44
+ minP: 0.05,
45
+ presencePenalty: 0,
46
+ repeatPenalty: 1,
35
47
  parallelSlots: defaults?.parallelSlots ?? 1, // one agent at a time: max context per request
48
+ cachedChats: 0, // llama.cpp's own --cache-ram default until the user sizes it
36
49
  batchSize: null,
37
50
  ubatchSize: null,
38
51
  extraArgs: [],
@@ -67,6 +80,9 @@ export function forModel(store, model, defaults) {
67
80
  return {
68
81
  ...base,
69
82
  ...stored,
83
+ // `preserveReasoning` was optional in the store so adding the Qwen default
84
+ // does not rewrite existing profiles as false merely because they predate it.
85
+ preserveReasoning: stored.preserveReasoning ?? base.preserveReasoning,
70
86
  // COMPAT(hostingProfileMode): added in v0.8.8, remove after 2027-02-12.
71
87
  // The first hosting-profile store only had an id, and a non-null id there
72
88
  // was an explicit custom choice. Such a profile parses as `inherit` (the
@@ -99,16 +115,16 @@ export function calibrationKey(profile) {
99
115
  : `${profile.cacheTypeK}:${profile.cacheTypeV}${multiplier}`;
100
116
  }
101
117
  /**
102
- * True when this model has a stored calibration, but for different cache types
103
- * than the profile currently uses - i.e. the measurement is stale and the budget
104
- * has fallen back to the theoretical estimate. Drives the "recalibrate" prompt.
118
+ * True when this model has stored calibrations but none for the profile's
119
+ * current calibration key. Historical entries do not make an exact current
120
+ * measurement stale when the matching key is also present.
105
121
  */
106
122
  export function hasStaleCalibration(store, model, profile) {
107
123
  const measured = store.calibrations?.[model.id];
108
124
  if (!measured)
109
125
  return false;
110
126
  const key = calibrationKey(profile);
111
- return Object.keys(measured).some((k) => k !== key);
127
+ return !Object.hasOwn(measured, key) && Object.keys(measured).length > 0;
112
128
  }
113
129
  /**
114
130
  * KV cost per token is a property of the attention geometry, not of the particular
@@ -151,6 +167,38 @@ export function getCalibration(store, model, profile) {
151
167
  inherited: true,
152
168
  };
153
169
  }
170
+ /**
171
+ * Return the most recent direct calibration stored for a model, regardless of
172
+ * which profile key produced it. A stale measurement is still the stable value
173
+ * the UI and budget math should keep showing until a new calibration replaces
174
+ * it; callers use the calibration state to make its reduced trust visible.
175
+ */
176
+ export function getLastCalibration(store, model) {
177
+ const entries = Object.values(store.calibrations?.[model.id] ?? {});
178
+ if (entries.length === 0)
179
+ return null;
180
+ return entries.reduce((latest, candidate) => {
181
+ const latestTime = latest.measuredAt ? Date.parse(latest.measuredAt) : Number.NEGATIVE_INFINITY;
182
+ const candidateTime = candidate.measuredAt
183
+ ? Date.parse(candidate.measuredAt)
184
+ : Number.NEGATIVE_INFINITY;
185
+ return candidateTime >= latestTime ? candidate : latest;
186
+ });
187
+ }
188
+ /**
189
+ * Resolve the calibration used by VRAM budgeting and launch arguments.
190
+ *
191
+ * An exact or inherited current-profile calibration remains preferred while the
192
+ * profile is current. Once an edit requires recalibration, retain the model's
193
+ * most recent direct measurement instead of jumping to the theoretical formula.
194
+ * The profile/calibration state still tells the caller that this value is stale.
195
+ */
196
+ export function getCalibrationForBudget(store, model, profile) {
197
+ const current = getCalibration(store, model, profile);
198
+ if (!profile.calibrationRequired && current)
199
+ return current;
200
+ return getLastCalibration(store, model) ?? current;
201
+ }
154
202
  export function putCalibration(store, model, profile, measurement) {
155
203
  if (!store.calibrations)
156
204
  store.calibrations = {};