@otto-code/brain 0.8.12 → 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.
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import http from "node:http";
10
10
  import path from "node:path";
11
- import { forModel, getCalibration, loadBrainConfig, loadProfilesStore, resolveBrainPaths, } from "../config/index.js";
11
+ import { forModel, getCalibrationForBudget, loadBrainConfig, loadProfilesStore, resolveBrainPaths, } from "../config/index.js";
12
12
  import { query as queryGpu } from "../gpu.js";
13
13
  import { pickModel, scanModels } from "../models/index.js";
14
14
  import { CommandError } from "../output/types.js";
@@ -172,7 +172,7 @@ async function runBenchSuite(options, _command) {
172
172
  // that ran is the profile that was configured, and the calibration is what
173
173
  // says whether the fit's own VRAM figures were measured or guessed. A score
174
174
  // read without them is a score nobody can diagnose.
175
- const calibration = getCalibration(store, model, profile);
175
+ const calibration = getCalibrationForBudget(store, model, profile);
176
176
  let fit = null;
177
177
  const gpu = await queryGpu();
178
178
  if (gpu) {
@@ -1,4 +1,4 @@
1
- import { getCalibration, forModel, loadBrainConfig, loadProfilesStore, putCalibration, saveProfilesStore, } from "../config/index.js";
1
+ import { getCalibrationForBudget, forModel, loadBrainConfig, loadProfilesStore, putCalibration, saveProfilesStore, } from "../config/index.js";
2
2
  import { query as queryGpu } from "../gpu.js";
3
3
  import { pickModel, scanModels } from "../models/index.js";
4
4
  import { CommandError } from "../output/types.js";
@@ -35,12 +35,12 @@ export async function runCalibrateCommand(options, _command) {
35
35
  const catalog = scanModels(config);
36
36
  const model = pickModel(catalog, options.model ?? store.lastModelId ?? undefined);
37
37
  const profile = forModel(store, model, config.defaults);
38
- // A measurement from the previous run is the best prior on bytes/token for
39
- // this exact profile shape. The sample cap uses it so the high sample never
40
- // lands at a context that would spill the KV cache to CPU and bias the new
41
- // slope low. Inherited family calibrations are excluded: they were measured
42
- // on another file and are not a trustworthy budget input here.
43
- const prior = getCalibration(store, model, profile);
38
+ // The last direct measurement is the best prior on bytes/token available for
39
+ // capping the next sample. It keeps the calibration path stable while a
40
+ // changed profile is awaiting a fresh measurement. Inherited family
41
+ // calibrations are excluded: they were measured on another file and are not
42
+ // a trustworthy budget input here.
43
+ const prior = getCalibrationForBudget(store, model, profile);
44
44
  // Announced so the Brain rail can show the host as busy: a calibrate loads the
45
45
  // model at several context sizes and will make anything else queue behind it.
46
46
  const measurement = await withActivity("calibrate", { target: model.displayName }, () => calibrate({
@@ -66,7 +66,7 @@ export async function runCalibrateCommand(options, _command) {
66
66
  ? vram.maxContextThatFits({
67
67
  model,
68
68
  profile,
69
- calibration: getCalibration(store, model, profile),
69
+ calibration: getCalibrationForBudget(store, model, profile),
70
70
  totalVramBytes: gpu.totalBytes,
71
71
  })
72
72
  : null;
@@ -4,7 +4,7 @@ export { ensurePrivateDirectory, writePrivateFileAtomicSync } from "./private-fi
4
4
  export { resolveBrainPaths, packageRoot, type BrainPaths } from "./paths.js";
5
5
  export { parseBooleanEnv, applyEnvOverrides } from "./env.js";
6
6
  export { loadBrainConfig, loadPersistedConfig, saveBrainConfig, loadProfilesStore, saveProfilesStore, loadCatalog, } from "./store.js";
7
- export { defaultProfile, forModel, put, calibrationKey, geometryKey, getCalibration, putCalibration, hasStaleCalibration, } from "./profiles.js";
7
+ export { defaultProfile, forModel, put, calibrationKey, geometryKey, getCalibration, getCalibrationForBudget, getLastCalibration, putCalibration, hasStaleCalibration, } from "./profiles.js";
8
8
  export { effectiveHostingProfile, resolveHostingProfileForLaunch } from "./hosting-profiles.js";
9
9
  export { calibrationInfo, nativeContextLimit, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, formatReasoningBudget, CACHE_TYPE_CYCLE, REASONING_BUDGET_CYCLE, UNRESTRICTED_REASONING_BUDGET, PRESERVE_REASONING_CYCLE, SAMPLING_RANGES, preserveReasoningFromOption, preserveReasoningOption, type CalibrationInfo, type CalibrationState, type ProfileFieldDescriptor, type ProfileWarning, } from "./profile-edit.js";
10
10
  export * from "./schema.js";
@@ -4,7 +4,7 @@ export { ensurePrivateDirectory, writePrivateFileAtomicSync } from "./private-fi
4
4
  export { resolveBrainPaths, packageRoot } from "./paths.js";
5
5
  export { parseBooleanEnv, applyEnvOverrides } from "./env.js";
6
6
  export { loadBrainConfig, loadPersistedConfig, saveBrainConfig, loadProfilesStore, saveProfilesStore, loadCatalog, } from "./store.js";
7
- export { defaultProfile, forModel, put, calibrationKey, geometryKey, getCalibration, putCalibration, hasStaleCalibration, } from "./profiles.js";
7
+ export { defaultProfile, forModel, put, calibrationKey, geometryKey, getCalibration, getCalibrationForBudget, getLastCalibration, putCalibration, hasStaleCalibration, } from "./profiles.js";
8
8
  export { effectiveHostingProfile, resolveHostingProfileForLaunch } from "./hosting-profiles.js";
9
9
  export { calibrationInfo, nativeContextLimit, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, formatReasoningBudget, CACHE_TYPE_CYCLE, REASONING_BUDGET_CYCLE, UNRESTRICTED_REASONING_BUDGET, PRESERVE_REASONING_CYCLE, SAMPLING_RANGES, preserveReasoningFromOption, preserveReasoningOption, } from "./profile-edit.js";
10
10
  export * from "./schema.js";
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import os from "node:os";
21
21
  import { CACHE_TYPE_BYTES, formatGiB, promptCacheSize } from "../vram.js";
22
- import { getCalibration, hasStaleCalibration } from "./profiles.js";
22
+ import { getCalibration, getCalibrationForBudget, hasStaleCalibration } from "./profiles.js";
23
23
  /**
24
24
  * Sampler ranges. llama.cpp itself bounds almost none of these - it will take a
25
25
  * temperature of 50 - so the bounds are the useful range rather than the legal
@@ -329,8 +329,10 @@ export function profileWarnings(profile, model, store) {
329
329
  // A count above 0 can only be priced from a measurement: without one the
330
330
  // theoretical KV cost runs to multiples of the real one, and naming a figure
331
331
  // derived from it would invite reserving several times the RAM actually
332
- // needed. So the unmeasured count falls back to the calibration request.
333
- const calibration = model && store ? getCalibration(store, model, profile) : null;
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;
334
336
  const cache = model ? promptCacheSize(model, profile, calibration) : null;
335
337
  const hasCount = (profile.cachedChats ?? 0) > 0;
336
338
  if (hasCount && (!cache || cache.source !== "measured")) {
@@ -359,7 +361,7 @@ export function profileWarnings(profile, model, store) {
359
361
  warnings.push({
360
362
  field: "contextMultiplier",
361
363
  severity: "warn",
362
- message: `YaRN ×${profile.contextMultiplier} extrapolates beyond the native context. Recalibrate before relying on this profile.`,
364
+ message: "YaRN extrapolates beyond the native context.",
363
365
  blocksStart: false,
364
366
  });
365
367
  }
@@ -370,7 +372,7 @@ export function profileWarnings(profile, model, store) {
370
372
  warnings.push({
371
373
  field: "cacheTypeK",
372
374
  severity: "info",
373
- message: "Cache types changed since the last measurement. Recalibrate for a real budget.",
375
+ message: "Cache types changed since the last measurement.",
374
376
  blocksStart: false,
375
377
  });
376
378
  }
@@ -382,24 +384,22 @@ export function profileWarnings(profile, model, store) {
382
384
  * model's layer count, which the UI must never present as measured on this file.
383
385
  */
384
386
  export function calibrationInfo(store, model, profile) {
385
- // A historical measurement is invalid as soon as any VRAM-affecting setting
386
- // changes. Keep the data for comparison, but do not present or use it as the
387
- // current model budget until a calibration commits the new profile.
388
- if (profile.calibrationRequired) {
387
+ const current = getCalibration(store, model, profile);
388
+ const calibration = getCalibrationForBudget(store, model, profile);
389
+ if (!calibration) {
389
390
  return {
390
- state: "theoretical",
391
+ state: hasStaleCalibration(store, model, profile) ? "stale" : "theoretical",
391
392
  kvBytesPerToken: null,
392
393
  measuredAt: null,
393
394
  measuredOn: null,
394
395
  };
395
396
  }
396
- const calibration = getCalibration(store, model, profile);
397
- if (!calibration) {
397
+ if (profile.calibrationRequired || !current) {
398
398
  return {
399
- state: hasStaleCalibration(store, model, profile) ? "stale" : "theoretical",
400
- kvBytesPerToken: null,
401
- measuredAt: null,
402
- measuredOn: null,
399
+ state: "stale",
400
+ kvBytesPerToken: calibration.kvBytesPerToken,
401
+ measuredAt: calibration.measuredAt ?? null,
402
+ measuredOn: calibration.measuredOn ?? null,
403
403
  };
404
404
  }
405
405
  return {
@@ -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
@@ -115,16 +115,16 @@ export function calibrationKey(profile) {
115
115
  : `${profile.cacheTypeK}:${profile.cacheTypeV}${multiplier}`;
116
116
  }
117
117
  /**
118
- * True when this model has a stored calibration, but for different cache types
119
- * than the profile currently uses - i.e. the measurement is stale and the budget
120
- * 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.
121
121
  */
122
122
  export function hasStaleCalibration(store, model, profile) {
123
123
  const measured = store.calibrations?.[model.id];
124
124
  if (!measured)
125
125
  return false;
126
126
  const key = calibrationKey(profile);
127
- return Object.keys(measured).some((k) => k !== key);
127
+ return !Object.hasOwn(measured, key) && Object.keys(measured).length > 0;
128
128
  }
129
129
  /**
130
130
  * KV cost per token is a property of the attention geometry, not of the particular
@@ -167,6 +167,38 @@ export function getCalibration(store, model, profile) {
167
167
  inherited: true,
168
168
  };
169
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
+ }
170
202
  export function putCalibration(store, model, profile, measurement) {
171
203
  if (!store.calibrations)
172
204
  store.calibrations = {};
@@ -1516,6 +1516,10 @@ export declare const BrainConfigSchema: z.ZodObject<{
1516
1516
  modelsDir: z.ZodDefault<z.ZodNullable<z.ZodString>>;
1517
1517
  hfToken: z.ZodDefault<z.ZodNullable<z.ZodString>>;
1518
1518
  defaultModel: z.ZodDefault<z.ZodNullable<z.ZodString>>;
1519
+ /** Maximum independently hosted llama-server model processes. */
1520
+ maxLoadedModels: z.ZodDefault<z.ZodNumber>;
1521
+ /** Stable ids selected for residency while model locking is enabled. */
1522
+ lockedModels: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
1519
1523
  lockModel: z.ZodDefault<z.ZodBoolean>;
1520
1524
  allowRemoteConfig: z.ZodDefault<z.ZodBoolean>;
1521
1525
  allowInsecureBind: z.ZodDefault<z.ZodBoolean>;
@@ -1571,6 +1575,8 @@ export declare const BrainConfigSchema: z.ZodObject<{
1571
1575
  modelsDir: string | null;
1572
1576
  hfToken: string | null;
1573
1577
  defaultModel: string | null;
1578
+ maxLoadedModels: number;
1579
+ lockedModels: string[];
1574
1580
  lockModel: boolean;
1575
1581
  allowRemoteConfig: boolean;
1576
1582
  allowInsecureBind: boolean;
@@ -1612,6 +1618,8 @@ export declare const BrainConfigSchema: z.ZodObject<{
1612
1618
  modelsDir?: string | null | undefined;
1613
1619
  hfToken?: string | null | undefined;
1614
1620
  defaultModel?: string | null | undefined;
1621
+ maxLoadedModels?: number | undefined;
1622
+ lockedModels?: string[] | undefined;
1615
1623
  lockModel?: boolean | undefined;
1616
1624
  allowRemoteConfig?: boolean | undefined;
1617
1625
  allowInsecureBind?: boolean | undefined;
@@ -251,9 +251,13 @@ export const BrainConfigSchema = z
251
251
  // persisted fallback so users can set it once (config set hfToken <token>).
252
252
  hfToken: z.string().nullable().default(null),
253
253
  defaultModel: z.string().nullable().default(null),
254
- // Pin the host to a single model: serve only the default/resident model and
255
- // refuse completion requests that name a different one, instead of queuing a
256
- // 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.
257
261
  lockModel: z.boolean().default(false),
258
262
  // Sharing/control gates (off by default - a brain is not remotely
259
263
  // controllable until its owner opts in). `allowRemoteConfig`: a client with
@@ -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,26 @@
1
1
  import type { GpuInfo, Model, ModelFeatures } from "../types.js";
2
2
  import type { Calibration, Profile } from "../config/schema.js";
3
3
  import type { FitResult } from "../vram.js";
4
+ /**
5
+ * Persistent benchmark history.
6
+ *
7
+ * One JSON file per run, so results accumulate across sessions and can be
8
+ * compared and charted later. Runs record the configuration they were measured
9
+ * under, because a score is meaningless without the quant, context size and
10
+ * reasoning budget that produced it.
11
+ *
12
+ * **A run records every value it was measured with, not a summary of them.** A
13
+ * bad score is far more often a bad setup than a bad model, and the difference
14
+ * is only visible from the settings: a context the VRAM fit had to cut, a
15
+ * reasoning budget the model spends entirely on thinking, a KV quant that
16
+ * wrecked recall, weights that fell off the GPU because `gpuLayers` did not
17
+ * cover them, a budget estimated from the formula rather than measured. None of
18
+ * that is recoverable after the fact, so it is all written down at save time -
19
+ * including the exact llama-server argv the run was served with, which is the
20
+ * only true statement of what ran.
21
+ */
22
+ /** Resolve the writable store for benchmark scores for a given host environment. */
23
+ declare function resolveResultsDir(env?: NodeJS.ProcessEnv): string;
4
24
  declare const RESULTS_DIR: string;
5
25
  /** Aggregate of a numeric health series (nvidia-smi samples during a run). */
6
26
  export interface AggStat {
@@ -368,5 +388,5 @@ declare function variance(records?: RunRecord[]): VarianceRow[];
368
388
  declare function rankModels(records?: RunRecord[]): RankedModel[];
369
389
  /** All task ids seen across a set of records, in a stable order. */
370
390
  declare function taskColumns(records: RunRecord[]): TaskColumn[];
371
- export { RESULTS_DIR, save, loadAll, latestPerConfig, grouped, variance, stats, rankModels, taskColumns, configKey, slugify, ENGINE_SAMPLER_DEFAULTS, };
391
+ export { RESULTS_DIR, resolveResultsDir, save, loadAll, latestPerConfig, grouped, variance, stats, rankModels, taskColumns, configKey, slugify, ENGINE_SAMPLER_DEFAULTS, };
372
392
  //# sourceMappingURL=results.d.ts.map
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { fileURLToPath } from "node:url";
3
+ import { resolveBrainPaths } from "../config/paths.js";
4
4
  /**
5
5
  * Persistent benchmark history.
6
6
  *
@@ -19,9 +19,14 @@ import { fileURLToPath } from "node:url";
19
19
  * including the exact llama-server argv the run was served with, which is the
20
20
  * only true statement of what ran.
21
21
  */
22
- const HERE = path.dirname(fileURLToPath(import.meta.url));
23
- const ROOT = path.resolve(HERE, "..", "..");
24
- const RESULTS_DIR = path.join(ROOT, "results");
22
+ /** Resolve the writable store for benchmark scores for a given host environment. */
23
+ function resolveResultsDir(env = process.env) {
24
+ return resolveBrainPaths(env).resultsDir;
25
+ }
26
+ // Benchmark history is host state, not package data. Keeping it beside the
27
+ // Brain config also makes the service work from packaged/Electron installs,
28
+ // where the package directory may be an archive or read-only.
29
+ const RESULTS_DIR = resolveResultsDir();
25
30
  function slugify(text) {
26
31
  return String(text)
27
32
  .toLowerCase()
@@ -383,5 +388,5 @@ function taskColumns(records) {
383
388
  }
384
389
  return [...seen.entries()].map(([id, category]) => ({ id, category }));
385
390
  }
386
- export { RESULTS_DIR, save, loadAll, latestPerConfig, grouped, variance, stats, rankModels, taskColumns, configKey, slugify, ENGINE_SAMPLER_DEFAULTS, };
391
+ export { RESULTS_DIR, resolveResultsDir, save, loadAll, latestPerConfig, grouped, variance, stats, rankModels, taskColumns, configKey, slugify, ENGINE_SAMPLER_DEFAULTS, };
387
392
  //# sourceMappingURL=results.js.map
@@ -28,7 +28,7 @@ import * as vram from "../vram.js";
28
28
  import type { SystemSample } from "../sysmon.js";
29
29
  import type { BrainLogPublisher, BrainStatusPublisher } from "./status-events.js";
30
30
  import type { Supervisor } from "./supervisor.js";
31
- import type { Scheduler } from "./scheduler.js";
31
+ import type { ModelScheduler } from "./scheduler.js";
32
32
  import type { BrainRunLog } from "./run-log.js";
33
33
  import type { BrainLogArea } from "./log-format.js";
34
34
  /**
@@ -83,6 +83,8 @@ export interface HostCapabilities {
83
83
  jobs: boolean;
84
84
  /** POST /__host/restart delegates a restart to the service owner. */
85
85
  restart: boolean;
86
+ /** Multiple independently supervised model processes are supported. */
87
+ processPool: boolean;
86
88
  }
87
89
  /** A long-running operation owned by this brain host, not its caller. */
88
90
  export interface HostJob {
@@ -122,8 +124,9 @@ export interface HostApiDeps {
122
124
  queryGpuInfo: () => Promise<GpuInfo | null>;
123
125
  getRanking: () => RankedModel[];
124
126
  loadModel: (model: Model) => Promise<void>;
125
- /** The single model queue shared by completions and resident operations. */
126
- scheduler?: Scheduler | null;
127
+ unloadModels?: () => Promise<void>;
128
+ /** The process-pool scheduler shared by completions and resident operations. */
129
+ scheduler?: ModelScheduler<Supervisor> | null;
127
130
  /** Mirrors POST /__host/config's gate: may a network caller change things? */
128
131
  getAllowWrite: () => boolean;
129
132
  /** The managed models directory, for disk accounting. Null when unresolvable. */
@@ -204,7 +207,7 @@ export declare function buildInventoryRow(params: {
204
207
  gpu: GpuInfo | null;
205
208
  ranking: RankedModel[];
206
209
  supervisor: Supervisor;
207
- scheduler?: Scheduler | null;
210
+ scheduler?: ModelScheduler<Supervisor> | null;
208
211
  runtimeBuild?: number | null;
209
212
  }): InventoryRow;
210
213
  export interface HostApi {
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { calibrationInfo, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, } from "../config/profile-edit.js";
3
3
  import { familyHostingProfileId, hostingFamily, removeHostingProfileMaterialization, } from "../config/hosting-profiles.js";
4
- import { forModel, getCalibration, put } from "../config/profiles.js";
4
+ import { forModel, getCalibrationForBudget, put } from "../config/profiles.js";
5
5
  import { HostingProfileSchema, } from "../config/schema.js";
6
6
  import { deleteComponentFile, deleteModelFiles, diskUsage, planDelete, totalModelBytes, } from "../models/manage.js";
7
7
  import { deleteDisplayName, updateDisplayName } from "../models/rename-map.js";
@@ -171,11 +171,13 @@ function hostingProfilesFor(store, model) {
171
171
  return Object.values(store.hostingProfiles).filter((candidate) => candidate.family === family);
172
172
  }
173
173
  function stateOf(supervisor, scheduler, model) {
174
- const resident = supervisor.model?.id === model.id;
174
+ const residentSupervisor = (typeof scheduler?.supervisorFor === "function" ? scheduler.supervisorFor(model.id) : null) ??
175
+ (supervisor.model?.id === model.id ? supervisor : null);
176
+ const resident = residentSupervisor !== null;
175
177
  if (resident) {
176
- if (supervisor.state === "starting")
178
+ if (residentSupervisor.state === "starting")
177
179
  return "loading";
178
- if (supervisor.state === "stopping")
180
+ if (residentSupervisor.state === "stopping")
179
181
  return "unloading";
180
182
  }
181
183
  const stats = scheduler?.stats();
@@ -183,7 +185,7 @@ function stateOf(supervisor, scheduler, model) {
183
185
  return "active";
184
186
  if ((stats?.waitingModelIds[model.id] ?? 0) > 0)
185
187
  return "queued";
186
- if (resident && supervisor.state === "ready")
188
+ if (resident && residentSupervisor.state === "ready")
187
189
  return "loaded";
188
190
  return "not-loaded";
189
191
  }
@@ -197,7 +199,7 @@ function stateOf(supervisor, scheduler, model) {
197
199
  export function buildInventoryRow(params) {
198
200
  const { model, store, defaults, gpu, ranking, supervisor, scheduler = null, runtimeBuild: activeRuntimeBuild = null, } = params;
199
201
  const profile = forModel(store, model, defaults);
200
- const calibration = profile.calibrationRequired ? null : getCalibration(store, model, profile);
202
+ const calibration = getCalibrationForBudget(store, model, profile);
201
203
  const budgetOptions = gpu
202
204
  ? { model, profile, calibration, totalVramBytes: gpu.totalBytes }
203
205
  : null;
@@ -344,6 +346,7 @@ export function createHostApi(deps) {
344
346
  writable: deps.getAllowWrite(),
345
347
  jobs: Boolean(deps.jobs),
346
348
  restart: Boolean(deps.restart),
349
+ processPool: true,
347
350
  });
348
351
  /** Refuse a write unless the owner opted into remote configuration. */
349
352
  const guardWrite = (res) => {
@@ -413,7 +416,9 @@ export function createHostApi(deps) {
413
416
  // A setting is only unapplied when it was changed on the currently
414
417
  // resident model. Edits to an unloaded model take effect naturally
415
418
  // when it is next loaded and do not earn a misleading reload badge.
416
- const requiresRestart = deps.supervisor.model?.id === model.id;
419
+ const requiresRestart = Boolean((typeof deps.scheduler?.supervisorFor === "function"
420
+ ? deps.scheduler.supervisorFor(model.id)
421
+ : null) ?? (deps.supervisor.model?.id === model.id ? deps.supervisor : null));
417
422
  if (requiresRestart)
418
423
  store.pendingReloadModelIds[model.id] = true;
419
424
  deps.saveProfiles(store);
@@ -421,9 +426,7 @@ export function createHostApi(deps) {
421
426
  // Return the recomputed budget so an edit costs one round trip rather
422
427
  // than a write followed by a read the UI has to sequence.
423
428
  const gpu = await deps.queryGpuInfo();
424
- const calibration = profile.calibrationRequired
425
- ? null
426
- : getCalibration(store, model, profile);
429
+ const calibration = getCalibrationForBudget(store, model, profile);
427
430
  const options = gpu
428
431
  ? { model, profile, calibration, totalVramBytes: gpu.totalBytes }
429
432
  : null;
@@ -522,7 +525,7 @@ export function createHostApi(deps) {
522
525
  const options = {
523
526
  model,
524
527
  profile,
525
- calibration: profile.calibrationRequired ? null : getCalibration(store, model, profile),
528
+ calibration: getCalibrationForBudget(store, model, profile),
526
529
  totalVramBytes: gpu.totalBytes,
527
530
  };
528
531
  sendJson(res, {
@@ -553,11 +556,14 @@ export function createHostApi(deps) {
553
556
  deps.log?.("model", `loading ${model.displayName}`);
554
557
  await deps.loadModel(model);
555
558
  deps.log?.("model", `loaded ${model.displayName}`);
559
+ const resident = (typeof deps.scheduler?.supervisorFor === "function"
560
+ ? deps.scheduler.supervisorFor(model.id)
561
+ : null) ?? deps.supervisor;
556
562
  sendJson(res, {
557
- status: deps.supervisor.status(),
563
+ status: resident.status(),
558
564
  // What actually got used: loadModel fits the profile to VRAM, so the
559
565
  // context here may be lower than the one saved.
560
- profile: deps.supervisor.profile,
566
+ profile: resident.profile,
561
567
  });
562
568
  }
563
569
  catch (error) {
@@ -570,7 +576,10 @@ export function createHostApi(deps) {
570
576
  void (async () => {
571
577
  try {
572
578
  deps.log?.("model", "unloading resident model");
573
- await deps.supervisor.stop();
579
+ if (deps.unloadModels)
580
+ await deps.unloadModels();
581
+ else
582
+ await deps.supervisor.stop();
574
583
  deps.log?.("model", "resident model unloaded");
575
584
  sendJson(res, { status: deps.supervisor.status() });
576
585
  }
@@ -580,7 +589,10 @@ export function createHostApi(deps) {
580
589
  })();
581
590
  };
582
591
  const handleDelete = (res, model) => {
583
- if (deps.supervisor.model?.id === model.id && deps.supervisor.state !== "stopped") {
592
+ const resident = typeof deps.scheduler?.supervisorFor === "function"
593
+ ? deps.scheduler.supervisorFor(model.id)
594
+ : null;
595
+ if (resident && resident.state !== "stopped") {
584
596
  sendError(res, 409, "stop the model before deleting it");
585
597
  return;
586
598
  }
@@ -600,7 +612,10 @@ export function createHostApi(deps) {
600
612
  }
601
613
  };
602
614
  const handleComponentDelete = (res, model, componentId) => {
603
- if (deps.supervisor.model?.id === model.id && deps.supervisor.state !== "stopped") {
615
+ const resident = typeof deps.scheduler?.supervisorFor === "function"
616
+ ? deps.scheduler.supervisorFor(model.id)
617
+ : null;
618
+ if (resident && resident.state !== "stopped") {
604
619
  sendError(res, 409, "stop the model before removing a bundle component");
605
620
  return;
606
621
  }
@@ -0,0 +1,45 @@
1
+ import type { Model } from "../types.js";
2
+ import type { ModelScheduler, SchedulerStats, SchedulerSubmitOptions } from "./scheduler.js";
3
+ import { Scheduler } from "./scheduler.js";
4
+ import type { Supervisor } from "./supervisor.js";
5
+ export interface ModelProcessPoolOptions {
6
+ initialSupervisor: Supervisor;
7
+ maxModels: number;
8
+ createSupervisor: (index: number) => Supervisor;
9
+ createScheduler: (supervisor: Supervisor, loadModel: (model: Model) => Promise<void>, onChange: () => void) => Scheduler<Supervisor>;
10
+ /** Returns the complete VRAM reservation for the process after it is ready. */
11
+ loadModel: (supervisor: Supervisor, model: Model, reservedElsewhereBytes: number) => Promise<number>;
12
+ onChange?: (() => void) | null;
13
+ logger?: ((message: string) => void) | null;
14
+ }
15
+ /**
16
+ * Global admission and eviction boundary for independently hosted models.
17
+ *
18
+ * Each resident model owns one Supervisor/Scheduler pair and therefore one
19
+ * llama-server process, port, KV pool, and lifecycle. Requests for a resident
20
+ * model go directly to that process. An unloaded model claims a free process
21
+ * slot, or evicts the least-recently-used idle process when the configured
22
+ * model limit is full. Busy processes are never evicted; the request remains
23
+ * queued until one becomes idle.
24
+ */
25
+ export declare class ModelProcessPool implements ModelScheduler<Supervisor> {
26
+ #private;
27
+ constructor(options: ModelProcessPoolOptions);
28
+ get maxModels(): number;
29
+ submit(model: Model, run: (supervisor: Supervisor) => Promise<unknown>, options?: SchedulerSubmitOptions): Promise<unknown>;
30
+ /** Load a model and leave it resident without consuming an inference slot. */
31
+ preload(model: Model): Promise<void>;
32
+ /** Apply the host-owned process limit and retire excess idle processes. */
33
+ configure(maxModels: number): Promise<void>;
34
+ supervisorFor(modelId: string): Supervisor | null;
35
+ supervisors(): Supervisor[];
36
+ /** Complete statuses for every process that currently owns a model. */
37
+ residentSupervisors(): Supervisor[];
38
+ reservationFor(modelId: string): number;
39
+ unload(modelId?: string | null): Promise<void>;
40
+ stop(): Promise<void>;
41
+ forgetSlots(): void;
42
+ stats(): SchedulerStats;
43
+ }
44
+ export declare function normalizeModelLimit(value: number): number;
45
+ //# sourceMappingURL=process-pool.d.ts.map