@otto-code/brain 0.8.10 → 0.8.12

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 (59) hide show
  1. package/dist/commands/calibrate.js +9 -0
  2. package/dist/commands/catalog.d.ts +1 -0
  3. package/dist/commands/catalog.js +1 -0
  4. package/dist/commands/pull.d.ts +1 -0
  5. package/dist/commands/pull.js +12 -3
  6. package/dist/commands/search.d.ts +1 -0
  7. package/dist/commands/search.js +12 -2
  8. package/dist/config/index.d.ts +1 -1
  9. package/dist/config/index.js +1 -1
  10. package/dist/config/profile-edit.d.ts +88 -1
  11. package/dist/config/profile-edit.js +280 -29
  12. package/dist/config/profiles.js +16 -0
  13. package/dist/config/schema.d.ts +608 -0
  14. package/dist/config/schema.js +58 -0
  15. package/dist/config/store.js +7 -4
  16. package/dist/gguf.d.ts +7 -0
  17. package/dist/gguf.js +15 -2
  18. package/dist/models/download.d.ts +1 -1
  19. package/dist/models/download.js +2 -2
  20. package/dist/models/enrich.d.ts +6 -0
  21. package/dist/models/enrich.js +27 -1
  22. package/dist/models/index.d.ts +1 -1
  23. package/dist/models/index.js +4 -3
  24. package/dist/ops/calibrate.d.ts +38 -3
  25. package/dist/ops/calibrate.js +68 -19
  26. package/dist/ops/report.js +51 -1
  27. package/dist/ops/results.d.ts +57 -11
  28. package/dist/ops/results.js +75 -10
  29. package/dist/ops/sweep.d.ts +38 -1
  30. package/dist/ops/sweep.js +61 -10
  31. package/dist/runtime/args.d.ts +15 -2
  32. package/dist/runtime/args.js +60 -5
  33. package/dist/runtime/managed.js +2 -2
  34. package/dist/service/activity.d.ts +19 -0
  35. package/dist/service/activity.js +47 -4
  36. package/dist/service/host-api.d.ts +25 -4
  37. package/dist/service/host-api.js +82 -16
  38. package/dist/service/log-format.d.ts +18 -0
  39. package/dist/service/log-format.js +32 -0
  40. package/dist/service/router.d.ts +70 -2
  41. package/dist/service/router.js +219 -21
  42. package/dist/service/run-log.d.ts +6 -1
  43. package/dist/service/run-log.js +46 -4
  44. package/dist/service/scheduler.d.ts +227 -24
  45. package/dist/service/scheduler.js +395 -63
  46. package/dist/service/serve.d.ts +4 -0
  47. package/dist/service/serve.js +302 -117
  48. package/dist/service/status-events.d.ts +14 -1
  49. package/dist/service/status-events.js +111 -12
  50. package/dist/service/supervisor.d.ts +9 -7
  51. package/dist/service/supervisor.js +37 -12
  52. package/dist/sysmon.d.ts +15 -0
  53. package/dist/sysmon.js +56 -9
  54. package/dist/tui/app.d.ts +8 -2
  55. package/dist/tui/app.js +65 -17
  56. package/dist/types.d.ts +18 -0
  57. package/dist/vram.d.ts +37 -0
  58. package/dist/vram.js +57 -18
  59. package/package.json +1 -1
@@ -6,6 +6,25 @@
6
6
  */
7
7
  import { z } from "zod";
8
8
  export const DEFAULT_REASONING_MESSAGE = "Enough analysis. Write the complete answer now.";
9
+ const TemplateArgumentSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/u);
10
+ /**
11
+ * Model-native names for reasoning controls exposed by its chat template.
12
+ * These are catalog metadata, not caller-provided flags: the host owns the
13
+ * translation from the OpenAI-compatible request into template arguments.
14
+ */
15
+ export const ReasoningTemplateSchema = z
16
+ .object({
17
+ enableThinkingArgument: TemplateArgumentSchema,
18
+ effortArgument: TemplateArgumentSchema,
19
+ })
20
+ .strict();
21
+ /** A template's native spelling for Brain's provider-neutral preservation setting. */
22
+ export const ReasoningPreservationSchema = z
23
+ .object({
24
+ templateArgument: TemplateArgumentSchema,
25
+ default: z.boolean().optional(),
26
+ })
27
+ .strict();
9
28
  // --------------------------------------------------------------- profiles store
10
29
  export const ProfileSchema = z
