@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.
@@ -0,0 +1,271 @@
1
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
2
+ if (kind === "m") throw new TypeError("Private method is not writable");
3
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
4
+ 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");
5
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
6
+ };
7
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
8
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
9
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
+ };
12
+ var _ModelProcessPool_instances, _ModelProcessPool_createSupervisor, _ModelProcessPool_createScheduler, _ModelProcessPool_loadModel, _ModelProcessPool_onChange, _ModelProcessPool_logger, _ModelProcessPool_slots, _ModelProcessPool_queue, _ModelProcessPool_maxModels, _ModelProcessPool_busy, _ModelProcessPool_dirty, _ModelProcessPool_clock, _ModelProcessPool_makeSlot, _ModelProcessPool_announce, _ModelProcessPool_dispatch, _ModelProcessPool_pass, _ModelProcessPool_slotFor, _ModelProcessPool_nextSlotIndex, _ModelProcessPool_releaseSlot, _ModelProcessPool_trimIdleSlots;
13
+ /**
14
+ * Global admission and eviction boundary for independently hosted models.
15
+ *
16
+ * Each resident model owns one Supervisor/Scheduler pair and therefore one
17
+ * llama-server process, port, KV pool, and lifecycle. Requests for a resident
18
+ * model go directly to that process. An unloaded model claims a free process
19
+ * slot, or evicts the least-recently-used idle process when the configured
20
+ * model limit is full. Busy processes are never evicted; the request remains
21
+ * queued until one becomes idle.
22
+ */
23
+ export class ModelProcessPool {
24
+ constructor(options) {
25
+ _ModelProcessPool_instances.add(this);
26
+ _ModelProcessPool_createSupervisor.set(this, void 0);
27
+ _ModelProcessPool_createScheduler.set(this, void 0);
28
+ _ModelProcessPool_loadModel.set(this, void 0);
29
+ _ModelProcessPool_onChange.set(this, void 0);
30
+ _ModelProcessPool_logger.set(this, void 0);
31
+ _ModelProcessPool_slots.set(this, []);
32
+ _ModelProcessPool_queue.set(this, []);
33
+ _ModelProcessPool_maxModels.set(this, void 0);
34
+ _ModelProcessPool_busy.set(this, false);
35
+ _ModelProcessPool_dirty.set(this, false);
36
+ _ModelProcessPool_clock.set(this, 0);
37
+ __classPrivateFieldSet(this, _ModelProcessPool_createSupervisor, options.createSupervisor, "f");
38
+ __classPrivateFieldSet(this, _ModelProcessPool_createScheduler, options.createScheduler, "f");
39
+ __classPrivateFieldSet(this, _ModelProcessPool_loadModel, options.loadModel, "f");
40
+ __classPrivateFieldSet(this, _ModelProcessPool_onChange, options.onChange ?? null, "f");
41
+ __classPrivateFieldSet(this, _ModelProcessPool_logger, options.logger ?? null, "f");
42
+ __classPrivateFieldSet(this, _ModelProcessPool_maxModels, normalizeModelLimit(options.maxModels), "f");
43
+ __classPrivateFieldGet(this, _ModelProcessPool_slots, "f").push(__classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_makeSlot).call(this, 0, options.initialSupervisor));
44
+ }
45
+ get maxModels() {
46
+ return __classPrivateFieldGet(this, _ModelProcessPool_maxModels, "f");
47
+ }
48
+ submit(model, run, options = {}) {
49
+ return new Promise((resolve, reject) => {
50
+ __classPrivateFieldGet(this, _ModelProcessPool_queue, "f").push({ model, run, options, resolve, reject });
51
+ __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_announce).call(this);
52
+ queueMicrotask(() => void __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_dispatch).call(this));
53
+ });
54
+ }
55
+ /** Load a model and leave it resident without consuming an inference slot. */
56
+ async preload(model) {
57
+ await this.submit(model, async () => undefined);
58
+ }
59
+ /** Apply the host-owned process limit and retire excess idle processes. */
60
+ async configure(maxModels) {
61
+ __classPrivateFieldSet(this, _ModelProcessPool_maxModels, normalizeModelLimit(maxModels), "f");
62
+ await __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_trimIdleSlots).call(this);
63
+ __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_announce).call(this);
64
+ void __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_dispatch).call(this);
65
+ }
66
+ supervisorFor(modelId) {
67
+ return (__classPrivateFieldGet(this, _ModelProcessPool_slots, "f").find((slot) => slot.assignedModelId === modelId ||
68
+ (slot.supervisor.model?.id === modelId && slot.supervisor.state !== "stopped"))?.supervisor ?? null);
69
+ }
70
+ supervisors() {
71
+ return __classPrivateFieldGet(this, _ModelProcessPool_slots, "f").map((slot) => slot.supervisor);
72
+ }
73
+ /** Complete statuses for every process that currently owns a model. */
74
+ residentSupervisors() {
75
+ return __classPrivateFieldGet(this, _ModelProcessPool_slots, "f")
76
+ .filter((slot) => slot.assignedModelId !== null && slot.supervisor.state !== "stopped")
77
+ .map((slot) => slot.supervisor);
78
+ }
79
+ reservationFor(modelId) {
80
+ return __classPrivateFieldGet(this, _ModelProcessPool_slots, "f").find((slot) => slot.assignedModelId === modelId)?.reservationBytes ?? 0;
81
+ }
82
+ async unload(modelId) {
83
+ const targets = modelId
84
+ ? __classPrivateFieldGet(this, _ModelProcessPool_slots, "f").filter((slot) => slot.assignedModelId === modelId)
85
+ : [...__classPrivateFieldGet(this, _ModelProcessPool_slots, "f")];
86
+ for (const slot of targets) {
87
+ if (!slot.scheduler.isIdle || slot.pending > 0) {
88
+ throw new Error(modelId ? "the model is still serving requests" : "a model is still serving requests");
89
+ }
90
+ }
91
+ await Promise.all(targets.map((slot) => __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_releaseSlot).call(this, slot)));
92
+ await __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_trimIdleSlots).call(this);
93
+ __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_announce).call(this);
94
+ void __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_dispatch).call(this);
95
+ }
96
+ async stop() {
97
+ const error = new Error("Brain process pool stopped");
98
+ for (const job of __classPrivateFieldGet(this, _ModelProcessPool_queue, "f").splice(0))
99
+ job.reject(error);
100
+ await Promise.all(__classPrivateFieldGet(this, _ModelProcessPool_slots, "f").map((slot) => slot.supervisor.stop()));
101
+ __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_announce).call(this);
102
+ }
103
+ forgetSlots() {
104
+ for (const slot of __classPrivateFieldGet(this, _ModelProcessPool_slots, "f"))
105
+ slot.scheduler.forgetSlots();
106
+ }
107
+ stats() {
108
+ const waiting = {};
109
+ const waitingModelIds = {};
110
+ let queued = 0;
111
+ let lastTurn = null;
112
+ let active = null;
113
+ for (const job of __classPrivateFieldGet(this, _ModelProcessPool_queue, "f")) {
114
+ queued += 1;
115
+ waiting[job.model.displayName] = (waiting[job.model.displayName] ?? 0) + 1;
116
+ waitingModelIds[job.model.id] = (waitingModelIds[job.model.id] ?? 0) + 1;
117
+ }
118
+ for (const slot of __classPrivateFieldGet(this, _ModelProcessPool_slots, "f")) {
119
+ const stats = slot.scheduler.stats();
120
+ queued += stats.queued;
121
+ for (const [name, count] of Object.entries(stats.waiting)) {
122
+ waiting[name] = (waiting[name] ?? 0) + count;
123
+ }
124
+ for (const [id, count] of Object.entries(stats.waitingModelIds)) {
125
+ waitingModelIds[id] = (waitingModelIds[id] ?? 0) + count;
126
+ }
127
+ lastTurn = stats.lastTurn ?? lastTurn;
128
+ active = active ?? stats.active;
129
+ }
130
+ return { queued, waiting, waitingModelIds, lastTurn, active };
131
+ }
132
+ }
133
+ _ModelProcessPool_createSupervisor = new WeakMap(), _ModelProcessPool_createScheduler = new WeakMap(), _ModelProcessPool_loadModel = new WeakMap(), _ModelProcessPool_onChange = new WeakMap(), _ModelProcessPool_logger = new WeakMap(), _ModelProcessPool_slots = new WeakMap(), _ModelProcessPool_queue = new WeakMap(), _ModelProcessPool_maxModels = new WeakMap(), _ModelProcessPool_busy = new WeakMap(), _ModelProcessPool_dirty = new WeakMap(), _ModelProcessPool_clock = new WeakMap(), _ModelProcessPool_instances = new WeakSet(), _ModelProcessPool_makeSlot = function _ModelProcessPool_makeSlot(index, supervisor) {
134
+ var _a;
135
+ const slot = {
136
+ index,
137
+ supervisor,
138
+ scheduler: null,
139
+ assignedModelId: supervisor.model?.id ?? null,
140
+ pending: 0,
141
+ reservationBytes: 0,
142
+ lastUsedAt: __classPrivateFieldSet(this, _ModelProcessPool_clock, (_a = __classPrivateFieldGet(this, _ModelProcessPool_clock, "f"), ++_a), "f"),
143
+ };
144
+ slot.scheduler = __classPrivateFieldGet(this, _ModelProcessPool_createScheduler, "f").call(this, supervisor, async (model) => {
145
+ var _a;
146
+ const reservedElsewhereBytes = __classPrivateFieldGet(this, _ModelProcessPool_slots, "f").reduce((total, candidate) => (candidate === slot ? total : total + candidate.reservationBytes), 0);
147
+ slot.reservationBytes = await __classPrivateFieldGet(this, _ModelProcessPool_loadModel, "f").call(this, supervisor, model, reservedElsewhereBytes);
148
+ slot.assignedModelId = model.id;
149
+ slot.lastUsedAt = __classPrivateFieldSet(this, _ModelProcessPool_clock, (_a = __classPrivateFieldGet(this, _ModelProcessPool_clock, "f"), ++_a), "f");
150
+ __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_announce).call(this);
151
+ }, () => {
152
+ __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_announce).call(this);
153
+ void __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_dispatch).call(this);
154
+ });
155
+ return slot;
156
+ }, _ModelProcessPool_announce = function _ModelProcessPool_announce() {
157
+ try {
158
+ __classPrivateFieldGet(this, _ModelProcessPool_onChange, "f")?.call(this);
159
+ }
160
+ catch {
161
+ // Status observers are not allowed to fail admission or eviction.
162
+ }
163
+ }, _ModelProcessPool_dispatch = async function _ModelProcessPool_dispatch() {
164
+ if (__classPrivateFieldGet(this, _ModelProcessPool_busy, "f")) {
165
+ __classPrivateFieldSet(this, _ModelProcessPool_dirty, true, "f");
166
+ return;
167
+ }
168
+ __classPrivateFieldSet(this, _ModelProcessPool_busy, true, "f");
169
+ try {
170
+ do {
171
+ __classPrivateFieldSet(this, _ModelProcessPool_dirty, false, "f");
172
+ await __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_pass).call(this);
173
+ } while (__classPrivateFieldGet(this, _ModelProcessPool_dirty, "f"));
174
+ }
175
+ finally {
176
+ __classPrivateFieldSet(this, _ModelProcessPool_busy, false, "f");
177
+ }
178
+ }, _ModelProcessPool_pass = async function _ModelProcessPool_pass() {
179
+ var _a;
180
+ await __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_trimIdleSlots).call(this);
181
+ for (;;) {
182
+ let selectedIndex = -1;
183
+ let selectedSlot = null;
184
+ for (let index = 0; index < __classPrivateFieldGet(this, _ModelProcessPool_queue, "f").length; index += 1) {
185
+ const candidate = __classPrivateFieldGet(this, _ModelProcessPool_queue, "f")[index];
186
+ const slot = await __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_slotFor).call(this, candidate.model);
187
+ if (!slot)
188
+ continue;
189
+ selectedIndex = index;
190
+ selectedSlot = slot;
191
+ break;
192
+ }
193
+ if (selectedIndex < 0 || !selectedSlot)
194
+ return;
195
+ const [job] = __classPrivateFieldGet(this, _ModelProcessPool_queue, "f").splice(selectedIndex, 1);
196
+ if (!job)
197
+ return;
198
+ const slot = selectedSlot;
199
+ slot.pending += 1;
200
+ slot.lastUsedAt = __classPrivateFieldSet(this, _ModelProcessPool_clock, (_a = __classPrivateFieldGet(this, _ModelProcessPool_clock, "f"), ++_a), "f");
201
+ __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_announce).call(this);
202
+ void slot.scheduler
203
+ .submit(job.model, job.run, job.options)
204
+ .then(job.resolve, job.reject)
205
+ .finally(() => {
206
+ var _a;
207
+ slot.pending = Math.max(0, slot.pending - 1);
208
+ slot.lastUsedAt = __classPrivateFieldSet(this, _ModelProcessPool_clock, (_a = __classPrivateFieldGet(this, _ModelProcessPool_clock, "f"), ++_a), "f");
209
+ if (slot.supervisor.state === "failed" && slot.pending === 0) {
210
+ void __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_releaseSlot).call(this, slot).finally(() => void __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_dispatch).call(this));
211
+ }
212
+ __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_announce).call(this);
213
+ void __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_dispatch).call(this);
214
+ });
215
+ }
216
+ }, _ModelProcessPool_slotFor = async function _ModelProcessPool_slotFor(model) {
217
+ const resident = __classPrivateFieldGet(this, _ModelProcessPool_slots, "f").find((slot) => slot.assignedModelId === model.id);
218
+ if (resident)
219
+ return resident;
220
+ let empty = __classPrivateFieldGet(this, _ModelProcessPool_slots, "f").find((slot) => slot.assignedModelId === null && slot.scheduler.isIdle && slot.pending === 0);
221
+ if (!empty && __classPrivateFieldGet(this, _ModelProcessPool_slots, "f").length < __classPrivateFieldGet(this, _ModelProcessPool_maxModels, "f")) {
222
+ const index = __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_nextSlotIndex).call(this);
223
+ empty = __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_makeSlot).call(this, index, __classPrivateFieldGet(this, _ModelProcessPool_createSupervisor, "f").call(this, index));
224
+ __classPrivateFieldGet(this, _ModelProcessPool_slots, "f").push(empty);
225
+ }
226
+ if (empty) {
227
+ empty.assignedModelId = model.id;
228
+ return empty;
229
+ }
230
+ const evictable = __classPrivateFieldGet(this, _ModelProcessPool_slots, "f")
231
+ .filter((slot) => slot.scheduler.isIdle && slot.pending === 0)
232
+ .sort((left, right) => left.lastUsedAt - right.lastUsedAt)[0];
233
+ if (!evictable)
234
+ return null;
235
+ __classPrivateFieldGet(this, _ModelProcessPool_logger, "f")?.call(this, `evicting ${evictable.supervisor.model?.displayName ?? "idle model"} for ${model.displayName}`);
236
+ await __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_releaseSlot).call(this, evictable);
237
+ evictable.assignedModelId = model.id;
238
+ return evictable;
239
+ }, _ModelProcessPool_nextSlotIndex = function _ModelProcessPool_nextSlotIndex() {
240
+ const used = new Set(__classPrivateFieldGet(this, _ModelProcessPool_slots, "f").map((slot) => slot.index));
241
+ let index = 0;
242
+ while (used.has(index))
243
+ index += 1;
244
+ return index;
245
+ }, _ModelProcessPool_releaseSlot = async function _ModelProcessPool_releaseSlot(slot) {
246
+ var _a;
247
+ await slot.supervisor.stop();
248
+ slot.scheduler.forgetSlots();
249
+ slot.assignedModelId = null;
250
+ slot.reservationBytes = 0;
251
+ slot.lastUsedAt = __classPrivateFieldSet(this, _ModelProcessPool_clock, (_a = __classPrivateFieldGet(this, _ModelProcessPool_clock, "f"), ++_a), "f");
252
+ }, _ModelProcessPool_trimIdleSlots = async function _ModelProcessPool_trimIdleSlots() {
253
+ if (__classPrivateFieldGet(this, _ModelProcessPool_slots, "f").length <= __classPrivateFieldGet(this, _ModelProcessPool_maxModels, "f"))
254
+ return;
255
+ const removable = __classPrivateFieldGet(this, _ModelProcessPool_slots, "f")
256
+ .filter((slot) => slot.scheduler.isIdle && slot.pending === 0)
257
+ .sort((left, right) => left.lastUsedAt - right.lastUsedAt);
258
+ while (__classPrivateFieldGet(this, _ModelProcessPool_slots, "f").length > __classPrivateFieldGet(this, _ModelProcessPool_maxModels, "f") && removable.length > 0) {
259
+ const slot = removable.shift();
260
+ await __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_releaseSlot).call(this, slot);
261
+ const index = __classPrivateFieldGet(this, _ModelProcessPool_slots, "f").indexOf(slot);
262
+ if (index >= 0)
263
+ __classPrivateFieldGet(this, _ModelProcessPool_slots, "f").splice(index, 1);
264
+ }
265
+ };
266
+ export function normalizeModelLimit(value) {
267
+ if (!Number.isFinite(value))
268
+ return 1;
269
+ return Math.max(1, Math.min(16, Math.floor(value)));
270
+ }
271
+ //# sourceMappingURL=process-pool.js.map
@@ -1,5 +1,5 @@
1
1
  import http from "node:http";
