@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.
- package/dist/commands/bench.js +2 -2
- package/dist/commands/calibrate.js +8 -8
- package/dist/commands/pull.d.ts +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +1 -1
- package/dist/config/profile-edit.js +16 -16
- package/dist/config/profiles.d.ts +19 -3
- package/dist/config/profiles.js +36 -4
- package/dist/config/schema.d.ts +8 -0
- package/dist/config/schema.js +7 -3
- package/dist/ops/archive.d.ts +14 -1
- package/dist/ops/archive.js +9 -5
- package/dist/ops/results.d.ts +21 -1
- package/dist/ops/results.js +10 -5
- package/dist/service/activity.d.ts +95 -14
- package/dist/service/activity.js +206 -78
- package/dist/service/host-api.d.ts +7 -4
- package/dist/service/host-api.js +31 -16
- package/dist/service/process-pool.d.ts +45 -0
- package/dist/service/process-pool.js +271 -0
- package/dist/service/router.d.ts +7 -4
- package/dist/service/router.js +123 -59
- package/dist/service/scheduler.d.ts +41 -10
- package/dist/service/scheduler.js +49 -3
- package/dist/service/serve.js +107 -58
- package/dist/service/status-events.js +1 -0
- package/dist/service/supervisor.js +2 -2
- package/dist/tui/app.js +18 -9
- package/package.json +1 -1
package/dist/service/activity.js
CHANGED
|
@@ -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,
|
|
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, "
|
|
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
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
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
|
-
*
|
|
238
|
-
*
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
/**
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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 (
|
|
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
|
-
/**
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
309
|
-
result[stage] += 1;
|
|
310
|
-
|
|
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(
|
|
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(),
|
|
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 {
|
|
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
|
-
|
|
126
|
-
scheduler
|
|
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?:
|
|
210
|
+
scheduler?: ModelScheduler<Supervisor> | null;
|
|
208
211
|
runtimeBuild?: number | null;
|
|
209
212
|
}): InventoryRow;
|
|
210
213
|
export interface HostApi {
|
package/dist/service/host-api.js
CHANGED
|
@@ -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,
|
|
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
|
|
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 (
|
|
178
|
+
if (residentSupervisor.state === "starting")
|
|
177
179
|
return "loading";
|
|
178
|
-
if (
|
|
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 &&
|
|
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 =
|
|
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.
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|