@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.
@@ -13,7 +13,7 @@ import { execFile, spawn } from "node:child_process";
13
13
  import { randomUUID } from "node:crypto";
14
14
  import http from "node:http";
15
15
  import https from "node:https";
16
- import { getCalibration, forModel, loadPersistedConfig, loadProfilesStore, put, putCalibration, saveBrainConfig, saveProfilesStore, } from "../config/index.js";
16
+ import { getCalibrationForBudget, forModel, loadPersistedConfig, loadProfilesStore, put, putCalibration, saveBrainConfig, saveProfilesStore, } from "../config/index.js";
17
17
  import { resolveBrainPaths } from "../config/paths.js";
18
18
  import { query as queryGpu } from "../gpu.js";
19
19
  import { managedModelsDir, pickAutoModel, pickModel, scanModels } from "../models/index.js";
@@ -31,6 +31,7 @@ import { createHostApi } from "./host-api.js";
31
31
  import { errorMessage } from "./http-util.js";
32
32
  import { createRouter, createSlotEraser, Telemetry } from "./router.js";
33
33
  import { Scheduler } from "./scheduler.js";
34
+ import { ModelProcessPool } from "./process-pool.js";
34
35
  import { BrainLogPublisher, BrainStatusPublisher } from "./status-events.js";
35
36
  import { Supervisor } from "./supervisor.js";
36
37
  import * as tailscale from "./tailscale.js";
