@otto-code/brain 0.8.10 → 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.
Files changed (65) hide show
  1. package/dist/commands/bench.js +2 -2
  2. package/dist/commands/calibrate.js +11 -2
  3. package/dist/commands/catalog.d.ts +1 -0
  4. package/dist/commands/catalog.js +1 -0
  5. package/dist/commands/pull.d.ts +1 -0
  6. package/dist/commands/pull.js +12 -3
  7. package/dist/commands/search.d.ts +1 -0
  8. package/dist/commands/search.js +12 -2
  9. package/dist/config/index.d.ts +2 -2
  10. package/dist/config/index.js +2 -2
  11. package/dist/config/profile-edit.d.ts +88 -1
  12. package/dist/config/profile-edit.js +294 -43
  13. package/dist/config/profiles.d.ts +19 -3
  14. package/dist/config/profiles.js +52 -4
  15. package/dist/config/schema.d.ts +616 -0
  16. package/dist/config/schema.js +65 -3
  17. package/dist/config/store.js +7 -4
  18. package/dist/gguf.d.ts +7 -0
  19. package/dist/gguf.js +15 -2
  20. package/dist/models/download.d.ts +1 -1
  21. package/dist/models/download.js +2 -2
  22. package/dist/models/enrich.d.ts +6 -0
  23. package/dist/models/enrich.js +27 -1
  24. package/dist/models/index.d.ts +1 -1
  25. package/dist/models/index.js +4 -3
  26. package/dist/ops/archive.d.ts +14 -1
  27. package/dist/ops/archive.js +9 -5
  28. package/dist/ops/calibrate.d.ts +38 -3
  29. package/dist/ops/calibrate.js +68 -19
  30. package/dist/ops/report.js +51 -1
  31. package/dist/ops/results.d.ts +77 -11
  32. package/dist/ops/results.js +84 -14
  33. package/dist/ops/sweep.d.ts +38 -1
  34. package/dist/ops/sweep.js +61 -10
  35. package/dist/runtime/args.d.ts +15 -2
  36. package/dist/runtime/args.js +60 -5
  37. package/dist/runtime/managed.js +2 -2
  38. package/dist/service/activity.d.ts +19 -0
  39. package/dist/service/activity.js +47 -4
  40. package/dist/service/host-api.d.ts +28 -4
  41. package/dist/service/host-api.js +109 -28
  42. package/dist/service/log-format.d.ts +18 -0
  43. package/dist/service/log-format.js +32 -0
  44. package/dist/service/process-pool.d.ts +45 -0
  45. package/dist/service/process-pool.js +271 -0
  46. package/dist/service/router.d.ts +74 -3
  47. package/dist/service/router.js +277 -51
  48. package/dist/service/run-log.d.ts +6 -1
  49. package/dist/service/run-log.js +46 -4
  50. package/dist/service/scheduler.d.ts +250 -31
  51. package/dist/service/scheduler.js +408 -63
  52. package/dist/service/serve.d.ts +4 -0
  53. package/dist/service/serve.js +376 -142
  54. package/dist/service/status-events.d.ts +14 -1
  55. package/dist/service/status-events.js +112 -12
  56. package/dist/service/supervisor.d.ts +9 -7
  57. package/dist/service/supervisor.js +37 -12
  58. package/dist/sysmon.d.ts +15 -0
  59. package/dist/sysmon.js +56 -9
  60. package/dist/tui/app.d.ts +8 -2
  61. package/dist/tui/app.js +83 -26
  62. package/dist/types.d.ts +18 -0
  63. package/dist/vram.d.ts +37 -0
  64. package/dist/vram.js +57 -18
  65. package/package.json +1 -1
@@ -180,6 +180,10 @@ export function describeModel(model, options = {}) {
180
180
  reasoningEfforts.every((value) => typeof value === "string")) {
181
181
  entry.reasoning_efforts = reasoningEfforts;
182
182
  }
