@otto-code/brain 0.8.12 → 0.8.14

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.
@@ -9,7 +9,7 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (
9
9
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
10
10
  return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
11
11
  };
12
- var _ReasoningTracker_instances, _ReasoningTracker_requests, _ReasoningTracker_slots, _ReasoningTracker_tails, _ReasoningTracker_inlineReasoning, _ReasoningTracker_listeners, _ReasoningTracker_lastSnapshot, _ReasoningTracker_announce;
12
+ var _ReasoningTracker_instances, _ReasoningTracker_requests, _ReasoningTracker_listeners, _ReasoningTracker_lastSnapshot, _ReasoningTracker_counter, _ReasoningTracker_announce, _ReasoningTracker_setSlot, _ReasoningTracker_observe, _ReasoningTracker_touch, _ReasoningTracker_drop;
13
13
  /**
14
14
  * What long-running work currently owns the brain, and which stage each live
15
15
  * inference request has reached.
@@ -179,6 +179,23 @@ export function chunkHasContent(text) {
179
179
  /"tool_calls"\s*:\s*\[\s*\{/u.test(text) ||
180
180
  /"type"\s*:\s*"(?:tool_use|input_json_delta)"/u.test(text));
181
181
  }
182
+ /**
183
+ * How long a request may be silent before the reaper will consider it at all.
184
+ *
185
+ * Long enough to cover the gap between proxy dispatch and llama-server picking
186
+ * the task up, which is the one window where a healthy request and an idle
187
+ * engine legitimately coexist.
188
+ */
189
+ const INFERENCE_QUIET_MS = 5000;
190
+ /**
191
+ * How many consecutive contradicting samples clear a request.
192
+ *
193
+ * More than one because a single sample can catch a real dispatch mid-flight;
194
+ * small because the status sampler runs about once a second while anything
195
+ * claims to be busy, so a genuine leak is gone in seconds rather than surviving
196
+ * until someone restarts the service.
197
+ */
198
+ const INFERENCE_STRIKES = 3;
182
199
  /**
183
200
  * Which in-flight completions are currently mid-thought.
184
201
  *
@@ -197,18 +214,9 @@ export class ReasoningTracker {
197
214
  constructor() {
198
215
  _ReasoningTracker_instances.add(this);
199
216
  _ReasoningTracker_requests.set(this, new Map());
200
- /**
201
- * The llama-server slot a request was pinned to at dispatch, so its proxy-side
202
- * stage can be attributed to the engine row the panel actually shows. Set once
203
- * per request (see `setSlot`), never on the per-chunk path.
204
- */
205
- _ReasoningTracker_slots.set(this, new Map());
206
- /** Tail of the last transport chunk, so a field name split by TCP is still detected. */
207
- _ReasoningTracker_tails.set(this, new Map());
208
- /** Models/runtimes that leave reasoning inline as `<think>…</think>`. */
209
- _ReasoningTracker_inlineReasoning.set(this, new Set());
210
217
  _ReasoningTracker_listeners.set(this, new Set());
211
- _ReasoningTracker_lastSnapshot.set(this, "0:0:0:0");
218
+ _ReasoningTracker_lastSnapshot.set(this, "");
219
+ _ReasoningTracker_counter.set(this, 0);
212
220
  }
213
221
  /**
214
222
  * Watch stage counts, not the per-chunk traffic behind them.
@@ -221,74 +229,142 @@ export class ReasoningTracker {
221
229
  __classPrivateFieldGet(this, _ReasoningTracker_listeners, "f").add(listener);
222
230
  return () => __classPrivateFieldGet(this, _ReasoningTracker_listeners, "f").delete(listener);
223
231
  }
224
- /** A completion was dispatched to llama-server and awaits its first output delta. */
225
- begin(requestId) {
226
- if (__classPrivateFieldGet(this, _ReasoningTracker_requests, "f").has(requestId))
227
- return;
228
- __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "processing");
229
- __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
230
- }
231
232
  /**
232
- * Record the engine slot this request was pinned to. Called exactly once per
233
- * request, at dispatch - the pin is injected into the outbound body before
234
- * the request goes out, so the association exists before the first chunk and
235
- * `observe` never has to learn about it.
233
+ * Open a lease for a completion that has just been dispatched to
234
+ * llama-server and awaits its first output delta.
235
+ *
236
+ * A lease rather than an id the caller carries around, because every stuck
237
+ * "thinking" this tracker has produced was a release that did not happen on
238
+ * some branch of the proxy's event wiring. A lease makes both halves of that
239
+ * bug unrepresentable: nothing can advance a request without holding its
240
+ * lease, and a released lease is inert, so a chunk that lands after the
241
+ * release cannot resurrect the request it belongs to. (The same shape as
242
+ * `beginActivity` above, for the same reason.)
236
243
  *
237
- * Idempotent and self-cleaning: a repeat for the same slot is a no-op, and a
238
- * different slot replaces it, so a request that somehow moves slots (a
239
- * restarted engine hands a task out again) reports where it is now.
244
+ * Ids are minted here rather than by the caller: two callers sharing one id
245
+ * would silently share one request's state.
240
246
  */