11
30
  .object({
@@ -24,7 +43,37 @@ export const ProfileSchema = z
24
43
  vision: z.boolean().default(false),
25
44
  reasoningBudget: z.number().default(1536),
26
45
  reasoningBudgetMessage: z.string().default(DEFAULT_REASONING_MESSAGE),
46
+ /**
47
+ * Tri-state, matching llama-server's own `--reasoning-preserve` /
48
+ * `--no-reasoning-preserve` / unset: `true` keeps the reasoning trace in the
49
+ * whole history, `false` forces it to the last assistant message only, and
50
+ * null/undefined leaves the template's own default alone. Null is the
51
+ * default precisely so an untouched profile emits no flag and launches
52
+ * exactly as it did before this field existed.
53
+ */
54
+ preserveReasoning: z.boolean().nullable().optional(),
55
+ // ------------------------------------------------------------- sampling
56
+ // Defaults are llama.cpp's own, read from `llama-server --help` on the
57
+ // pinned build (b10433) so an untouched profile runs identically to one
58
+ // that never emitted these flags. They are stored and emitted explicitly
59
+ // rather than left implicit because a sampler the user can see is one they
60
+ // can reason about; re-check them when DEFAULT_LLAMA_BUILD moves.
61
+ temperature: z.number().default(0.8),
62
+ topP: z.number().default(0.95),
63
+ topK: z.number().default(40),
64
+ minP: z.number().default(0.05),
65
+ presencePenalty: z.number().default(0),
66
+ repeatPenalty: z.number().default(1),
27
67
  parallelSlots: z.number().default(1),
68
+ /**
69
+ * How many chats' KV state llama-server may park in system RAM when they
70
+ * lose their GPU slot, so returning to one costs a bulk copy instead of a
71
+ * full re-prefill. Stored as a count, not a size: the byte budget it turns
72
+ * into (`--cache-ram`) depends on the measured KV bytes/token and the
73
+ * per-slot context, both of which move when other fields are edited.
74
+ * 0 means "leave llama.cpp's own default alone" - the flag is not emitted.
75
+ */
76
+ cachedChats: z.number().default(0),
28
77
  /** RoPE extension factor; 1 keeps the GGUF-native context window. */
29
78
  contextMultiplier: z.number().default(1),
30
79
  /** Cleared only by a successful calibration of this saved model profile. */
@@ -171,6 +220,8 @@ export const RuntimeConfigSchema = z
171
220
  // auto = prefer a managed runtime, fall back to LM Studio discovery.
172
221
  source: z.enum(["auto", "managed", "lmstudio"]).default("auto"),
173
222
  path: z.string().nullable().default(null),
223
+ /** llama.cpp's `--log-verbosity`: 0 generic output through 5 debug. */
224
+ logVerbosity: z.number().int().min(0).max(5).default(3),
174
225
  })
175
226
  .strict();
176
227
  export const ProfileDefaultsSchema = z
@@ -224,6 +275,8 @@ export const CatalogModelSchema = z
224
275
  name: z.string(),
225
276
  /** Stable UI family identity. Otto clients resolve this to a monochrome glyph. */
226
277
  family: z.string().optional(),
278
+ /** Otto-curated favorite shown as a gold premium badge in the Brain Library. */
279
+ favorite: z.boolean().default(false),
227
280
  publisher: z.string().optional(),
228
281
  hfRepo: z.string(),
229
282
  quant: z.string(),
@@ -234,6 +287,9 @@ export const CatalogModelSchema = z
234
287
  vision: z.boolean().optional(),
235
288
  thinking: z.boolean().optional(),
236
289
  reasoningEfforts: z.array(z.string()).optional(),
290
+ reasoningEffortDefault: z.string().optional(),
291
+ reasoningTemplate: ReasoningTemplateSchema.optional(),
292
+ reasoningPreservation: ReasoningPreservationSchema.optional(),
237
293
  contextMax: z.number().optional(),
238
294
  useCases: z.array(z.string()).optional(),
239
295
  tier: z.string().optional(),
@@ -265,6 +321,8 @@ export const CatalogSchema = z
265
321
  note: z.string().optional(),
266
322
  vramBudgetBytes: z.number().optional(),
267
323
  systemRamBytes: z.number().optional(),
324
+ /** Retired curated ids that no longer belong to any canonical catalog entry. */
325
+ retiredModelIds: z.array(z.string()).default([]),
268
326
  models: z.array(CatalogModelSchema).default([]),
269
327
  })
270
328
  .passthrough();
