@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
@@ -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
@@ -200,9 +251,13 @@ export const BrainConfigSchema = z
200
251
  // persisted fallback so users can set it once (config set hfToken <token>).
201
252
  hfToken: z.string().nullable().default(null),
202
253
  defaultModel: z.string().nullable().default(null),
203
- // Pin the host to a single model: serve only the default/resident model and
204
- // refuse completion requests that name a different one, instead of queuing a
205
- // switch. For hosts that load one model and must not thrash between clients.
254
+ /** Maximum independently hosted llama-server model processes. */
255
+ maxLoadedModels: z.number().int().min(1).max(16).default(1),
256
+ /** Stable ids selected for residency while model locking is enabled. */
257
+ lockedModels: z.array(z.string()).default([]),
258
+ // Pin the host to the selected resident set and refuse completion requests
259
+ // that name a different model. With a one-process host this preserves the
260
+ // original single-model lock behavior.
206
261
  lockModel: z.boolean().default(false),
207
262
  // Sharing/control gates (off by default - a brain is not remotely
208
263
  // controllable until its owner opts in). `allowRemoteConfig`: a client with
@@ -224,6 +279,8 @@ export const CatalogModelSchema = z
224
279
  name: z.string(),
225
280
  /** Stable UI family identity. Otto clients resolve this to a monochrome glyph. */
226
281
  family: z.string().optional(),
282
+ /** Otto-curated favorite shown as a gold premium badge in the Brain Library. */
283
+ favorite: z.boolean().default(false),
227
284
  publisher: z.string().optional(),
228
285
  hfRepo: z.string(),
229
286
  quant: z.string(),
@@ -234,6 +291,9 @@ export const CatalogModelSchema = z
234
291
  vision: z.boolean().optional(),
235
292
  thinking: z.boolean().optional(),
236
293
  reasoningEfforts: z.array(z.string()).optional(),
294
+ reasoningEffortDefault: z.string().optional(),
295
+ reasoningTemplate: ReasoningTemplateSchema.optional(),
296
+ reasoningPreservation: ReasoningPreservationSchema.optional(),
237
297
  contextMax: z.number().optional(),
238
298
  useCases: z.array(z.string()).optional(),
239
299
  tier: z.string().optional(),
@@ -265,6 +325,8 @@ export const CatalogSchema = z
265
325
  note: z.string().optional(),
266
326
  vramBudgetBytes: z.number().optional(),
267
327
  systemRamBytes: z.number().optional(),
328
+ /** Retired curated ids that no longer belong to any canonical catalog entry. */
329
+ retiredModelIds: z.array(z.string()).default([]),
268
330
  models: z.array(CatalogModelSchema).default([]),
269
331
  })
270
332
  .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,4 +1,17 @@
1
1
  import type { Model } from "../types.js";
2
+ /**
3
+ * Raw model output archive.
4
+ *
5
+ * Scoring is a separate, replayable pass over stored transcripts rather than
6
+ * something that only happens live. This exists because a bug in the scorer
7
+ * (a filename matcher that attributed every test block to the wrong file) made
8
+ * seven models look identical, and fixing it cost a full re-run on the GPU. The
9
+ * model outputs had been correct all along - only the grading was wrong.
10
+ *
11
+ * With the transcript on disk, a scorer fix re-grades history in seconds.
12
+ */
13
+ /** Resolve the writable transcript store for a given host environment. */
14
+ declare function resolveArchiveDir(env?: NodeJS.ProcessEnv): string;
2
15
  declare const ARCHIVE_DIR: string;
3
16
  /** One archived request/response exchange. */
4
17
  export interface TranscriptEntry {
@@ -30,5 +43,5 @@ declare function load(id: string): Record<string, TranscriptEntry[]>;
30
43
  declare function list(): string[];
31
44
  /** Total bytes held, so the archive can be pruned knowingly. */
32
45
  declare function size(): number;
33
- export { ARCHIVE_DIR, runId, runDir, put, load, list, size };
46
+ export { ARCHIVE_DIR, resolveArchiveDir, runId, runDir, put, load, list, size };
34
47
  //# sourceMappingURL=archive.d.ts.map
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
- import { fileURLToPath } from "node:url";
4
+ import { resolveBrainPaths } from "../config/paths.js";
5
5
  /**
6
6
  * Raw model output archive.
7
7
  *
@@ -13,9 +13,13 @@ import { fileURLToPath } from "node:url";
13
13
  *
14
14
  * With the transcript on disk, a scorer fix re-grades history in seconds.
15
15
  */
16
- const HERE = path.dirname(fileURLToPath(import.meta.url));
17
- const ROOT = path.resolve(HERE, "..", "..");
18
- const ARCHIVE_DIR = path.join(ROOT, "results", "transcripts");
16
+ /** Resolve the writable transcript store for a given host environment. */
17
+ function resolveArchiveDir(env = process.env) {
18
+ return path.join(resolveBrainPaths(env).resultsDir, "transcripts");
19
+ }
20
+ // Raw benchmark exchanges are host state too. They must follow the score store
21
+ // into OTTO_HOME rather than attempting to write beside the installed package.
22
+ const ARCHIVE_DIR = resolveArchiveDir();
19
23
  function runId(model, timestamp = new Date()) {
20
24
  const stamp = timestamp.toISOString().replace(/[:.]/g, "-");
21
25
  const slug = String(model?.displayName || "unknown")
@@ -101,5 +105,5 @@ function size() {
101
105
  }
102
106
  return bytes;
103
107
  }
104
- export { ARCHIVE_DIR, runId, runDir, put, load, list, size };
108
+ export { ARCHIVE_DIR, resolveArchiveDir, runId, runDir, put, load, list, size };
105
109
  //# sourceMappingURL=archive.js.map
@@ -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,