241
- setSlot(requestId, slotId) {
242
- if (!Number.isInteger(slotId) || slotId < 0)
243
- return;
244
- if (__classPrivateFieldGet(this, _ReasoningTracker_slots, "f").get(requestId) === slotId)
245
- return;
246
- __classPrivateFieldGet(this, _ReasoningTracker_slots, "f").set(requestId, slotId);
247
+ begin() {
248
+ __classPrivateFieldSet(this, _ReasoningTracker_counter, __classPrivateFieldGet(this, _ReasoningTracker_counter, "f") + 1, "f");
249
+ const id = `s${__classPrivateFieldGet(this, _ReasoningTracker_counter, "f")}`;
250
+ const now = Date.now();
251
+ __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(id, {
252
+ stage: "processing",
253
+ slotId: null,
254
+ tail: "",
255
+ inlineReasoning: false,
256
+ startedAt: now,
257
+ lastSignalAt: now,
258
+ strikes: 0,
259
+ });
247
260
  __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
261
+ let open = true;
262
+ return {
263
+ id,
264
+ observe: (text) => {
265
+ if (!open)
266
+ return;
267
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_observe).call(this, id, text);
268
+ },
269
+ setSlot: (slotId) => {
270
+ if (!open)
271
+ return;
272
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_setSlot).call(this, id, slotId);
273
+ },
274
+ end: () => {
275
+ if (!open)
276
+ return;
277
+ open = false;
278
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_drop).call(this, [id]);
279
+ },
280
+ };
248
281
  }
249
- /** Note a chunk of `requestId`'s stream. Cheap enough to call per chunk. */
250
- observe(requestId, text) {
251
- const current = __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").get(requestId);
252
- if (current === "generating")
253
- return;
254
- if (!current)
255
- __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "processing");
256
- // Node can split an SSE JSON field name at any byte. Keeping a small tail
257
- // makes stage recognition independent of transport chunk boundaries without
258
- // parsing or retaining the generated content itself.
259
- const combined = `${__classPrivateFieldGet(this, _ReasoningTracker_tails, "f").get(requestId) ?? ""}${text}`;
260
- __classPrivateFieldGet(this, _ReasoningTracker_tails, "f").set(requestId, combined.slice(-128));
261
- if (__classPrivateFieldGet(this, _ReasoningTracker_inlineReasoning, "f").has(requestId)) {
262
- if (combined.includes("</think>")) {
263
- __classPrivateFieldGet(this, _ReasoningTracker_inlineReasoning, "f").delete(requestId);
264
- __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "generating");
265
- __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
266
- }
267
- return;
268
- }
269
- if (combined.includes("<think>")) {
270
- __classPrivateFieldGet(this, _ReasoningTracker_inlineReasoning, "f").add(requestId);
271
- __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "thinking");
272
- __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
273
- return;
274
- }
275
- if (chunkHasContent(combined)) {
276
- __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "generating");
277
- __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
278
- return;
282
+ /**
283
+ * Forget every slot pin, because the engine's slots did not survive its
284
+ * relaunch.
285
+ *
286
+ * The mirror of `Scheduler.forgetSlots`, and required for the same reason: a
287
+ * pin that outlives the process it named is no longer evidence. Worse than
288
+ * useless, in fact - a stale pin can collide with a NEW request's slot id,
289
+ * and the reaper would read that unrelated busy row as proof the dead request
290
+ * is still alive. Dropping the pins demotes those requests to the
291
+ * conservative unpinned rule, which clears them once the engine is quiet.
292
+ */
293
+ forgetSlots() {
294
+ let changed = false;
295
+ for (const state of __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").values()) {
296
+ if (state.slotId === null)
297
+ continue;
298
+ state.slotId = null;
299
+ changed = true;
279
300
  }
280
- if (chunkHasReasoning(combined) && current !== "thinking") {
281
- __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "thinking");
301
+ if (changed)
282
302
  __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
283
- }
284
303
  }