183
+ const reasoningEffortDefault = md["reasoning_effort_default"] ?? model.reasoningEffortDefault;
184
+ if (typeof reasoningEffortDefault === "string") {
185
+ entry.reasoning_effort_default = reasoningEffortDefault;
186
+ }
183
187
  if (state === "loaded" && profile && profile.contextSize) {
184
188
  // llama-server splits -c across --parallel slots, so the window a single
185
189
  // request actually gets is the total divided by the concurrency.
@@ -201,30 +205,39 @@ function resolveCatalog(getCatalog) {
201
205
  * the supervisor is running marked 'loaded'. Falls back to just the running
202
206
  * model when no catalog provider is wired in.
203
207
  */
204
- export function buildModelList(supervisor, getCatalog) {
205
- 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;
206
213
  const stateOf = (model) => {
207
- if (!loadedId || model.id !== loadedId)
214
+ const resident = residentFor(model.id);
215
+ if (!resident)
208
216
  return "not-loaded";
209
- if (supervisor.state === "ready")
217
+ if (resident.state === "ready")
210
218
  return "loaded";
211
- if (supervisor.state === "starting")
219
+ if (resident.state === "starting")
212
220
  return "loading";
213
221
  return "not-loaded";
214
222
  };
215
223
  let catalog = resolveCatalog(getCatalog);
216
224
  // Guarantee the running model appears even if the snapshot predates it.
217
- if (supervisor.model && !catalog.some((m) => m.id === loadedId)) {
218
- 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
+ }
219
229
  }
220
- return catalog.map((model) => describeModel(model, {
221
- state: stateOf(model),
222
- profile: model.id === loadedId ? supervisor.profile : null,
223
- createdAt: model.id === loadedId ? supervisor.startedAt : null,
224
- }));
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
+ });
225
238
  }
226
239
  /** Handle the model-discovery endpoints ourselves; returns true if it did. */