2
- import { Scheduler } from "./scheduler.js";
2
+ import { type ModelScheduler } from "./scheduler.js";
3
3
  import type { Supervisor } from "./supervisor.js";
4
4
  import { type RankedModel } from "../ops/results.js";
5
5
  import type { GpuInfo, Model } from "../types.js";
@@ -108,7 +108,7 @@ export declare function describeModel(model: Model | null, options?: DescribeOpt
108
108
  * the supervisor is running marked 'loaded'. Falls back to just the running
109
109
  * model when no catalog provider is wired in.
110
110
  */
111
- export declare function buildModelList(supervisor: Supervisor, getCatalog: GetCatalog): ModelEntry[];
111
+ export declare function buildModelList(supervisor: Supervisor, getCatalog: GetCatalog, scheduler?: ModelScheduler<Supervisor> | null): ModelEntry[];
112
112
  /**
113
113
  * Map an OpenAI-compatible effort request onto a model's own chat-template
114
114
  * arguments. llama.cpp does not know every model's dialect: Qwen3.8 calls the
@@ -207,6 +207,7 @@ export declare function decideModelGate(params: {
207
207
  lockModel: boolean;
208
208
  requestedName: string | null;
209
209
  pinned: Model | null;
210
+ pinnedModels?: Model[];
210
211
  resolved: Model | null;
211
212
  }): ModelGateResult;
212
213
  export interface RouterOptions {
@@ -227,6 +228,8 @@ export interface RouterOptions {
227
228
  getLockModel?: () => boolean;
228
229
  /** Live: the configured default model, the pin target before one is resident. */
229
230
  getDefaultModel?: () => string | null;
231
+ /** Live: the complete model set served while locking is enabled. */
232
+ getLockedModels?: () => string[];
230
233
  /**
231
234
  * Apply an editable config patch (write config.json, live-switch the model,
232
235
  * update the lock), for POST /__host/config. Absent = the write endpoint is
@@ -262,8 +265,8 @@ export interface RouterOptions {
262
265
  */
263
266
  statusEvents?: BrainStatusPublisher | null;
264
267
  /** A service shares this scheduler with host-owned model operations. */
265
- scheduler?: Scheduler | null;
268
+ scheduler?: ModelScheduler<Supervisor> | null;
266
269
  }