285
- /** Forget the request. Must be called on end *and* on error, or the flag sticks. */
286
- end(requestId) {
287
- __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").delete(requestId);
288
- __classPrivateFieldGet(this, _ReasoningTracker_slots, "f").delete(requestId);
289
- __classPrivateFieldGet(this, _ReasoningTracker_tails, "f").delete(requestId);
290
- __classPrivateFieldGet(this, _ReasoningTracker_inlineReasoning, "f").delete(requestId);
291
- __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
304
+ /**
305
+ * Drop tracked requests the engine's own account of itself contradicts.
306
+ *
307
+ * The safety net under the lease, and it exists because `active` outranks
308
+ * every engine signal on the rail: one release that never happened claims
309
+ * "thinking" until the service restarts. The ops tracker already refuses that
310
+ * bargain by probing the recorded pid, on the principle that a status stuck
311
+ * on "calibrating" forever is worse than no status at all. This is the
312
+ * inference half of the same rule.
313
+ *
314
+ * **It must never clear valid work**, so it acts only on positive evidence,
315
+ * and only on evidence a live request could not produce:
316
+ *
317
+ * 1. A request that has sent a chunk (or been pinned, or been dispatched)
318
+ * within `INFERENCE_QUIET_MS` is alive. A streaming request is therefore
319
+ * never a candidate at all, whatever the engine says this instant.
320
+ * 2. A PINNED request is checked against its own slot. llama-server marks a
321
+ * slot processing for the whole task, prefill included, so a request that
322
+ * is genuinely running makes its row busy. That row being idle is the
323
+ * contradiction. This is what lets one chat's leak be cleared while
324
+ * another chat keeps generating.
325
+ * 3. An UNPINNED request - or any request when the engine reports no
326
+ * per-slot rows - cannot be attributed to a row, so it is cleared only
327
+ * when the engine reports nothing running at all. Ambiguity is not
328
+ * evidence.
329
+ * 4. The contradiction has to hold for `INFERENCE_STRIKES` samples in a row.
330
+ * A single sample can race the dispatch window, where a request has been
331
+ * begun and the engine has not picked it up yet; a run of them cannot.
332
+ *
333
+ * A failed slot sample is not evidence either, and reconciles nothing.
334
+ *
335
+ * Returns what it reaped, so the caller can log a leak rather than silently
336
+ * paper over it.
337
+ */
338
+ reconcile(truth) {
339
+ if (truth.busyCount === null)
340
+ return [];
341
+ const now = Date.now();
342
+ const quietBefore = now - INFERENCE_QUIET_MS;
343
+ const reaped = [];
344
+ for (const [id, state] of __classPrivateFieldGet(this, _ReasoningTracker_requests, "f")) {
345
+ if (state.lastSignalAt > quietBefore) {
346
+ state.strikes = 0;
347
+ continue;
348
+ }
349
+ const contradicted = state.slotId !== null && truth.busySlots
350
+ ? !truth.busySlots.has(state.slotId)
351
+ : truth.busyCount === 0;
352
+ if (!contradicted) {
353
+ state.strikes = 0;
354
+ continue;
355
+ }
356
+ state.strikes += 1;
357
+ if (state.strikes < INFERENCE_STRIKES)
358
+ continue;
359
+ reaped.push({
360
+ id,
361
+ stage: state.stage,
362
+ slotId: state.slotId,
363
+ ageMs: now - state.startedAt,
364
+ });
365
+ }
366
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_drop).call(this, reaped.map((entry) => entry.id));
367
+ return reaped;
292
368
  }