227
- function handleModelsRoute(req, res, supervisor, getCatalog) {
240
+ function handleModelsRoute(req, res, supervisor, getCatalog, scheduler = null) {
228
241
  if (req.method !== "GET")
229
242
  return false;
230
243
  const url = (req.url || "").split("?")[0];
@@ -234,7 +247,7 @@ function handleModelsRoute(req, res, supervisor, getCatalog) {
234
247
  : null;
235
248
  if (!isList && single === null)
236
249
  return false;
237
- const list = buildModelList(supervisor, getCatalog);
250
+ const list = buildModelList(supervisor, getCatalog, scheduler);
238
251
  let payload;
239
252
  if (single !== null) {
240
253
  const entry = list.find((e) => e.id === single);
@@ -277,13 +290,152 @@ function nextStreamId() {
277
290
  * bench command builds its own throwaway router and simply never reads it.
278
291
  */
279
292
  const reasoningTracker = new ReasoningTracker();
293
+ /**
294
+ * Map an OpenAI-compatible effort request onto a model's own chat-template
295
+ * arguments. llama.cpp does not know every model's dialect: Qwen3.8 calls the
296
+ * controls `enable_thinking` and `reasoning_effort`, for example. Only catalog
297
+ * entries that declare these names are rewritten, so generic models and GPT-OSS
298
+ * keep their existing server-native request handling.
299
+ */
300
+ export function applyModelReasoningTemplate(body, model) {
301
+ const template = model.reasoningTemplate;
302
+ const advertised = model.reasoningEfforts;
303
+ if (!template || !Array.isArray(advertised))
304
+ return body;
305
+ let parsed;
306
+ try {
307
+ parsed = JSON.parse(body.toString("utf8"));
308
+ }
309
+ catch {
310
+ return body;
311
+ }
312
+ if (!isRecord(parsed) || typeof parsed.reasoning_effort !== "string")
313
+ return body;
314
+ const requested = parsed.reasoning_effort.toLowerCase();
315
+ const knownEfforts = new Set(advertised.map((value) => value.toLowerCase()));
316
+ const isDisabled = requested === "off" || requested === "none";
317
+ const isEnabled = requested === "on" || knownEfforts.has(requested);
318
+ if (!isDisabled && !isEnabled)
319
+ return body;
320
+ const suppliedKwargs = parsed.chat_template_kwargs;
321
+ if (suppliedKwargs !== undefined && !isRecord(suppliedKwargs))
322
+ return body;
323
+ const templateKwargs = { ...suppliedKwargs };
324
+ templateKwargs[template.enableThinkingArgument] = !isDisabled;
325
+ if (knownEfforts.has(requested)) {
326
+ templateKwargs[template.effortArgument] = requested;
327
+ }
328
+ else {
329
+ // The generic On selection means the model's native default, not a stale
330
+ // explicit effort that happened to be supplied by a previous client.
331
+ delete templateKwargs[template.effortArgument];
332
+ }
333
+ const { reasoning_effort: _reasoningEffort, ...withoutReasoningEffort } = parsed;
334
+ return Buffer.from(JSON.stringify({ ...withoutReasoningEffort, chat_template_kwargs: templateKwargs }), "utf8");
335
+ }
336
+ /**
337
+ * Pin the request to one llama-server slot by adding the engine's own
338
+ * `id_slot` field to the request body (host API v3).
339
+ *
340
+ * Why pin instead of guess: the OpenAI-compatible stream chunks never carry the
341
+ * slot id, so without a pin the router can only correlate a request to a slot
342
+ * by elimination, and with several concurrent requests that guess is exactly
343
+ * the lie the Overview panel used to tell. llama-server honors the pin on every
344
+ * completion endpoint: if the named slot is free the task lands there, and if
345
+ * it is busy the engine DEFERS the task internally - it never reassigns the
346
+ * task elsewhere and never fails the request - so the slot this router names is
347
+ * always the slot the request ends up on (possibly after waiting on it).
348
+ *
349
+ * `null` returns the body untouched: no pin, and therefore no join data for
350
+ * this request, which is the same degraded state as an older brain. A body this
351
+ * cannot parse is forwarded exactly as-is - an unfamiliar request must reach
352
+ * llama-server and get llama-server's own answer, not a 400 invented here.
353
+ */
354
+ export function pinSlot(body, slotId) {
355
+ if (slotId === null)
356
+ return body;
357
+ let parsed;
358
+ try {
359
+ parsed = JSON.parse(body.toString("utf8"));
360
+ }
361
+ catch {
362
+ return body;
363
+ }
364
+ if (!isRecord(parsed))
365
+ return body;
366
+ parsed.id_slot = slotId;
367
+ return Buffer.from(JSON.stringify(parsed), "utf8");
368
+ }
369
+ /**
370
+ * Wipe one llama-server slot's retained KV state, and RESOLVE only once the
371
+ * engine has acknowledged the wipe.
372
+ *
373
+ * This is the engine-side half of the scheduler's OWNERSHIP fix. The engine
374
+ * never clears a released slot's prompt, so a slot handed to a different chat
375
+ * would keep the previous chat's KV and bleed its topics into the new chat's
376
+ * thinking. The router erases the slot the moment the scheduler hands it off;
377
+ * the engine's task queue runs in arrival order, so resolving on the
378
+ * acknowledgment is what guarantees the clean state sits in the queue ahead of
379
+ * the completion the scheduler posts right after.
380
+ *
381
+ * The route is `POST /slots?action=erase&id_slot=N` - llama.cpp's own slot
382
+ * action. It answers 200 `{id, id_slot, n_erased}` on success and a
383
+ * `NOT_SUPPORTED` error when the server was not launched with a slot-save path;
384
+ * either way this resolves (never rejects), because an erase that cannot be
385
+ * performed degrades to the old behavior rather than failing the completion.
386
+ *
387
+ * NOTE: `action` and `id_slot` MUST travel in the query string, not the JSON
388
+ * body. llama-server's `POST /slots` handler reads both via `req.get_param()`,
389
+ * which is built only from query + path params (b10441 tools/server/server-http.cpp,
390
+ * `server_http_req::params` = "path_params + query_params"; the body is a
391
+ * separate field the handler never parses for this route). A body-only request
392
+ * reaches `std::stoi("")` and answers 400 "Invalid slot ID" - the erase then
393
+ * silently no-ops and the bleed survives. The body must stay empty for the
394
+ * same reason `handle_slots_erase` ignores it entirely.
395
+ */
396
+ export function eraseSlot(host, port, slotId) {
397
+ return new Promise((resolve) => {
398
+ const req = http.request({
399
+ host,
400
+ port,
401
+ path: `/slots?action=erase&id_slot=${slotId}`,
402
+ method: "POST",
403
+ timeout: 3000,
404
+ }, (res) => {
405
+ res.resume();
406
+ res.on("end", () => resolve());
407
+ });
408
+ req.on("timeout", () => req.destroy());
409
+ req.on("error", () => resolve());
410
+ req.end();
411
+ });
412
+ }
413
+ /**
414
+ * The eraser the scheduler needs, bound to one engine endpoint. Extracted so
415
+ * the router (which builds its own scheduler) and the service (which builds a
416
+ * shared one and passes it in) hand the scheduler the SAME transport rather
417
+ * than each spelling the request.
418
+ */
419
+ export function createSlotEraser(host, port) {
420
+ return (slotId) => eraseSlot(host, port, slotId);
421
+ }
280
422
  /**
281
423
  * Forward a buffered completion body to the resident llama-server and stream the
282
424
  * reply back, teeing non-streaming bodies for classification. Resolves once the
283
425
  * client response is fully concluded (it owns the response in every outcome,
284
426
  * including upstream errors), so the scheduler can move to the next turn.
427
+ *
428
+ * The slot this request is pinned to arrives as `options.slot`, named by the
429
+ * scheduler at the moment of admission (see `Scheduler.onSlotFree`). That is
430
+ * the only race-free place to choose it: by then the model is resident (slot
431
+ * ids do not survive a model switch), the admission sample has just counted
432
+ * the slot free, and every sibling admitted in the same pass has been named a
433
+ * DISTINCT id. `null` means the engine could not be sampled or reported no
434
+ * per-slot rows - the request then runs unpinned with no join data, exactly
435
+ * the degraded state of an older brain.
285
436
  */
286
- function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, reasoning = null, }) {
437
+ function proxyBuffered({ agent, model, supervisor, telemetry, logger, req, res, body, reasoning = null, slot, }) {
438
+ const slotId = slot ?? null;
287
439
  return new Promise((resolve) => {
288
440
  let settled = false;
289
441
  const streamId = nextStreamId();
@@ -300,7 +452,8 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
300
452
  // Injected here, not at queue time: the scheduler may switch models between
301
453
  // buffering and dispatch, and the addendum belongs to whichever model ends
302
454
  // up resident, which is the one `supervisor.profile` now describes.
303
- const outbound = injectSystemAddendum(body, supervisor.profile?.chatSystemAddendum ?? null, completionShape(req.url));
455
+ const withSystemAddendum = injectSystemAddendum(body, supervisor.profile?.chatSystemAddendum ?? null, completionShape(req.url));
456
+ const outbound = pinSlot(applyModelReasoningTemplate(withSystemAddendum, model), slotId);
304
457
  const headers = {};
305
458
  for (const [name, value] of Object.entries(req.headers)) {
306
459
  if (!HOP_BY_HOP.has(name.toLowerCase()))
@@ -309,6 +462,7 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
309
462
  headers.host = `${supervisor.host}:${supervisor.internalPort}`;
310
463
  headers["content-length"] = Buffer.byteLength(outbound);
311
464
  const started = Date.now();
465
+ logger?.info?.(`dispatching ${req.method ?? "POST"} ${req.url ?? "/v1/chat/completions"} to ${model.displayName}`);
312
466
  const upstream = http.request({
313
467
  host: supervisor.host,
314
468
  port: supervisor.internalPort,
@@ -362,6 +516,7 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
362
516
  streamed: true,
363
517
  verdict: !sawContent && sawReasoning ? "reasoning-only" : "ok",
364
518
  });
519
+ logger?.info?.(`completed streamed ${req.method ?? "POST"} ${req.url ?? "/v1/chat/completions"} for ${model.displayName}`);
365
520
  done();
366
521
  });
367
522
  upstreamRes.pipe(res);
@@ -389,6 +544,7 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
389
544
  ...(analysis ?? { verdict: "ok" }),
390
545
  };
391
546
  telemetry.record(entry);
547
+ logger?.info?.(`completed ${req.method ?? "POST"} ${req.url ?? "/v1/chat/completions"} for ${model.displayName} in ${entry.ms}ms (${entry.verdict})`);
392
548
  if (entry.verdict === "reasoning-only") {
393
549
  logger?.warn?.(`reasoning-only response: ${entry.outputTokens} tokens, ${entry.reasoningChars} reasoning chars, 0 content`);
394
550
  }
@@ -398,6 +554,12 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
398
554
  res.on("close", () => {
399
555
  if (!res.writableFinished && !upstream.destroyed)
400
556
  upstream.destroy();
557
+ // The client is gone - interrupt, chat switch, socket death. Destroying
558
+ // the upstream request does not reliably fire "aborted"/"error" on the
559
+ // already-open response stream, so this is the authoritative release:
560
+ // without it a departed client's job never resolves and pins its model's
561
+ // slot forever, wedging the whole queue behind it (needs a reboot).
562
+ done();
401
563
  });
402
564
  res.on("error", () => {
403
565
  if (!upstream.destroyed)
@@ -410,13 +572,21 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
410
572
  verdict: "failed",
411
573
  error: error.message,
412
574
  });
413
- sendError(res, 502, `Upstream llama-server error: ${error.message}`);
575
+ logger?.warn(`request to ${model.displayName} failed: ${error.message}`);
576
+ // The client may already be gone; writing a 502 into a dead socket
577
+ // throws and would kill this handler before done() releases the slot.
578
+ if (!res.writableEnded && !res.destroyed) {
579
+ sendError(res, 502, `Upstream llama-server error: ${error.message}`);
580
+ }
414
581
  done();
415
582
  });
416
583
  // This is the first authoritative inference-stage signal: the request is
417
584
  // being dispatched to llama-server and is waiting for prompt processing or
418
- // its first output delta.
585
+ // its first output delta. The slot association is set once here, at the
586
+ // same moment, so `observe` stays free of any per-chunk slot work.
419
587
  reasoning?.begin(streamId);
588
+ if (slotId !== null)
589
+ reasoning?.setSlot(streamId, slotId);
420
590
  upstream.end(outbound);
421
591
  });
422
592
  }