@@ -65,10 +65,13 @@ export function loadCatalog(paths = resolveBrainPaths()) {
65
65
  if (!legacy)
66
66
  return current;
67
67
  const seedIds = new Set(legacy.models.map((model) => model.id));
68
- // A source repository can change while the underlying curated model stays
69
- // the same. Those retired ids are product-owned too: drop them rather than
70
- // displaying an obsolete duplicate beside its canonical replacement.
71
- const retiredSeedIds = new Set(legacy.models.flatMap((model) => model.replaces ?? []));
68
+ // Curated models can be replaced or intentionally removed from the
69
+ // shortlist. Both kinds of retired ids stay product-owned so they do not
70
+ // survive a seed refresh as accidental "user-added" catalog rows.
71
+ const retiredSeedIds = new Set([
72
+ ...legacy.retiredModelIds,
73
+ ...legacy.models.flatMap((model) => model.replaces ?? []),
74
+ ]);
72
75
  const userModels = current.models.filter((model) => !seedIds.has(model.id) && !retiredSeedIds.has(model.id));
73
76
  const models = [...legacy.models, ...userModels];
74
77
  const changed = JSON.stringify(models) !== JSON.stringify(current.models);
package/dist/gguf.d.ts CHANGED
@@ -53,7 +53,14 @@ interface GgufSummary {
53
53
  isProjector: boolean;
54
54
  /** Chat template exposes a thinking/reasoning channel. */
55
55
  reasoning: boolean;
56
+ /** Native preservation argument, normalized by model enrichment when present. */
57
+ reasoningPreservationArgument?: "preserve_thinking" | "preserve_reasoning";
56
58
  }
59
+ /** Read model-specific template spellings without leaking them into the UI contract. */
60
+ export declare function detectTemplateReasoningCapabilities(chatTemplate: string): {
61
+ reasoning: boolean;
62
+ reasoningPreservationArgument?: "preserve_thinking" | "preserve_reasoning";
63
+ };
57
64
  /**
58
65
  * Pull out the fields that matter for hosting decisions.
59
66
  */
package/dist/gguf.js CHANGED
@@ -176,6 +176,19 @@ export function readMetadata(file) {
176
176
  }
177
177
  throw new Error(`could not read GGUF header from ${file}`);
178
178
  }
179
+ /** Read model-specific template spellings without leaking them into the UI contract. */
180
+ export function detectTemplateReasoningCapabilities(chatTemplate) {
181
+ const reasoningPreservationArgument = /\bpreserve_thinking\b/iu.test(chatTemplate)
182
+ ? "preserve_thinking"
183
+ : /\bpreserve_reasoning\b/iu.test(chatTemplate)
184
+ ? "preserve_reasoning"
185
+ : undefined;
186
+ return {
187
+ reasoning: Boolean(reasoningPreservationArgument ||
188
+ /<think>|<\/think>|reasoning_content|enable_thinking/iu.test(chatTemplate)),
189
+ ...(reasoningPreservationArgument ? { reasoningPreservationArgument } : {}),
190
+ };
191
+ }
179
192
  /**
180
193
  * Pull out the fields that matter for hosting decisions.
181
194
  */