267
- export declare function createRouter({ supervisor, telemetry, logger, getCatalog, loadModel, loadRanking, queryGpuInfo, version, getConfig, getEvals, getLockModel, getDefaultModel, applyConfigPatch, getAllowConfigWrite, hostApi, getResources, statusEvents, scheduler: suppliedScheduler, }: RouterOptions): (req: http.IncomingMessage, res: http.ServerResponse) => void;
270
+ export declare function createRouter({ supervisor, telemetry, logger, getCatalog, loadModel, loadRanking, queryGpuInfo, version, getConfig, getEvals, getLockModel, getDefaultModel, getLockedModels, applyConfigPatch, getAllowConfigWrite, hostApi, getResources, statusEvents, scheduler: suppliedScheduler, }: RouterOptions): (req: http.IncomingMessage, res: http.ServerResponse) => void;
268
271
  export {};
269
272
  //# sourceMappingURL=router.d.ts.map
@@ -205,30 +205,39 @@ function resolveCatalog(getCatalog) {
205
205
  * the supervisor is running marked 'loaded'. Falls back to just the running
206
206
  * model when no catalog provider is wired in.
207
207
  */
208
- export function buildModelList(supervisor, getCatalog) {
209
- const loadedId = supervisor.model ? supervisor.model.id : null;
208
+ export function buildModelList(supervisor, getCatalog, scheduler = null) {
209
+ const supervisors = scheduler && typeof scheduler.supervisors === "function"
210
+ ? scheduler.supervisors()
211
+ : [supervisor];
212
+ const residentFor = (modelId) => supervisors.find((candidate) => candidate.model?.id === modelId && candidate.state !== "stopped") ?? null;
210
213
  const stateOf = (model) => {
211
- if (!loadedId || model.id !== loadedId)
214
+ const resident = residentFor(model.id);
215
+ if (!resident)
212
216
  return "not-loaded";
213
- if (supervisor.state === "ready")
217
+ if (resident.state === "ready")
214
218
  return "loaded";
215
- if (supervisor.state === "starting")
219
+ if (resident.state === "starting")
216
220
  return "loading";
217
221
  return "not-loaded";
218
222
  };
219
223
  let catalog = resolveCatalog(getCatalog);
220
224
  // Guarantee the running model appears even if the snapshot predates it.
221
- if (supervisor.model && !catalog.some((m) => m.id === loadedId)) {
222
- catalog = [supervisor.model, ...catalog];
225
+ for (const resident of supervisors) {
226
+ if (resident.model && !catalog.some((model) => model.id === resident.model?.id)) {
227
+ catalog.unshift(resident.model);
228
+ }
223
229
  }
224
- return catalog.map((model) => describeModel(model, {
225
- state: stateOf(model),
226
- profile: model.id === loadedId ? supervisor.profile : null,
227
- createdAt: model.id === loadedId ? supervisor.startedAt : null,
228
- }));
230
+ return catalog.map((model) => {
231
+ const resident = residentFor(model.id);
232
+ return describeModel(model, {
233
+ state: stateOf(model),
234
+ profile: resident?.profile ?? null,
235
+ createdAt: resident?.startedAt ?? null,
236
+ });
237
+ });
229
238
  }
230
239
  /** Handle the model-discovery endpoints ourselves; returns true if it did. */
231
- function handleModelsRoute(req, res, supervisor, getCatalog) {
240
+ function handleModelsRoute(req, res, supervisor, getCatalog, scheduler = null) {
232
241
  if (req.method !== "GET")
233
242
  return false;
234
243
  const url = (req.url || "").split("?")[0];
@@ -238,7 +247,7 @@ function handleModelsRoute(req, res, supervisor, getCatalog) {
238
247
  : null;
239
248
  if (!isList && single === null)
240
249
  return false;
241
- const list = buildModelList(supervisor, getCatalog);
250
+ const list = buildModelList(supervisor, getCatalog, scheduler);
242
251
  let payload;
243
252
  if (single !== null) {
244
253
  const entry = list.find((e) => e.id === single);
@@ -658,19 +667,24 @@ export function injectSystemAddendum(body, addendum, shape) {
658
667
  * a switch; an unnamed request rides the pin.
659
668
  */
660
669
  export function decideModelGate(params) {
661
- const { lockModel, requestedName, pinned, resolved } = params;
670
+ const { lockModel, requestedName, pinned, pinnedModels = pinned ? [pinned] : [], resolved, } = params;
662
671
  if (lockModel) {
663
- if (!pinned) {
664
- return { ok: false, status: 503, message: "no model is loaded yet on this locked host" };
672
+ if (pinnedModels.length === 0) {
673
+ return { ok: false, status: 503, message: "no models are selected on this locked host" };
665
674
  }
666
- if (requestedName && requestedName !== pinned.id && requestedName !== pinned.displayName) {
675
+ const selected = requestedName
676
+ ? pinnedModels.find((model) => model.id === requestedName || model.displayName === requestedName)
677
+ : pinnedModels[0];
678
+ if (!selected) {
667
679
  return {
668
680
  ok: false,
669
681
  status: 409,
670
- message: `model switching is disabled on this host; only "${pinned.displayName}" is served`,
682
+ message: `model switching is disabled on this host; served models: ${pinnedModels
683
+ .map((model) => `"${model.displayName}"`)
684
+ .join(", ")}`,
671
685
  };
672
686
  }
673
- return { ok: true, model: pinned };
687
+ return { ok: true, model: selected };
674
688
  }
675
689
  if (!resolved) {
676
690
  return {
@@ -684,7 +698,7 @@ export function decideModelGate(params) {
684
698
  return { ok: true, model: resolved };
685
699
  }
686
700
  /** Buffer a completion request, resolve its target model, and queue it. */
687
- function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, scheduler, modelGate, }) {
701
+ function scheduleCompletion({ req, res, agent, telemetry, logger, scheduler, modelGate, }) {
688
702
  const chunks = [];
689
703
  let size = 0;
690
704
  let tooBig = false;
@@ -737,10 +751,10 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
737
751
  // relaunched - and not at dispatch time either, where a sibling admitted in
738
752
  // the same pass could see the same slot free and pin it too.
739
753
  let slot = null;
740
- const queued = scheduler.submit(model, () => proxyBuffered({
754
+ const queued = scheduler.submit(model, (resident) => proxyBuffered({
741
755
  agent,
742
756
  model,
743
- supervisor,
757
+ supervisor: resident,
744
758
  telemetry,
745
759
  logger,
746
760
  req,
@@ -758,7 +772,7 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
758
772
  // (rare), so the router caches the ranking and re-reads it at most once per
759
773
  // window - the cheap time-based trigger.
760
774
  const RANKING_TTL_MS = 60000;
761
- export function createRouter({ supervisor, telemetry, logger, getCatalog = null, loadModel = null, loadRanking = () => rankModels(), queryGpuInfo = queryGpu, version = null, getConfig = null, getEvals = null, getLockModel = () => false, getDefaultModel = () => null, applyConfigPatch = null, getAllowConfigWrite = () => false, hostApi = null, getResources = null, statusEvents = null, scheduler: suppliedScheduler = null, }) {
775
+ export function createRouter({ supervisor, telemetry, logger, getCatalog = null, loadModel = null, loadRanking = () => rankModels(), queryGpuInfo = queryGpu, version = null, getConfig = null, getEvals = null, getLockModel = () => false, getDefaultModel = () => null, getLockedModels = () => [], applyConfigPatch = null, getAllowConfigWrite = () => false, hostApi = null, getResources = null, statusEvents = null, scheduler: suppliedScheduler = null, }) {
762
776
  // llama-server may close an idle response socket while this scheduler holds
763
777
  // the next request in queue. A reused keep-alive socket then fails as
764
778
  // ECONNRESET ("socket hang up") before the queued request reaches inference.
@@ -851,11 +865,20 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
851
865
  // The single model this host will serve when switching is locked: the
852
866
  // resident model if one is up, else the configured default resolved through
853
867
  // the catalog. Null means nothing is loadable yet.
854
- const pinnedModel = () => {
855
- if (supervisor.model)
856
- return supervisor.model;
857
- const def = getDefaultModel();
858
- return def ? resolveModel(def) : null;
868
+ const pinnedModels = () => {
869
+ const configured = getLockedModels();
870
+ const names = configured.length > 0 ? configured : [getDefaultModel()].filter(Boolean);
871
+ const selected = names
872
+ .map((name) => resolveModel(name ?? null))
873
+ .filter((model) => model !== null);
874
+ if (selected.length > 0)
875
+ return selected;
876
+ const residents = scheduler && typeof scheduler.supervisors === "function"
877
+ ? scheduler.supervisors()
878
+ : [supervisor];
879
+ return residents
880
+ .map((candidate) => candidate.model)
881
+ .filter((model) => model !== null);
859
882
  };
860
883
  // Decide whether a completion for `name` may run. The lock/default are read
861
884
  // live so a POST /__host/config change takes effect without a restart. Only
@@ -865,7 +888,8 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
865
888
  return decideModelGate({
866
889
  lockModel: lock,
867
890
  requestedName: name,
868
- pinned: lock ? pinnedModel() : null,
891
+ pinned: null,
892
+ pinnedModels: lock ? pinnedModels() : [],
869
893
  resolved: lock ? null : resolveModel(name),
870
894
  });
871
895
  };
@@ -880,6 +904,10 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
880
904
  */
881
905
  const buildCheapStatus = async () => {
882
906
  const schedulerStats = scheduler ? scheduler.stats() : null;
907
+ const residentSupervisors = scheduler && typeof scheduler.supervisors === "function"
908
+ ? scheduler.supervisors()
909
+ : [supervisor];
910
+ const residents = residentSupervisors.filter((candidate) => candidate.model !== null && candidate.state !== "stopped");
883
911
  // Slots come from a loopback GET on the resident llama-server. That is
884
912
  // cheap enough to pay on every sample - unlike GPU sampling, which spawns
885
913
  // `nvidia-smi` and stays opt-in. Skipped entirely unless a model is
@@ -895,6 +923,7 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
895
923
  // package version.
896
924
  apiVersion: HOST_API_VERSION,
897
925
  ...supervisor.status(),
926
+ residents: residents.map((resident) => resident.status()),
898
927
  telemetry: { ...telemetry.totals, warning: telemetry.warning },
899
928
  scheduler: schedulerStats,
900
929
  recent: telemetry.records.slice(-10),
@@ -983,7 +1012,7 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
983
1012
  return;
984
1013
  // Answer model discovery ourselves so ids are real names (not paths), the
985
1014
  // whole catalog is listed, and each carries LM Studio's context fields.
986
- if (handleModelsRoute(req, res, supervisor, getCatalog))
1015
+ if (handleModelsRoute(req, res, supervisor, getCatalog, scheduler))
987
1016
  return;
988
1017
  // With a scheduler wired in, completion requests are queued and served in
989
1018
  // turns - including loading/switching to the model they ask for - instead
@@ -993,7 +1022,6 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
993
1022
  req,
994
1023
  res,
995
1024
  agent,
996
- supervisor,
997
1025
  telemetry,
998
1026
  logger,
999
1027
  scheduler,
@@ -84,6 +84,18 @@ export interface SchedulerSupervisor {
84
84
  model: Model | null;
85
85
  profile: Profile | null;
86
86
  }
87
+ /**
88
+ * The scheduler surface consumed by the router and host operations. A managed
89
+ * process pool implements the same contract while choosing which resident
90
+ * supervisor owns each submitted model.
91
+ */
92
+ export interface ModelScheduler<TSupervisor extends SchedulerSupervisor = SchedulerSupervisor> {
93
+ submit(model: Model, run: (supervisor: TSupervisor) => Promise<unknown>, options?: SchedulerSubmitOptions): Promise<unknown>;
94
+ stats(): SchedulerStats;
95
+ forgetSlots(): void;
96
+ supervisorFor(modelId: string): TSupervisor | null;
97
+ supervisors(): TSupervisor[];
98
+ }
87
99
  /**
88
100
  * The answer to a live slot measurement: how many sequence slots are free, and
89
101
  * optionally WHICH ones. `idle` drives admission (see CAPACITY); `ids` lets
@@ -115,8 +127,8 @@ export interface SlotMeasurement {
115
127
  * behavior but must never fail the completion that is about to run.
116
128
  */
117
129
  export type SlotEraser = (slotId: number) => Promise<void>;
118
- export interface SchedulerOptions {
119
- supervisor: SchedulerSupervisor;
130
+ export interface SchedulerOptions<TSupervisor extends SchedulerSupervisor = SchedulerSupervisor> {
131
+ supervisor: TSupervisor;
120
132
  loadModel: (model: Model) => Promise<void>;
121
133
  logger?: ((message: string) => void) | null;
122
134
  /**
@@ -192,7 +204,7 @@ export interface SchedulerSubmitOptions {
192
204
  onSlotFree?: ((slotId: number | null) => void) | null;
193
205
  }
194
206
  /** A queued request or host operation bound to a resolved catalog model. */
195
- export interface QueuedJob {
207
+ export interface QueuedJob<TSupervisor extends SchedulerSupervisor = SchedulerSupervisor> {
196
208
  modelId: string;
197
209
  model: Model;
198
210
  kind: SchedulerJobKind;
@@ -209,7 +221,7 @@ export interface QueuedJob {
209
221
  * busy behavior would serialize the pair onto it while another slot sat empty.
210
222
  */
211
223
  slotId: number | null;
212
- run: () => Promise<unknown>;
224
+ run: (supervisor: TSupervisor) => Promise<unknown>;
213
225
  resolve: (value: unknown) => void;
214
226
  reject: (error: unknown) => void;
215
227
  }
@@ -224,13 +236,13 @@ export interface SchedulerStats {
224
236
  kind: Exclude<SchedulerJobKind, "completion">;
225
237
  } | null;
226
238
  }
227
- export declare class Scheduler {
239
+ export declare class Scheduler<TSupervisor extends SchedulerSupervisor = SchedulerSupervisor> implements ModelScheduler<TSupervisor> {
228
240
  #private;
229
- supervisor: SchedulerSupervisor;
241
+ supervisor: TSupervisor;
230
242
  loadModel: (model: Model) => Promise<void>;
231
243
  logger: ((message: string) => void) | null;
232
244
  /** Submitted, not yet claimed by a turn. */
233
- queue: QueuedJob[];
245
+ queue: QueuedJob<TSupervisor>[];
234
246
  lastTurnId: string | null;
235
247
  /**
236
248
  * Per model: the session whose jobs most recently filled its slots. That
@@ -239,7 +251,7 @@ export declare class Scheduler {
239
251
  */
240
252
  hotSessions: Map<string, string>;
241
253
  /** The exclusive operation in flight, if any. Reported by `stats()`. */
242
- activeJob: QueuedJob | null;
254
+ activeJob: QueuedJob<TSupervisor> | null;
243
255
  onChange: (() => void) | null;
244
256
  freeSlots: SchedulerOptions["freeSlots"];
245
257
  slotPollMs: number;
@@ -252,7 +264,7 @@ export declare class Scheduler {
252
264
  * engine reloads a model - slot ids do not survive the relaunch.
253
265
  */
254
266
  slotOwners: Map<number, string | null>;
255
- constructor({ supervisor, loadModel, logger, onChange, freeSlots, slotPollMs, eraseSlot, }: SchedulerOptions);
267
+ constructor({ supervisor, loadModel, logger, onChange, freeSlots, slotPollMs, eraseSlot, }: SchedulerOptions<TSupervisor>);
256
268
  /** Id of the model that is actually loaded and ready, or null. */
257
269
  get loadedId(): string | null;
258
270
  /** How many requests may run at once against the resident model (static ceiling). */
@@ -263,7 +275,7 @@ export declare class Scheduler {
263
275
  * Dispatch is deferred a microtask so a burst of requests submitted together
264
276
  * shares one turn rather than the first one taking a turn by itself.
265
277
  */
266
- submit(model: Model, run: () => Promise<unknown>, { kind, exclusive, onStart, session, onSlotFree, }?: SchedulerSubmitOptions): Promise<unknown>;
278
+ submit(model: Model, run: (supervisor: TSupervisor) => Promise<unknown>, { kind, exclusive, onStart, session, onSlotFree, }?: SchedulerSubmitOptions): Promise<unknown>;
267
279
  /**
268
280
  * Drop every recorded slot owner. The engine's slots do not survive a model
269
281
  * (re)launch, so their owners do not either - a stale entry would make the
@@ -279,6 +291,10 @@ export declare class Scheduler {
279
291
  * supervisor says the slots are gone. Both paths are idempotent.
280
292
  */
281
293
  forgetSlots(): void;
294
+ /** True only when this engine has no claimed, queued, or running work. */
295
+ get isIdle(): boolean;
296
+ supervisorFor(modelId: string): TSupervisor | null;
297
+ supervisors(): TSupervisor[];
282
298
  /** Queue snapshot for the status endpoint / UI. */
283
299
  stats(): SchedulerStats;
284
300
  }
@@ -118,6 +118,19 @@ export class Scheduler {
118
118
  forgetSlots() {
119
119
  this.slotOwners.clear();
120
120
  }
121
+ /** True only when this engine has no claimed, queued, or running work. */
122
+ get isIdle() {
123
+ return (this.queue.length === 0 &&
124
+ __classPrivateFieldGet(this, _Scheduler_batch, "f").length === 0 &&
125
+ __classPrivateFieldGet(this, _Scheduler_running, "f").size === 0 &&
126
+ this.activeJob === null);
127
+ }
128
+ supervisorFor(modelId) {
129
+ return this.supervisor.model?.id === modelId ? this.supervisor : null;
130
+ }
131
+ supervisors() {
132
+ return [this.supervisor];
133
+ }
121
134
  /** Queue snapshot for the status endpoint / UI. */
122
135
  stats() {
123
136
  const waiting = {};
@@ -269,7 +282,7 @@ async function _Scheduler_start(job) {
269
282
  void (async () => {
270
283
  try {
271
284
  job.onStart?.();
272
- job.resolve(await job.run());
285
+ job.resolve(await job.run(this.supervisor));
273
286
  }
274
287
  catch (error) {
275
288
  job.reject(error);