@@ -497,19 +667,24 @@ export function injectSystemAddendum(body, addendum, shape) {
497
667
  * a switch; an unnamed request rides the pin.
498
668
  */
499
669
  export function decideModelGate(params) {
500
- const { lockModel, requestedName, pinned, resolved } = params;
670
+ const { lockModel, requestedName, pinned, pinnedModels = pinned ? [pinned] : [], resolved, } = params;
501
671
  if (lockModel) {
502
- if (!pinned) {
503
- 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" };
504
674
  }
505
- 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) {
506
679
  return {
507
680
  ok: false,
508
681
  status: 409,
509
- 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(", ")}`,
510
685
  };
511
686
  }
512
- return { ok: true, model: pinned };
687
+ return { ok: true, model: selected };
513
688
  }
514
689
  if (!resolved) {
515
690
  return {
@@ -523,7 +698,7 @@ export function decideModelGate(params) {
523
698
  return { ok: true, model: resolved };
524
699
  }
525
700
  /** Buffer a completion request, resolve its target model, and queue it. */
526
- function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, scheduler, modelGate, }) {
701
+ function scheduleCompletion({ req, res, agent, telemetry, logger, scheduler, modelGate, }) {
527
702
  const chunks = [];
528
703
  let size = 0;
529
704
  let tooBig = false;
@@ -544,31 +719,52 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
544
719
  }
545
720
  const body = Buffer.concat(chunks);
546
721
  let modelName = null;
722
+ let session = null;
547
723
  try {
548
724
  const parsed = JSON.parse(body.toString("utf8"));
549
- modelName = isRecord(parsed) && typeof parsed.model === "string" ? parsed.model : null;
725
+ if (isRecord(parsed)) {
726
+ modelName = typeof parsed.model === "string" ? parsed.model : null;
727
+ // The standard prompt_cache_key is the chat's stable identity: Otto's
728
+ // provider sends its session id there, and a third-party client that
729
+ // uses it for prompt caching sends one too. Brain reads it for
730
+ // session-affine scheduling; the OpenAI-compatible contract is
731
+ // untouched either way.
732
+ session =
733
+ typeof parsed.prompt_cache_key === "string" && parsed.prompt_cache_key.length > 0
734
+ ? parsed.prompt_cache_key
735
+ : null;
736
+ }
550
737
  }
551
738
  catch {
552
739
  /* leave null */
553
740
  }
554
741
  const gate = modelGate(modelName);
555
742
  if (!gate.ok) {
743
+ logger?.warn(`refused ${req.method ?? "POST"} ${req.url ?? "completion"}: ${gate.message}`);
556
744
  sendError(res, gate.status, gate.message);
557
745
  return;
558
746
  }
559
747
  const model = gate.model;
560
- scheduler
561
- .submit(model, () => proxyBuffered({
748
+ // The slot is named by the scheduler at admission time (one distinct id per
749
+ // job, drawn from the pass's own free-slot sample). It is not sampled here
750
+ // at queue time - that could name a slot of an engine about to be
751
+ // relaunched - and not at dispatch time either, where a sibling admitted in
752
+ // the same pass could see the same slot free and pin it too.
753
+ let slot = null;
754
+ const queued = scheduler.submit(model, (resident) => proxyBuffered({
562
755
  agent,
563
- supervisor,
756
+ model,
757
+ supervisor: resident,
564
758
  telemetry,
565
759
  logger,
566
760
  req,
567
761
  res,
568
762
  body,
569
763
  reasoning: reasoningTracker,
570
- }))
571
- .catch((error) => sendError(res, 502, `could not serve ${model.displayName}: ${errorMessage(error)}`));
764
+ slot,
765
+ }), { session, onSlotFree: (id) => (slot = id) });
766
+ logger?.info?.(`queued ${req.method ?? "POST"} ${req.url ?? "completion"} for ${model.displayName}; queue depth ${scheduler.stats().queued}`);
767
+ queued.catch((error) => sendError(res, 502, `could not serve ${model.displayName}: ${errorMessage(error)}`));
572
768
  });
573
769
  }
574
770
  // The bench ranking is read from disk (one JSON per run). A completion request
@@ -576,29 +772,41 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
576
772
  // (rare), so the router caches the ranking and re-reads it at most once per
577
773
  // window - the cheap time-based trigger.
578
774
  const RANKING_TTL_MS = 60000;
579
- 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, }) {
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, }) {
580
776
  // llama-server may close an idle response socket while this scheduler holds
581
777
  // the next request in queue. A reused keep-alive socket then fails as
582
778
  // ECONNRESET ("socket hang up") before the queued request reaches inference.
583
779
  // Inference time dwarfs localhost connection setup, so isolate each request
584
780
  // instead of letting a second client inherit a stale upstream connection.
585
781
  const agent = new http.Agent({ keepAlive: false, maxSockets: 32 });
586
- const scheduler = loadModel
587
- ? new Scheduler({
588
- supervisor,
589
- loadModel,
590
- logger: (m) => logger?.warn?.(m),
591
- onChange: statusEvents ? () => statusEvents.notify() : null,
592
- })
593
- : null;
782
+ // Erase an engine slot's KV when it is handed to a different chat. The
783
+ // endpoint is the engine's own, on the private port - never on the public
784
+ // one - so a remote client cannot wipe a chat's cache out from under it.
785
+ const eraseSlot = createSlotEraser(supervisor.host, supervisor.internalPort);
786
+ const scheduler = suppliedScheduler ??
787
+ (loadModel
788
+ ? new Scheduler({
789
+ supervisor,
790
+ loadModel,
791
+ logger: (m) => logger?.warn?.(m),
792
+ onChange: statusEvents ? () => statusEvents.notify() : null,
793
+ eraseSlot,
794
+ })
795
+ : null);
594
796
  // A (re)start means whatever produced the current warning no longer applies -
595
797
  // either a different model is now resident, or the same one just picked up an
596
798
  // edited profile (e.g. a lowered reasoning budget). Either way the recent
597
799
  // window is stale, so start it clean rather than let old records blame a
598
- // config that is no longer running.
800
+ // config that is no longer running. The scheduler's slot owners go the same
801
+ // way: the engine's slots do not survive the relaunch, so a stale owner entry
802
+ // would make the next admission erase a FRESH slot or mistake a keyless job
803
+ // for one it owns. The model-switch case is cleared by the scheduler itself;
804
+ // this catches the relaunch that keeps the same model resident.
599
805
  supervisor.on("state", ({ state }) => {
600
- if (state === "starting")
806
+ if (state === "starting") {
601
807
  telemetry.reset();
808
+ scheduler?.forgetSlots();
809
+ }
602
810
  });
603
811
  // GPU total VRAM is static hardware, so it is queried once at startup and
604
812
  // cached. Absent (no nvidia-smi) or not-yet-resolved leaves the fit predicate
@@ -657,11 +865,20 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
657
865
  // The single model this host will serve when switching is locked: the
658
866
  // resident model if one is up, else the configured default resolved through
659
867
  // the catalog. Null means nothing is loadable yet.
660
- const pinnedModel = () => {
661
- if (supervisor.model)
662
- return supervisor.model;
663
- const def = getDefaultModel();
664
- 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);
665
882
  };
666
883
  // Decide whether a completion for `name` may run. The lock/default are read
667
884
  // live so a POST /__host/config change takes effect without a restart. Only
@@ -671,7 +888,8 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
671
888
  return decideModelGate({
672
889
  lockModel: lock,
673
890
  requestedName: name,
674
- pinned: lock ? pinnedModel() : null,
891
+ pinned: null,
892
+ pinnedModels: lock ? pinnedModels() : [],
675
893
  resolved: lock ? null : resolveModel(name),
676
894
  });
677
895
  };
@@ -686,6 +904,10 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
686
904
  */
687
905
  const buildCheapStatus = async () => {
688
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");
689
911
  // Slots come from a loopback GET on the resident llama-server. That is
690
912
  // cheap enough to pay on every sample - unlike GPU sampling, which spawns
691
913
  // `nvidia-smi` and stays opt-in. Skipped entirely unless a model is
@@ -701,6 +923,7 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
701
923
  // package version.
702
924
  apiVersion: HOST_API_VERSION,
703
925
  ...supervisor.status(),
926
+ residents: residents.map((resident) => resident.status()),
704
927
  telemetry: { ...telemetry.totals, warning: telemetry.warning },
705
928
  scheduler: schedulerStats,
706
929
  recent: telemetry.records.slice(-10),
@@ -763,8 +986,12 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
763
986
  sendError(res, 400, result.error);
764
987
  return;
765
988
  }
989
+ logger?.info?.("received host configuration update");
766
990
  applyConfigPatch(result.body)
767
- .then((cfg) => sendJson(res, cfg))
991
+ .then((cfg) => {
992
+ logger?.info?.("applied host configuration update");
993
+ return sendJson(res, cfg);
994
+ })
768
995
  .catch((err) => sendError(res, 500, `could not apply config: ${errorMessage(err)}`));
769
996
  });
770
997
  return;
@@ -785,7 +1012,7 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
785
1012
  return;
786
1013
  // Answer model discovery ourselves so ids are real names (not paths), the
787
1014
  // whole catalog is listed, and each carries LM Studio's context fields.
788
- if (handleModelsRoute(req, res, supervisor, getCatalog))
1015
+ if (handleModelsRoute(req, res, supervisor, getCatalog, scheduler))
789
1016
  return;
790
1017
  // With a scheduler wired in, completion requests are queued and served in
791
1018
  // turns - including loading/switching to the model they ask for - instead
@@ -795,7 +1022,6 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
795
1022
  req,
796
1023
  res,
797
1024
  agent,
798
- supervisor,
799
1025
  telemetry,
800
1026
  logger,
801
1027
  scheduler,
@@ -1,6 +1,11 @@
1
1
  export interface BrainRunLog {
2
2
  path: string;
3
- write(line: string): void;
3
+ write(line: string): string[];
4
+ /** The current Brain-service session's durable tail, including every source. */
5
+ tail(limit: number): {
6
+ lines: string[];
7
+ total: number;
8
+ };
4
9
  }
5
10
  /** Start a fresh Brain log and prune only expired Brain run logs. */
6
11
  export declare function createBrainRunLog(env?: NodeJS.ProcessEnv): BrainRunLog;
@@ -1,7 +1,8 @@
1
1
  /** Durable, per-service-run diagnostics for Otto Brain. */
2
- import { appendFileSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
2
+ import { appendFileSync, mkdirSync, openSync, readSync, closeSync, readdirSync, rmSync, statSync, } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { resolveBrainPaths } from "../config/paths.js";
5
+ import { timestampBrainLogLine } from "./log-format.js";
5
6
  const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
6
7
  const RUN_LOG_SUFFIX = "-brain.log";
7
8
  /** Start a fresh Brain log and prune only expired Brain run logs. */
@@ -18,14 +19,55 @@ export function createBrainRunLog(env = process.env) {
18
19
  pruneBrainRunLogs(logsDir, startedAt.getTime());
19
20
  }
20
21
  catch { }
22
+ let lineCount = 0;
21
23
  const write = (line) => {
24
+ const lines = line.split(/\r?\n|\r/u).filter((entry) => entry.length > 0);
25
+ if (lines.length === 0)
26
+ return [];
22
27
  try {
23
- appendFileSync(filePath, `${new Date().toISOString()} ${line}\n`, "utf8");
28
+ const timestamp = new Date().toISOString().slice(11, 23);
29
+ const entries = lines.map((entry) => timestampBrainLogLine(timestamp, entry));
30
+ appendFileSync(filePath, `${entries.join("\n")}\n`, "utf8");
31
+ lineCount += lines.length;
32
+ return entries;
33
+ }
34
+ catch {
35
+ return [];
24
36
  }
25
- catch { }
26
37
  };
27
38
  write(`Brain service started (pid ${process.pid})`);
28
- return { path: filePath, write };
39
+ return {
40
+ path: filePath,
41
+ write,
42
+ tail(limit) {
43
+ const count = Math.max(1, Math.floor(limit));
44
+ // The Brain log is append-only for the service lifetime. Read only enough
45
+ // of its end for the live viewer, rather than loading an unbounded bench
46
+ // or download log every two-second poll.
47
+ try {
48
+ const size = statSync(filePath).size;
49
+ const bytes = Math.min(size, Math.max(64 * 1024, count * 1024));
50
+ const fd = openSync(filePath, "r");
51
+ try {
52
+ const data = Buffer.alloc(bytes);
53
+ readSync(fd, data, 0, bytes, size - bytes);
54
+ const text = data.toString("utf8");
55
+ const firstNewline = text.indexOf("\n");
56
+ const completeLines = bytes < size && firstNewline !== -1 ? text.slice(firstNewline + 1) : text;
57
+ return {
58
+ lines: completeLines.split(/\r?\n/u).filter(Boolean).slice(-count),
59
+ total: lineCount,
60
+ };
61
+ }
62
+ finally {
63
+ closeSync(fd);
64
+ }
65
+ }
66
+ catch {
67
+ return { lines: [], total: lineCount };
68
+ }
69
+ },
70
+ };
29
71
  }
30
72
  export function pruneBrainRunLogs(logsDir, now = Date.now()) {
31
73
  for (const entry of readdirSync(logsDir, { withFileTypes: true })) {