@@ -202,7 +215,7 @@ export function summarize(file) {
202
215
  const chatTemplate = typeof meta["tokenizer.chat_template"] === "string"
203
216
  ? meta["tokenizer.chat_template"]
204
217
  : "";
205
- const reasoning = /<think>|<\/think>|reasoning_content|enable_thinking/i.test(chatTemplate);
218
+ const reasoningCapabilities = detectTemplateReasoningCapabilities(chatTemplate);
206
219
  return {
207
220
  file,
208
221
  fileSize,
@@ -226,7 +239,7 @@ export function summarize(file) {
226
239
  expertCount: (get("expert_count") ?? null),
227
240
  eosTokenId: (meta["tokenizer.ggml.eos_token_id"] ?? null),
228
241
  isProjector: Boolean(meta["clip.has_vision_encoder"] || arch === "clip"),
229
- reasoning,
242
+ ...reasoningCapabilities,
230
243
  };
231
244
  }
232
245
  //# sourceMappingURL=gguf.js.map
@@ -13,7 +13,7 @@ export interface PullOptions {
13
13
  }
14
14
  /** Exact, manifest-driven files for a bundle selection. No quant discovery is
15
15
  * involved, so a component pull can never select an arbitrary projector. */
16
- export declare function bundleDownloadPlan(model: CatalogModel, componentIds?: string[], primaryFiles?: string[], primaryBytes?: number): {
16
+ export declare function bundleDownloadPlan(model: CatalogModel, componentIds?: string[], primaryFiles?: string[], primaryBytes?: number, includeRequired?: boolean): {
17
17
  repo: string;
18
18
  files: string[];
19
19
  totalBytes: number | null;
@@ -195,13 +195,13 @@ async function streamRepoFile(url, destPath, label, token, onProgress, received)
195
195
  }
196
196
  /** Exact, manifest-driven files for a bundle selection. No quant discovery is
197
197
  * involved, so a component pull can never select an arbitrary projector. */
198
- export function bundleDownloadPlan(model, componentIds = [], primaryFiles, primaryBytes) {
198
+ export function bundleDownloadPlan(model, componentIds = [], primaryFiles, primaryBytes, includeRequired = true) {
199
199
  const selected = new Set(componentIds);
200
200
  const known = new Set((model.components ?? []).map((component) => component.id));
201
201
  const unknown = componentIds.filter((id) => !known.has(id));
202
202
  if (unknown.length)
203
203
  throw new Error(`unknown bundle components: ${unknown.join(", ")}`);
204
- const components = (model.components ?? []).filter((component) => component.required || selected.has(component.id));
204
+ const components = (model.components ?? []).filter((component) => (includeRequired && component.required) || selected.has(component.id));
205
205
  const foreign = components.find((component) => (component.hfRepo ?? model.hfRepo) !== model.hfRepo);
206
206
  if (foreign)
207
207
  throw new Error(`component ${foreign.id} uses a separate repository and needs its own plan`);
@@ -14,6 +14,12 @@ export declare function familyFromGgufMetadata(model: Model): string | undefined
14
14
  * specific (longest) hfRepo.
15
15
  */
16
16
  export declare function matchCatalogEntry(model: Model, catalog: Catalog): CatalogModel | null;
17
+ /**
18
+ * Bundle components are supporting artifacts, not independently selectable
19
+ * models. The disk scanner sees every GGUF, so remove an exact manifest match
20
+ * before the host exposes the inventory to the CLI or desktop client.
21
+ */
22
+ export declare function excludeCatalogComponentArtifacts(models: Model[], catalog: Catalog): Model[];
17
23
  /**
18
24
  * Return copies of the models with catalog coding metadata attached where a match
19
25
  * exists; models with no match (and every model when the catalog is empty) pass
@@ -112,6 +112,15 @@ export function matchCatalogEntry(model, catalog) {
112
112
  }
113
113
  return best;
114
114
  }
115
+ /**
116
+ * Bundle components are supporting artifacts, not independently selectable
117
+ * models. The disk scanner sees every GGUF, so remove an exact manifest match
118
+ * before the host exposes the inventory to the CLI or desktop client.
119
+ */
120
+ export function excludeCatalogComponentArtifacts(models, catalog) {
121
+ const componentIds = new Set(catalog.models.flatMap((entry) => (entry.components ?? []).map((component) => normalizePath(`${component.hfRepo ?? entry.hfRepo}/${component.file}`))));
122
+ return models.filter((model) => !componentIds.has(normalizePath(model.id)));
123
+ }
115
124
  /**
116
125
  * Return copies of the models with catalog coding metadata attached where a match
117
126
  * exists; models with no match (and every model when the catalog is empty) pass
@@ -123,7 +132,14 @@ export function enrichWithCatalog(models, catalog) {
123
132
  if (!entry) {
124
133
  const enriched = enrichDiscoveredProjector(model);
125
134
  const family = familyFromGgufMetadata(enriched);
126
- return family ? { ...enriched, family } : enriched;
135
+ const reasoningPreservation = detectedReasoningPreservation(enriched);
136
+ if (!family && !reasoningPreservation)
137
+ return enriched;
138
+ return {
139
+ ...enriched,
140
+ ...(family ? { family } : {}),
141
+ ...(reasoningPreservation ? { reasoningPreservation } : {}),
142
+ };
127
143
  }
128
144
  const components = resolveComponents(model, entry);
129
145
  const projector = components?.find((component) => component.role === "vision_projector");
@@ -141,10 +157,20 @@ export function enrichWithCatalog(models, catalog) {
141
157
  tier: entry.tier,
142
158
  thinking: entry.thinking,
143
159
  reasoningEfforts: entry.reasoningEfforts,
160
+ reasoningEffortDefault: entry.reasoningEffortDefault,
161
+ reasoningTemplate: entry.reasoningTemplate,
162
+ reasoningPreservation: entry.reasoningPreservation ?? detectedReasoningPreservation(model),
144
163
  contextMax: entry.contextMax,
145
164
  };
146
165
  });
147
166
  }
167
+ /** Map the two known template spellings to one model capability. */
168
+ function detectedReasoningPreservation(model) {
169
+ const templateArgument = model.metadata?.reasoningPreservationArgument;
170
+ return templateArgument === "preserve_thinking" || templateArgument === "preserve_reasoning"
171
+ ? { templateArgument }
172
+ : undefined;
173
+ }
148
174
  /** Promote a scanner-paired projector in an arbitrary Hugging Face repository
149
175
  * into the same component inventory shape used by curated bundles. */
150
176
  function enrichDiscoveredProjector(model) {
@@ -4,7 +4,7 @@ export * from "./scan.js";
4
4
  export { pickModel, pickAutoModel } from "./pick.js";
5
5
  export { resolveModelsDirs, managedModelsDir, type ModelsDir } from "./dirs.js";
6
6
  export { pullModel, bundleDownloadPlan, downloadRepoFiles, type PullOptions, type PullProgress, type DownloadFilesOptions, } from "./download.js";
7
- export { matchCatalogEntry, enrichWithCatalog } from "./enrich.js";
7
+ export { matchCatalogEntry, enrichWithCatalog, excludeCatalogComponentArtifacts, } from "./enrich.js";
8
8
  export { resolveHfToken, listRepoQuants, searchModels, clearCardSummaryCache, repoOfModel, quantRank, type QuantOption, type RepoQuants, type ModelSearchResult, } from "./hf.js";
9
9
  export { diskUsage, totalModelBytes, planDelete, deleteModelFiles, type DiskUsage, type DeletePlan, } from "./manage.js";
10
10
  export interface ScanModelsOptions {
@@ -3,14 +3,14 @@ import { resolveBrainPaths } from "../config/paths.js";
3
3
  import { CatalogSchema } from "../config/schema.js";
4
4
  import { loadCatalog } from "../config/store.js";
5
5
  import { resolveModelsDirs } from "./dirs.js";
6
- import { enrichWithCatalog } from "./enrich.js";
6
+ import { enrichWithCatalog, excludeCatalogComponentArtifacts } from "./enrich.js";
7
7
  import { loadRenameMap } from "./rename-map.js";
8
8
  import { scan } from "./scan.js";
9
9
  export * from "./scan.js";
10
10
  export { pickModel, pickAutoModel } from "./pick.js";
11
11
  export { resolveModelsDirs, managedModelsDir } from "./dirs.js";
12
12
  export { pullModel, bundleDownloadPlan, downloadRepoFiles, } from "./download.js";
13
- export { matchCatalogEntry, enrichWithCatalog } from "./enrich.js";
13
+ export { matchCatalogEntry, enrichWithCatalog, excludeCatalogComponentArtifacts, } from "./enrich.js";
14
14
  export { resolveHfToken, listRepoQuants, searchModels, clearCardSummaryCache, repoOfModel, quantRank, } from "./hf.js";
15
15
  export { diskUsage, totalModelBytes, planDelete, deleteModelFiles, } from "./manage.js";
16
16
  /** Scan every configured models directory (managed ∪ LM Studio), de-duplicated. */
@@ -33,7 +33,8 @@ export function scanModels(config, env = process.env, options = {}) {
33
33
  all.push(model);
34
34
  }
35
35
  }
36
- const enriched = enrichWithCatalog(all, loadCatalogSafe(env));
36
+ const catalog = loadCatalogSafe(env);
37
+ const enriched = enrichWithCatalog(excludeCatalogComponentArtifacts(all, catalog), catalog);
37
38
  const renameMap = loadRenameMap(resolveBrainPaths(env));
38
39
  for (const model of enriched) {
39
40
  if (renameMap[model.id]) {
@@ -1,6 +1,6 @@
1
1
  import { Supervisor } from "../service/supervisor.js";
2
2
  import type { Model, Runtime } from "../types.js";
3
- import type { Profile } from "../config/schema.js";
3
+ import type { Calibration, Profile } from "../config/schema.js";
4
4
  /**
5
5
  * Measure a model's real KV-cache cost per token.
6
6
  *
@@ -10,8 +10,15 @@ import type { Profile } from "../config/schema.js";
10
10
  * slope: bytes/token = (vram_b - vram_a) / (ctx_b - ctx_a). Everything that
11
11
  * does not scale with context (weights, projector, CUDA context, compute
12
12
  * buffers) cancels out of the difference.
13
+ *
14
+ * Two invariants keep that slope honest. The high sample is the profile's
15
+ * configured context (the depth the user will actually serve at) capped by
16
+ * what fits in VRAM - never the raw native × multiplier ceiling, which on a
17
+ * tight card loads a cache that spills to system RAM and biases the slope low.
18
+ * A sample whose load split the KV cache to CPU is unusable: the GPU delta
19
+ * misses the spilled share, the bytes/token comes out understated, and the
20
+ * budget then declares a context "fits" that actually runs on a RAM cache.
13
21
  */
14
- export declare const DEFAULT_SAMPLES: number[];
15
22
  /** A single context-size measurement collected during calibration. */
16
23
  export interface CalibrationSample {
17
24
  contextSize: number;
@@ -26,14 +33,42 @@ export interface CalibrateProgress {
26
33
  deltaBytes?: number;
27
34
  error?: string;
28
35
  }
36
+ /**
37
+ * Detect that a load split its KV cache onto system RAM.
38
+ *
39
+ * `usedBytes()` reads nvidia-smi, which only sees the GPU share of the cache.
40
+ * When llama-server cannot hold the whole allocation in VRAM it does not fail
41
+ * the load - it offloads the overflow to CPU and the load "succeeds" at a
42
+ * fraction of the VRAM the context should cost. The startup banner is the
43
+ * only place the split is stated.
44
+ */
45
+ export declare function kvSpilledToCpu(logLines: string[]): boolean;
46
+ /**
47
+ * The largest context size the static VRAM budget allows, or null when the
48
+ * cost per token is unknown and the budget cannot answer. Used to keep the
49
+ * calibration samples inside the region where the GPU sees the whole cache.
50
+ */
51
+ export declare function maxContextForCalibration(model: Model, profile: Profile, calibration: Calibration | null, totalVramBytes: number): number | null;
29
52
  export interface CalibrateOptions {
30
53
  runtime: Runtime;
31
54
  model: Model;
32
55
  profile: Profile;
56
+ /** Explicit context sizes to measure. Replaces the default strategy entirely. */
33
57
  samples?: number[];
58
+ /**
59
+ * The calibration being replaced, if one exists. The best prior on bytes/token
60
+ * for this exact profile shape, so the sample cap can use the measured figure
61
+ * instead of the (over-estimating) theoretical one.
62
+ */
63
+ priorCalibration?: Calibration | null;
34
64
  internalPort?: number;
35
65
  /** Reuse the host's resident supervisor instead of creating a sidecar server. */
36
66
  supervisor?: Supervisor;
67
+ /**
68
+ * Pause after stopping each sample so the driver releases its allocation
69
+ * before the next load. Tests run with zero.
70
+ */
71
+ releaseDelayMs?: number;
37
72
  onProgress?: (event: CalibrateProgress) => void;
38
73
  }
39
74
  /** The measured KV-cache profile calibration produces. */
@@ -48,5 +83,5 @@ export interface CalibrationMeasurement {
48
83
  vision: boolean;
49
84
  measuredAt: string;
50
85
  }
51
- export declare function calibrate({ runtime, model, profile, samples, internalPort, supervisor: optionsSupervisor, onProgress, }: CalibrateOptions): Promise<CalibrationMeasurement>;
86
+ export declare function calibrate({ runtime, model, profile, samples, priorCalibration, internalPort, supervisor: optionsSupervisor, releaseDelayMs, onProgress, }: CalibrateOptions): Promise<CalibrationMeasurement>;
52
87
  //# sourceMappingURL=calibrate.d.ts.map
@@ -1,24 +1,67 @@
1
- import { usedBytes } from "../gpu.js";
1
+ import { query, usedBytes } from "../gpu.js";
2
2
  import * as vram from "../vram.js";
3
3
  import { DEFAULT_INTERNAL_PORT, Supervisor } from "../service/supervisor.js";
4
4
  /**
5
- * Measure a model's real KV-cache cost per token.
5
+ * Detect that a load split its KV cache onto system RAM.
6
6
  *
7
- * The theoretical formula (layers x kv_heads x dims x bytes) overestimates
8
- * badly on architectures that only keep a full cache on some layers - by ~4x
9
- * on `qwen35`. So we load the model twice at two context sizes and take the
10
- * slope: bytes/token = (vram_b - vram_a) / (ctx_b - ctx_a). Everything that
11
- * does not scale with context (weights, projector, CUDA context, compute
12
- * buffers) cancels out of the difference.
7
+ * `usedBytes()` reads nvidia-smi, which only sees the GPU share of the cache.
8
+ * When llama-server cannot hold the whole allocation in VRAM it does not fail
9
+ * the load - it offloads the overflow to CPU and the load "succeeds" at a
10
+ * fraction of the VRAM the context should cost. The startup banner is the
11
+ * only place the split is stated.
13
12
  */
14
- export const DEFAULT_SAMPLES = [8192, 65536];
15
- export async function calibrate({ runtime, model, profile, samples, internalPort = DEFAULT_INTERNAL_PORT + 1, supervisor: optionsSupervisor, onProgress = () => { }, }) {
13
+ export function kvSpilledToCpu(logLines) {
14
+ return logLines.some((line) => /KV .*split|CPU buffer size|KV cache.*offload|offloading \d+ layers? to cpu/i.test(line));
15
+ }
16
+ /**
17
+ * The largest context size the static VRAM budget allows, or null when the
18
+ * cost per token is unknown and the budget cannot answer. Used to keep the
19
+ * calibration samples inside the region where the GPU sees the whole cache.
20
+ */
21
+ export function maxContextForCalibration(model, profile, calibration, totalVramBytes) {
22
+ const max = vram.maxContextThatFits({
23
+ model,
24
+ profile,
25
+ calibration,
26
+ totalVramBytes,
27
+ });
28
+ return max && max >= 4096 ? max : null;
29
+ }
30
+ export async function calibrate({ runtime, model, profile, samples, priorCalibration = null, internalPort = DEFAULT_INTERNAL_PORT + 1, supervisor: optionsSupervisor, releaseDelayMs = 3000, onProgress = () => { }, }) {
16
31
  const nativeContext = model.metadata?.contextLength ?? null;
17
- const effectiveSamples = samples ??
18
- (nativeContext ? [8192, nativeContext * profile.contextMultiplier] : DEFAULT_SAMPLES);
32
+ const nativeCeiling = nativeContext ? nativeContext * profile.contextMultiplier : null;
33
+ const gpu = (await query());
34
+ // The high sample is the depth the profile will actually serve at - its
35
+ // configured context, clamped to the native × multiplier ceiling - not the
36
+ // raw ceiling itself. On a card where that depth does not fit, the static
37
+ // budget caps it at the largest context that does. Measuring at a context
38
+ // whose KV spills to CPU understates bytes/token and poisons the budget.
39
+ const configured = Math.max(4096, profile.contextSize);
40
+ let high = configured;
41
+ if (nativeCeiling !== null && high > nativeCeiling)
42
+ high = nativeCeiling;
43
+ if (gpu !== null) {
44
+ const maxFits = maxContextForCalibration(model, profile, priorCalibration, gpu.totalBytes);
45
+ if (maxFits !== null && high > maxFits) {
46
+ high = maxFits;
47
+ onProgress({
48
+ phase: "skip",
49
+ contextSize: configured,
50
+ reason: `configured context exceeds what fits in VRAM; measuring at ${high.toLocaleString()}`,
51
+ });
52
+ }
53
+ }
54
+ // The low sample is a fixed small context far enough below the serving depth
55
+ // that the KV delta dominates the fixed terms (weights, CUDA context). The
56
+ // high sample never drops below it: a VRAM cap that pushed the two together
57
+ // would make the slope a difference of near-equal numbers, and at high == low
58
+ // a division by zero.
59
+ const lowContext = 4096;
60
+ const highContext = Math.max(high, lowContext * 2);
61
+ const effectiveSamples = samples ?? [lowContext, highContext];
19
62
  if (effectiveSamples.length < 2)
20
63
  throw new Error("calibration needs at least two context sizes");
21
- const native = (nativeContext || Math.max(...effectiveSamples)) * profile.contextMultiplier;
64
+ const native = nativeCeiling ?? Math.max(...effectiveSamples);
22
65
  const points = [];
23
66
  for (const contextSize of effectiveSamples) {
24
67
  if (contextSize > native) {
@@ -29,7 +72,12 @@ export async function calibrate({ runtime, model, profile, samples, internalPort
29
72
  onProgress({ phase: "loading", contextSize });
30
73
  const baseline = await usedBytes();
31
74
  try {
32
- await supervisor.start(model, { ...profile, contextSize }, { preserveLogs: Boolean(optionsSupervisor) });
75
+ await supervisor.start(model, { ...profile, contextSize });
76
+ if (kvSpilledToCpu(supervisor.logLines)) {
77
+ const reason = `KV cache split to CPU at ${contextSize.toLocaleString()} context`;
78
+ onProgress({ phase: "failed", contextSize, reason });
79
+ throw new Error(`${reason} - the context does not fit in VRAM. Lower the context or use a smaller KV cache type before calibrating.`);
80
+ }
33
81
  const used = supervisor.vramAtReadyBytes ?? (await usedBytes());
34
82
  const delta = Number(used) - Number(supervisor.vramBaselineBytes ?? baseline);
35
83
  points.push({ contextSize, deltaBytes: delta, loadSeconds: supervisor.loadSeconds });
@@ -46,21 +94,22 @@ export async function calibrate({ runtime, model, profile, samples, internalPort
46
94
  finally {
47
95
  await supervisor.stop();
48
96
  // Let the driver actually release the allocation before the next sample.
49
- await new Promise((resolve) => setTimeout(resolve, 3000));
97
+ if (releaseDelayMs > 0)
98
+ await new Promise((resolve) => setTimeout(resolve, releaseDelayMs));
50
99
  }
51
100
  }
52
101
  if (points.length < 2) {
53
102
  throw new Error("calibration produced fewer than two usable samples");
54
103
  }
55
104
  points.sort((a, b) => a.contextSize - b.contextSize);
56
- const low = points[0];
57
- const high = points[points.length - 1];
58
- const kvBytesPerToken = (high.deltaBytes - low.deltaBytes) / (high.contextSize - low.contextSize);
105
+ const lowPoint = points[0];
106
+ const highPoint = points[points.length - 1];
107
+ const kvBytesPerToken = (highPoint.deltaBytes - lowPoint.deltaBytes) / (highPoint.contextSize - lowPoint.contextSize);
59
108
  if (!(kvBytesPerToken > 0)) {
60
109
  throw new Error("measured a non-positive bytes-per-token; results are unusable");
61
110
  }
62
111
  const fixedBytes = model.sizeBytes + (profile.vision && model.mmprojPath ? model.mmprojBytes : 0);
63
- const baseOverheadBytes = Math.max(0, low.deltaBytes - fixedBytes - kvBytesPerToken * low.contextSize);
112
+ const baseOverheadBytes = Math.max(0, lowPoint.deltaBytes - fixedBytes - kvBytesPerToken * lowPoint.contextSize);
64
113
  const theoretical = vram.theoreticalKvBytesPerToken(model.metadata, profile.cacheTypeK, profile.cacheTypeV);
65
114
  return {
66
115
  kvBytesPerToken,
@@ -28,6 +28,54 @@ function escapeHtml(text) {
28
28
  function formatGiB(bytes) {
29
29
  return bytes ? `${(bytes / 1024 ** 3).toFixed(1)} GB` : "-";
30
30
  }
31
+ // The sampler defaults come from `configKey`'s own constant: the report and
32
+ // the grouping key must agree on what counts as "the engine default", or the
33
+ // report would print settings the key says were never changed.
34
+ const SAMPLER_DEFAULTS = results.ENGINE_SAMPLER_DEFAULTS;
35
+ /**
36
+ * The schema-3 profile settings that changed what a run was measured with,
37
+ * as a short human line. Returns "" when nothing deviates from the engine
38
+ * defaults, so an untouched run prints exactly what it always did. Older
39
+ * records that predate these fields store `null` for every one, which also
40
+ * collapses to "" - the report never guesses a value it was not given.
41
+ */
42
+ function formatProfileExtras(record) {
43
+ const p = record.profile;
44
+ if (!p)
45
+ return "";
46
+ const parts = [];
47
+ if (p.contextMultiplier !== null && p.contextMultiplier > 1) {
48
+ parts.push(`x${p.contextMultiplier} ctx`);
49
+ }
50
+ if (p.cachedChats !== null && p.cachedChats > 0) {
51
+ parts.push(`${p.cachedChats} cached`);
52
+ }
53
+ if (p.preserveReasoning === true)
54
+ parts.push("reasoning preserved");
55
+ else if (p.preserveReasoning === false)
56
+ parts.push("reasoning trimmed");
57
+ // Each entry pairs the display name with the `SAMPLER_DEFAULTS` key the
58
+ // default is looked up by - the two deliberately differ (see `configKey`).
59
+ const sampler = [
60
+ ["temp", p.temperature, "temperature"],
61
+ ["topP", p.topP, "topP"],
62
+ ["topK", p.topK, "topK"],
63
+ ["minP", p.minP, "minP"],
64
+ ["pres", p.presencePenalty, "presencePenalty"],
65
+ ["rep", p.repeatPenalty, "repeatPenalty"],
66
+ ];
67
+ // `typeof` rather than a null check: a record from before the sampler was
68
+ // stored carries `undefined` for every one of these, and that must read as
69
+ // "not recorded", not as a deviation.
70
+ const deviated = sampler
71
+ .filter(([, value, key]) => typeof value === "number" && value !== SAMPLER_DEFAULTS[key])
72
+ .map(([name, value]) => `${name} ${value}`);
73
+ if (deviated.length > 0)
74
+ parts.push(deviated.join(", "));
75
+ if (p.hostingProfileId)
76
+ parts.push("hosting profile");
77
+ return parts.join(" · ");
78
+ }
31
79
  /**
32
80
  * Per-model score card: each task labelled directly with its weight, then the
33
81
  * weighted Overall on its own row so the headline number reads as the sum of its
@@ -61,6 +109,7 @@ function groupedBars(records, columns, weightOf) {
61
109
  record.model.quant,
62
110
  `ctx ${(record.profile?.contextSize || 0).toLocaleString()}`,
63
111
  `rb ${record.profile?.reasoningBudget}`,
112
+ formatProfileExtras(record),
64
113
  ]
65
114
  .filter(Boolean)
66
115
  .join(" · ");
@@ -267,6 +316,7 @@ function build(records, allRuns = records) {
267
316
  return (`<tr><td>${escapeHtml(r.model.displayName)}</td><td>${escapeHtml(r.model.quant || "-")}</td>` +
268
317
  `<td class="num">${(r.profile?.contextSize || 0).toLocaleString()}</td>` +
269
318
  `<td class="num">${r.profile?.reasoningBudget ?? "-"}</td>` +
319
+ `<td class="muted">${escapeHtml(formatProfileExtras(r) || "-")}</td>` +
270
320
  `${cells}<td class="num strong">${(r.overall * 100).toFixed(0)}%</td>` +
271
321
  `<td class="num">${formatGiB(r.vramBytes)}</td>` +
272
322
  `<td class="muted">${escapeHtml(r.ranAt.slice(0, 16).replace("T", " "))}</td></tr>`);
@@ -434,7 +484,7 @@ function build(records, allRuns = records) {
434
484
  <h2>All runs</h2>
435
485
  <div class="scroll">
436
486
  <table>
437
- <thead><tr><th>Model</th><th>Quant</th><th class="num">Context</th><th class="num">Reasoning</th>
487
+ <thead><tr><th>Model</th><th>Quant</th><th class="num">Context</th><th class="num">Reasoning</th><th>Settings</th>
438
488
  ${columns.map((c) => `<th class="num">${escapeHtml(c.category)}<span class="wt">×${weightOf.get(c.id) ?? "?"}</span></th>`).join("")}
439
489
  <th class="num">Overall<span class="wt">wtd</span></th><th class="num">VRAM</th><th>Run at</th></tr></thead>
440
490
  <tbody>${tableRows}</tbody>