293
369
  get active() {
294
370
  return this.snapshot.thinking > 0;
@@ -305,19 +381,18 @@ export class ReasoningTracker {
305
381
  generating: 0,
306
382
  };
307
383
  let slotStages;
308
- for (const [requestId, stage] of __classPrivateFieldGet(this, _ReasoningTracker_requests, "f")) {
309
- result[stage] += 1;
310
- const slot = __classPrivateFieldGet(this, _ReasoningTracker_slots, "f").get(requestId);
311
- if (slot === undefined)
384
+ for (const state of __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").values()) {
385
+ result[state.stage] += 1;
386
+ if (state.slotId === null)
312
387
  continue;
313
- (slotStages ?? (slotStages = {}))[String(slot)] = stage;
388
+ (slotStages ?? (slotStages = {}))[String(state.slotId)] = state.stage;
314
389
  }
315
390
  if (slotStages)
316
391
  result.slotStages = slotStages;
317
392
  return result;
318
393
  }
319
394
  }
320
- _ReasoningTracker_requests = new WeakMap(), _ReasoningTracker_slots = new WeakMap(), _ReasoningTracker_tails = new WeakMap(), _ReasoningTracker_inlineReasoning = new WeakMap(), _ReasoningTracker_listeners = new WeakMap(), _ReasoningTracker_lastSnapshot = new WeakMap(), _ReasoningTracker_instances = new WeakSet(), _ReasoningTracker_announce = function _ReasoningTracker_announce() {
395
+ _ReasoningTracker_requests = new WeakMap(), _ReasoningTracker_listeners = new WeakMap(), _ReasoningTracker_lastSnapshot = new WeakMap(), _ReasoningTracker_counter = new WeakMap(), _ReasoningTracker_instances = new WeakSet(), _ReasoningTracker_announce = function _ReasoningTracker_announce() {
321
396
  const snapshot = this.snapshot;
322
397
  // The slot join rides in the key too: pinning a request to a slot is a
323
398
  // state change even when no stage count moves, and it is the field the
@@ -341,5 +416,58 @@ _ReasoningTracker_requests = new WeakMap(), _ReasoningTracker_slots = new WeakMa
341
416
  // Status reporting must never break a proxied completion.
342
417
  }
343
418
  }
419
+ }, _ReasoningTracker_setSlot = function _ReasoningTracker_setSlot(id, slotId) {
420
+ if (!Number.isInteger(slotId) || slotId < 0)
421
+ return;
422
+ const state = __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").get(id);
423
+ if (!state || state.slotId === slotId)
424
+ return;
425
+ state.slotId = slotId;
426
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_touch).call(this, state);
427
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
428
+ }, _ReasoningTracker_observe = function _ReasoningTracker_observe(id, text) {
429
+ const state = __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").get(id);
430
+ if (!state)
431
+ return;
432
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_touch).call(this, state);
433
+ if (state.stage === "generating")
434
+ return;
435
+ // Node can split an SSE JSON field name at any byte. Keeping a small tail
436
+ // makes stage recognition independent of transport chunk boundaries without
437
+ // parsing or retaining the generated content itself.
438
+ const combined = `${state.tail}${text}`;
439
+ state.tail = combined.slice(-128);
440
+ if (state.inlineReasoning) {
441
+ if (combined.includes("</think>")) {
442
+ state.inlineReasoning = false;
443
+ state.stage = "generating";
444
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
445
+ }
446
+ return;
447
+ }
448
+ if (combined.includes("<think>")) {
449
+ state.inlineReasoning = true;
450
+ state.stage = "thinking";
451
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
452
+ return;
453
+ }
454
+ if (chunkHasContent(combined)) {
455
+ state.stage = "generating";
456
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
457
+ return;
458
+ }
459
+ if (chunkHasReasoning(combined) && state.stage !== "thinking") {
460
+ state.stage = "thinking";
461
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
462
+ }
463
+ }, _ReasoningTracker_touch = function _ReasoningTracker_touch(state) {
464
+ state.lastSignalAt = Date.now();
465
+ state.strikes = 0;
466
+ }, _ReasoningTracker_drop = function _ReasoningTracker_drop(ids) {
467
+ let removed = false;
468
+ for (const id of ids)
469
+ removed = __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").delete(id) || removed;
470
+ if (removed)
471
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
344
472
  };
345
473
  //# sourceMappingURL=activity.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