@@ -171,9 +172,9 @@ class ServiceJobRunner {
171
172
  job.queuePosition = this.scheduler.stats().queued + 1;
172
173
  job.message = `Queued for ${model.displayName}`;
173
174
  void this.scheduler
174
- .submit(model, () => controller.signal.aborted
175
+ .submit(model, (supervisor) => controller.signal.aborted
175
176
  ? Promise.reject(new Error("Operation canceled."))
176
- : this.runResidentJob(kind, model.id, {
177
+ : this.runResidentJob(supervisor, kind, model.id, {
177
178
  message: (value) => {
178
179
  if (job.status === "running")
179
180
  job.message = value.slice(-1000);
@@ -469,7 +470,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
469
470
  const fit = vram.fitToBudget({
470
471
  model,
471
472
  profile,
472
- calibration: getCalibration(store, model, profile),
473
+ calibration: getCalibrationForBudget(store, model, profile),
473
474
  totalVramBytes: gpu.totalBytes,
474
475
  });
475
476
  if (!fit.adjusted && !fit.budget.fits) {
@@ -495,41 +496,43 @@ export async function startService({ config, modelNeedle, env = process.env, onL
495
496
  });
496
497
  supervisor.on("log", (line) => log("server", line));
497
498
  supervisor.on("crashed", (error) => log("model", `FATAL ${error}`));
498
- // Serialize model switches: the router queues request-driven switches, but the
499
- // config path (POST /__host/config) calls loadModel directly. Chaining here
500
- // guarantees two switches (e.g. a config write racing a request-driven switch)
501
- // can never overlap two supervisor.start() calls, whichever caller triggers them.
502
- let modelSwitchChain = Promise.resolve();
503
- const loadModelUnsafe = async (target) => {
499
+ const loadModelInto = async (resident, target, reservedElsewhereBytes) => {
504
500
  // A runtime can be installed from the Library tab after this service starts.
505
501
  // Resolve it at load time so the user does not have to restart the brain.
506
- supervisor.runtime = resolveRuntime(config, env);
507
- if (!supervisor.runtime) {
502
+ resident.runtime = resolveRuntime(config, env);
503
+ if (!resident.runtime) {
508
504
  throw new Error("no llama.cpp runtime available; install one from the Library tab");
509
505
  }
510
506
  const gpuInfo = await queryGpu();
511
507
  let fitProfile = forModel(store, target, config.defaults);
508
+ let reservationBytes = 0;
512
509
  if (gpuInfo) {
513
510
  const fit = vram.fitToBudget({
514
511
  model: target,
515
512
  profile: fitProfile,
516
- calibration: getCalibration(store, target, fitProfile),
517
- totalVramBytes: gpuInfo.totalBytes,
513
+ calibration: getCalibrationForBudget(store, target, fitProfile),
514
+ // Every resident process keeps its complete budget reserved. Fit this
515
+ // process against the capacity left after those independent allocations.
516
+ totalVramBytes: Math.max(0, gpuInfo.totalBytes - reservedElsewhereBytes),
518
517
  });
519
518
  if (!fit.adjusted && !fit.budget.fits)
520
519
  throw new Error(fit.reason ?? "does not fit");
521
520
  fitProfile = fit.profile;
521
+ reservationBytes = fit.budget.totalBytes;
522
522
  }
523
- await supervisor.start(target, fitProfile);
523
+ await resident.start(target, fitProfile);
524
524
  delete store.pendingReloadModelIds[target.id];
525
525
  store.lastModelId = target.id;
526
526
  saveProfilesStore(store, paths);
527
+ return reservationBytes;
527
528
  };
528
- const loadModel = (target) => {
529
- const run = modelSwitchChain.then(() => loadModelUnsafe(target));
530
- // Keep the chain alive even if this switch fails, so a later switch still runs.
531
- modelSwitchChain = run.catch(() => undefined);
532
- return run;
529
+ let processPool = null;
530
+ const loadModel = async (target) => {
531
+ if (processPool) {
532
+ await processPool.preload(target);
533
+ return;
534
+ }
535
+ await loadModelInto(supervisor, target, 0);
533
536
  };
534
537
  // Apply an editable config patch from POST /__host/config: mutate the live
535
538
  // config (so the lock/default getters and future starts see it), persist it to
@@ -555,15 +558,42 @@ export async function startService({ config, modelNeedle, env = process.env, onL
555
558
  throw new Error("lockModel must be a boolean");
556
559
  config.lockModel = p.lockModel;
557
560
  }
561
+ if ("maxLoadedModels" in p) {
562
+ const next = p.maxLoadedModels;
563
+ if (!Number.isInteger(next) || next < 1 || next > 16) {
564
+ throw new Error("maxLoadedModels must be an integer from 1 to 16");
565
+ }
566
+ config.maxLoadedModels = next;
567
+ }
568
+ if ("lockedModels" in p) {
569
+ const next = p.lockedModels;
570
+ if (!Array.isArray(next) || !next.every((value) => typeof value === "string")) {
571
+ throw new Error("lockedModels must be an array of model ids");
572
+ }
573
+ config.lockedModels = [...new Set(next)].slice(0, config.maxLoadedModels);
574
+ }
575
+ if (config.lockedModels.length > config.maxLoadedModels) {
576
+ config.lockedModels = config.lockedModels.slice(0, config.maxLoadedModels);
577
+ }
558
578
  const persisted = loadPersistedConfig(paths);
559
579
  persisted.defaultModel = config.defaultModel;
560
580
  persisted.lockModel = config.lockModel;
581
+ persisted.maxLoadedModels = config.maxLoadedModels;
582
+ persisted.lockedModels = config.lockedModels;
561
583
  saveBrainConfig(persisted, paths);
584
+ await processPool?.configure(config.maxLoadedModels);
562
585
  if (switchTo) {
563
586
  const target = catalog.find((m) => m.displayName === switchTo || m.id === switchTo);
564
587
  if (target)
565
588
  await loadModel(target);
566
589
  }
590
+ if (config.lockModel) {
591
+ for (const id of config.lockedModels) {
592
+ const target = catalog.find((candidate) => candidate.id === id || candidate.displayName === id);
593
+ if (target)
594
+ await loadModel(target);
595
+ }
596
+ }
567
597
  return redactConfig(config);
568
598
  };
569
599
  // One CPU sampler for the lifetime of the service: it reports a busy fraction
@@ -574,7 +604,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
574
604
  // /__host/events. One instance, so `capabilities.events` and the stream can
575
605
  // never disagree about whether this brain publishes.
576
606
  const statusEvents = new BrainStatusPublisher();
577
- const runResidentJob = async (kind, target, update, signal) => {
607
+ const runResidentJob = async (supervisor, kind, target, update, signal) => {
578
608
  const ensureActive = () => {
579
609
  if (signal.aborted)
580
610
  throw new Error("Operation canceled.");
@@ -659,7 +689,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
659
689
  supervisor.recordLog(`operation benchmark: resident model ready`);
660
690
  const profile = supervisor.profile ?? forModel(store, targetModel, config.defaults);
661
691
  const gpuInfo = await queryGpu();
662
- const calibration = getCalibration(store, targetModel, profile);
692
+ const calibration = getCalibrationForBudget(store, targetModel, profile);
663
693
  const fit = gpuInfo
664
694
  ? vram.fitToBudget({
665
695
  model: targetModel,
@@ -703,47 +733,54 @@ export async function startService({ config, modelNeedle, env = process.env, onL
703
733
  update.percent(100);
704
734
  supervisor.recordLog(`operation benchmark: saved result for ${targetModel.displayName}`);
705
735
  };
706
- const scheduler = new Scheduler({
707
- supervisor,
708
- loadModel,
736
+ const attachSupervisorLogs = (resident) => {
737
+ if (resident === supervisor)
738
+ return;
739
+ resident.on("log", (line) => log("server", line));
740
+ resident.on("crashed", (error) => log("model", `FATAL ${error}`));
741
+ resident.on("state", () => statusEvents.notify());
742
+ };
743
+ const createPooledSupervisor = (index) => {
744
+ const resident = new Supervisor({
745
+ runtime: resolveRuntime(config, env),
746
+ internalPort: supervisor.internalPort + index,
747
+ paths,
748
+ getProfilesStore: () => store,
749
+ logVerbosity: config.runtime.logVerbosity,
750
+ });
751
+ attachSupervisorLogs(resident);
752
+ return resident;
753
+ };
754
+ const createResidentScheduler = (resident, loadResidentModel, onChange) => new Scheduler({
755
+ supervisor: resident,
756
+ loadModel: loadResidentModel,
709
757
  logger: (message) => log("api", `WARN ${message}`),
710
- onChange: () => statusEvents.notify(),
711
- // Live admission: the engine's own /slots report is the source of truth
712
- // for how many sequence slots are actually free right now. The profile's
713
- // parallelSlots sizes the KV pool at launch; this keeps dispatch honest
714
- // while llama-server is mid-eviction or saturated, and degrades to the
715
- // static count when the sample is unavailable.
716
- // Answers with the free slots NAMED, not just counted. The count gates
717
- // admission; the ids let the scheduler pin each admitted completion to a
718
- // distinct slot (`id_slot`), which is what lets the proxy attribute a
719
- // request's stage - "thinking" above all, which llama-server cannot report -
720
- // to the exact slot row the Overview panel draws. Handing back only a count
721
- // (as this did before) leaves every request unpinned and the panel unable to
722
- // say which slot is thinking and which is emitting tokens.
758
+ onChange,
723
759
  freeSlots: async () => {
724
- if (supervisor.state !== "ready")
760
+ if (resident.state !== "ready")
725
761
  return null;
726
762
  try {
727
- const slots = await sampleSlots({
728
- host: supervisor.host,
729
- port: supervisor.internalPort,
730
- });
763
+ const slots = await sampleSlots({ host: resident.host, port: resident.internalPort });
731
764
  return slots ? { idle: slots.idle, ids: slots.idleSlots } : null;
732
765
  }
733
766
  catch {
734
767
  return null;
735
768
  }
736
769
  },
737
- // Erase a slot's retained KV when it is handed to a different chat, so one
738
- // chat's KV never bleeds into the next chat's thinking. The scheduler
739
- // decides the handoff from the owner map; this is the engine-side wipe on
740
- // the private port, resolved once the engine acknowledges it (see
741
- // Scheduler.OWNERSHIP). The router that shares this scheduler clears the
742
- // owner map on the supervisor's `starting` state.
743
- eraseSlot: createSlotEraser(supervisor.host, supervisor.internalPort),
770
+ eraseSlot: createSlotEraser(resident.host, resident.internalPort),
744
771
  });
772
+ const scheduler = new ModelProcessPool({
773
+ initialSupervisor: supervisor,
774
+ maxModels: config.maxLoadedModels,
775
+ createSupervisor: createPooledSupervisor,
776
+ createScheduler: createResidentScheduler,
777
+ loadModel: loadModelInto,
778
+ logger: (message) => log("model", message),
779
+ onChange: () => statusEvents.notify(),
780
+ });
781
+ processPool = scheduler;
745
782
  const jobs = new ServiceJobRunner(rescanCatalog, runResidentJob, scheduler, (target) => {
746
- const modelId = target ?? supervisor.model?.id ?? store.lastModelId ?? null;
783
+ const modelId = target ?? scheduler.residentSupervisors()[0]?.model?.id ?? store.lastModelId ?? null;
747
784
  return modelId
748
785
  ? (catalog.find((candidate) => candidate.id === modelId || candidate.displayName === modelId) ?? null)
749
786
  : null;
@@ -768,6 +805,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
768
805
  }
769
806
  },
770
807
  loadModel,
808
+ unloadModels: () => scheduler.unload(),
771
809
  scheduler,
772
810
  // The same gate as POST /__host/config. Deleting someone's model files over
773
811
  // the network is strictly more dangerous than changing their default model,
@@ -796,6 +834,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
796
834
  getEvals: collectEvals,
797
835
  getLockModel: () => config.lockModel,
798
836
  getDefaultModel: () => config.defaultModel,
837
+ getLockedModels: () => config.lockedModels,
799
838
  applyConfigPatch,
800
839
  getAllowConfigWrite: allowWrite,
801
840
  hostApi,
@@ -846,11 +885,17 @@ export async function startService({ config, modelNeedle, env = process.env, onL
846
885
  throw error;
847
886
  }
848
887
  certManager?.start();
849
- if (model && profile && runtime) {
850
- await supervisor.start(model, profile);
851
- delete store.pendingReloadModelIds[model.id];
852
- store.lastModelId = model.id;
853
- saveProfilesStore(store, paths);
888
+ if (runtime && config.lockModel && config.lockedModels.length > 0) {
889
+ for (const lockedId of config.lockedModels.slice(0, config.maxLoadedModels)) {
890
+ const locked = catalog.find((candidate) => candidate.id === lockedId || candidate.displayName === lockedId);
891
+ if (locked)
892
+ await scheduler.preload(locked);
893
+ }
894
+ }
895
+ else if (model && profile && runtime) {
896
+ // Default model remains one auto-load. Additional process slots stay empty
897
+ // until a request names another model.
898
+ await scheduler.preload(model);
854
899
  }
855
900
  else if (!runtime) {
856
901
  log("server", "ready: no llama.cpp runtime installed; use the Library tab to download one");
@@ -866,11 +911,15 @@ export async function startService({ config, modelNeedle, env = process.env, onL
866
911
  secure: Boolean(tlsOptions),
867
912
  displayHost,
868
913
  }, env);
869
- log("server", `ready: ${supervisor.model?.displayName ?? "no model loaded"} on ${bindHost}:${port}; run log ${runLog.path}`);
914
+ log("server", `ready: ${scheduler
915
+ .residentSupervisors()
916
+ .map((resident) => resident.model?.displayName)
917
+ .filter(Boolean)
918
+ .join(", ") || "no model loaded"} on ${bindHost}:${port}; run log ${runLog.path}`);
870
919
  const stop = async () => {
871
920
  certManager?.stop();
872
921
  log("server", "Brain service stopping");
873
- await supervisor.stop();
922
+ await scheduler.stop();
874
923
  // Publish the terminal outcome before closing the SSE responses below.
875
924
  // Once the listener is closed, the daemon can still report the child exit,
876
925
  // but it cannot receive this service-owned, durable session-log entry.
@@ -97,6 +97,7 @@ export function statusChangeKey(snapshot) {
97
97
  state: snapshot.state ?? null,
98
98
  model: snapshot.model ?? null,
99
99
  modelId: snapshot.modelId ?? null,
100
+ residents: snapshot.residents ?? null,
100
101
  pid: snapshot.pid ?? null,
101
102
  vramBytes: snapshot.vramBytes ?? null,
102
103
  loadSeconds: snapshot.loadSeconds ?? null,
@@ -11,7 +11,7 @@ import { spawn } from "node:child_process";
11
11
  import { EventEmitter } from "node:events";
12
12
  import { buildArgs, buildEnv, formatCommand } from "../runtime/index.js";
13
13
  import { resolveHostingProfileForLaunch } from "../config/hosting-profiles.js";
14
- import { getCalibration } from "../config/profiles.js";
14
+ import { getCalibrationForBudget } from "../config/profiles.js";
15
15
  import { resolveBrainPaths } from "../config/paths.js";
16
16
  import { loadProfilesStore } from "../config/store.js";
17
17
  import { usedBytes } from "../gpu.js";
@@ -115,7 +115,7 @@ export class Supervisor extends EventEmitter {
115
115
  // The prompt-cache budget is derived from measured KV bytes/token, so the
116
116
  // launch boundary is where it has to be resolved - nothing downstream of
117
117
  // here can reach the calibration store.
118
- getCalibration(this.getProfilesStore(), model, launchProfile));
118
+ getCalibrationForBudget(this.getProfilesStore(), model, launchProfile));
119
119
  this.args = args;
120
120
  this.command = formatCommand(runtime, args);
121
121
  __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, formatBrainLog("model", `launching: ${this.command}`));
package/dist/tui/app.js CHANGED
@@ -246,7 +246,7 @@ export class App {
246
246
  const fit = vram.fitToBudget({
247
247
  model: target,
248
248
  profile,
249
- calibration: profiles.getCalibration(this.store, target, profile),
249
+ calibration: profiles.getCalibrationForBudget(this.store, target, profile),
250
250
  totalVramBytes: info.totalBytes,
251
251
  });
252
252
  if (!fit.adjusted && !fit.budget.fits)
@@ -430,7 +430,7 @@ export class App {
430
430
  const profile = this.profile;
431
431
  if (!model || !profile)
432
432
  return null;
433
- return profiles.getCalibration(this.store, model, profile);
433
+ return profiles.getCalibrationForBudget(this.store, model, profile);
434
434
  }
435
435
  get budget() {
436
436
  const model = this.model;
@@ -1398,7 +1398,9 @@ export class App {
1398
1398
  // Only when it belongs to the model actually being benchmarked - the
1399
1399
  // model may have been resident since before this fit was computed.
1400
1400
  fit: this.lastFit?.modelId === model.id ? this.lastFit.fit : null,
1401
- calibration: profile ? profiles.getCalibration(this.store, model, profile) : null,
1401
+ calibration: profile
1402
+ ? profiles.getCalibrationForBudget(this.store, model, profile)
1403
+ : null,
1402
1404
  suite: {
1403
1405
  // The TUI runs the full static suite; only concurrency varies, and
1404
1406
  // it tracks the profile's slot count the same way runSuite is called.
@@ -1818,8 +1820,12 @@ export class App {
1818
1820
  const start = Math.max(0, Math.min(focusLine - Math.floor(rows / 2), content.length - rows));
1819
1821
  lines = content.slice(start, start + rows);
1820
1822
  }
1823
+ const stale = Boolean(this.profile?.calibrationRequired ||
1824
+ (this.model &&
1825
+ this.profile &&
1826
+ profiles.hasStaleCalibration(this.store, this.model, this.profile)));
1821
1827
  return box({
1822
- title: `Configuration${this.calibration ? ` ${style.brightGreen}calibrated${style.reset}` : ` ${style.yellow}not calibrated${style.reset}`}`,
1828
+ title: `Configuration${stale ? ` ${style.yellow}recalibrate${style.reset}` : this.calibration ? ` ${style.brightGreen}calibrated${style.reset}` : ` ${style.yellow}not calibrated${style.reset}`}`,
1823
1829
  lines,
1824
1830
  innerWidth,
1825
1831
  footer: `${style.grey}←→ change · enter edit${style.reset}`,
@@ -1847,18 +1853,21 @@ export class App {
1847
1853
  ` = ${style.bold}${vram.formatGiB(b.totalBytes)}${style.reset}`;
1848
1854
  const kvLabel = `kv ${(b.kvBytesPerToken / 1024).toFixed(1)} KB/token`;
1849
1855
  const cal = this.calibration;
1856
+ const stale = Boolean(this.profile?.calibrationRequired ||
1857
+ (this.model &&
1858
+ this.profile &&
1859
+ profiles.hasStaleCalibration(this.store, this.model, this.profile)));
1850
1860
  let sourceNote;
1851
1861
  if (b.source === "measured") {
1852
1862
  sourceNote = cal?.inherited
1853
1863
  ? `${style.yellow}${kvLabel} (measured on a relative - press c to calibrate this model)${style.reset}`
1854
- : `${style.grey}${kvLabel} (measured)${style.reset}`;
1864
+ : stale
1865
+ ? `${style.yellow}${kvLabel} (last measurement - press c to recalibrate)${style.reset}`
1866
+ : `${style.grey}${kvLabel} (measured)${style.reset}`;
1855
1867
  }
1856
1868
  else {
1857
- const stale = this.model && this.profile
1858
- ? profiles.hasStaleCalibration(this.store, this.model, this.profile)
1859
- : false;
1860
1869
  const hint = stale
1861
- ? "cache types changed - press c to recalibrate"
1870
+ ? "recalibrate to refresh this value"
1862
1871
  : "press c to calibrate; usually unlocks more context";
1863
1872
  sourceNote = `${style.yellow}${kvLabel} (theoretical - ${hint})${style.reset}`;
1864
1873
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otto-code/brain",
3
- "version": "0.8.12",
3
+ "version": "0.8.13",
4
4
  "description": "Otto Brain - self-contained host for local GGUF models, with measured VRAM budgeting and reasoning-budget control",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "bin": {