@otto-code/brain 0.7.6 → 0.8.0

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 (81) hide show
  1. package/dist/bench/context-corpus.js +3 -3
  2. package/dist/bench/corpus.js +2 -2
  3. package/dist/bench/curated-repos.js +3 -3
  4. package/dist/bench/health.d.ts +1 -1
  5. package/dist/bench/health.js +2 -2
  6. package/dist/bench/tasks.js +2 -2
  7. package/dist/cli.d.ts +1 -1
  8. package/dist/cli.js +1 -1
  9. package/dist/commands/bench.d.ts +1 -1
  10. package/dist/commands/bench.js +27 -4
  11. package/dist/commands/calibrate.d.ts +1 -1
  12. package/dist/commands/calibrate.js +5 -2
  13. package/dist/commands/catalog.d.ts +1 -1
  14. package/dist/commands/config.d.ts +1 -1
  15. package/dist/commands/lifecycle.js +1 -1
  16. package/dist/commands/pull.d.ts +1 -1
  17. package/dist/commands/pull.js +9 -4
  18. package/dist/commands/report.d.ts +1 -1
  19. package/dist/commands/rescore.d.ts +1 -1
  20. package/dist/commands/rescore.js +1 -1
  21. package/dist/commands/runtime.d.ts +1 -1
  22. package/dist/commands/scan.d.ts +1 -1
  23. package/dist/commands/scan.js +6 -1
  24. package/dist/commands/search.d.ts +1 -1
  25. package/dist/commands/share.js +2 -2
  26. package/dist/commands/sweep.d.ts +2 -2
  27. package/dist/commands/sweep.js +22 -8
  28. package/dist/commands/ui.d.ts +1 -1
  29. package/dist/config/index.d.ts +2 -1
  30. package/dist/config/index.js +2 -1
  31. package/dist/config/otto-home.js +1 -1
  32. package/dist/config/paths.d.ts +1 -0
  33. package/dist/config/paths.js +4 -0
  34. package/dist/config/profile-edit.d.ts +94 -0
  35. package/dist/config/profile-edit.js +269 -0
  36. package/dist/config/profiles.d.ts +2 -2
  37. package/dist/config/profiles.js +1 -1
  38. package/dist/config/schema.d.ts +4 -4
  39. package/dist/config/schema.js +6 -6
  40. package/dist/config/store.d.ts +1 -1
  41. package/dist/config/store.js +1 -1
  42. package/dist/models/download.js +1 -1
  43. package/dist/models/enrich.d.ts +2 -2
  44. package/dist/models/index.d.ts +1 -1
  45. package/dist/models/index.js +1 -1
  46. package/dist/models/pick.d.ts +9 -0
  47. package/dist/models/pick.js +24 -1
  48. package/dist/ops/report.js +28 -28
  49. package/dist/ops/results.d.ts +128 -9
  50. package/dist/ops/results.js +77 -5
  51. package/dist/output/render.js +1 -1
  52. package/dist/output/types.d.ts +1 -1
  53. package/dist/runtime/args.d.ts +1 -1
  54. package/dist/runtime/args.js +1 -1
  55. package/dist/runtime/managed.js +4 -4
  56. package/dist/service/activity.d.ts +83 -0
  57. package/dist/service/activity.js +216 -0
  58. package/dist/service/host-api.d.ts +132 -0
  59. package/dist/service/host-api.js +397 -0
  60. package/dist/service/http-util.d.ts +27 -0
  61. package/dist/service/http-util.js +72 -0
  62. package/dist/service/model-selector.d.ts +2 -2
  63. package/dist/service/model-selector.js +6 -6
  64. package/dist/service/router.d.ts +28 -4
  65. package/dist/service/router.js +128 -94
  66. package/dist/service/scheduler.d.ts +2 -2
  67. package/dist/service/scheduler.js +1 -1
  68. package/dist/service/serve.d.ts +2 -2
  69. package/dist/service/serve.js +51 -8
  70. package/dist/service/supervisor.d.ts +6 -0
  71. package/dist/service/supervisor.js +2 -0
  72. package/dist/service/tailscale.js +1 -1
  73. package/dist/service/tls.d.ts +4 -4
  74. package/dist/service/tls.js +3 -3
  75. package/dist/sysmon.d.ts +20 -4
  76. package/dist/sysmon.js +42 -18
  77. package/dist/tui/app.d.ts +12 -2
  78. package/dist/tui/app.js +46 -22
  79. package/dist/vram.d.ts +8 -1
  80. package/dist/vram.js +6 -3
  81. package/package.json +1 -1
@@ -1,8 +1,11 @@
1
1
  import http from "node:http";
2
+ import { chunkHasContent, chunkHasReasoning, readActivity, ReasoningTracker } from "./activity.js";
2
3
  import { Scheduler } from "./scheduler.js";
4
+ import { slots as sampleSlots } from "../sysmon.js";
3
5
  import { makeVramFitPredicate, selectCodingModel } from "./model-selector.js";
4
6
  import { query as queryGpu } from "../gpu.js";
5
7
  import { rankModels } from "../ops/results.js";
8
+ import { errorBody, errorMessage, HOP_BY_HOP, readJsonBody, sendError, sendJson, } from "./http-util.js";
6
9
  /**
7
10
  * Fronts the supervised llama-server on a stable port.
8
11
  *
@@ -18,16 +21,6 @@ import { rankModels } from "../ops/results.js";
18
21
  * catalog (via the injected `getCatalog`), mark the running one 'loaded',
19
22
  * and give each a friendly name plus the context fields clients size against.
20
23
  */
21
- const HOP_BY_HOP = new Set([
22
- "connection",
23
- "keep-alive",
24
- "proxy-authenticate",
25
- "proxy-authorization",
26
- "te",
27
- "trailer",
28
- "transfer-encoding",
29
- "upgrade",
30
- ]);
31
24
  const MAX_ANALYSIS_BYTES = 2 * 1024 * 1024;
32
25
  // Completion bodies are buffered so the scheduler can read `model` and replay
33
26
  // them after a possible model switch. Long-context prompts are large but bounded.
@@ -53,19 +46,32 @@ export class Telemetry {
53
46
  if (this.records.length > this.keep)
54
47
  this.records.shift();
55
48
  }
56
- /** Advice derived from observed behaviour, not guesswork. */
49
+ /**
50
+ * Advice derived from the recent window (`records`), not lifetime `totals`.
51
+ * A ratio over the lifetime total barely moves once a service has served any
52
+ * real volume, so a handful of clean responses after a bad patch could never
53
+ * clear it. The sliding window lets a few good requests visibly clear the
54
+ * advice, and `reset()` gives a restarted model a clean slate instead of
55
+ * carrying blame from before the fix was applied.
56
+ */
57
57
  get warning() {
58
- const { requests, reasoningOnly, truncated } = this.totals;
59
- if (requests < 3)
58
+ const total = this.records.length;
59
+ if (total < 3)
60
60
  return null;
61
- if (reasoningOnly / requests > 0.3) {
62
- return `${reasoningOnly}/${requests} responses spent all tokens on reasoning and returned no content - lower the reasoning budget`;
61
+ const reasoningOnly = this.records.filter((r) => r.verdict === "reasoning-only").length;
62
+ if (reasoningOnly / total > 0.3) {
63
+ return `${reasoningOnly}/${total} recent responses spent all tokens on reasoning and returned no content - lower the reasoning budget`;
63
64
  }
64
- if (truncated / requests > 0.3) {
65
- return `${truncated}/${requests} responses hit the token limit - raise the client's max_tokens`;
65
+ const truncated = this.records.filter((r) => r.verdict === "truncated").length;
66
+ if (truncated / total > 0.3) {
67
+ return `${truncated}/${total} recent responses hit the token limit - raise the client's max_tokens`;
66
68
  }
67
69
  return null;
68
70
  }
71
+ /** Clear the recent window so the warning starts fresh - called when the model (re)starts. */
72
+ reset() {
73
+ this.records = [];
74
+ }
69
75
  }
70
76
  function isRecord(value) {
71
77
  return typeof value === "object" && value !== null;
@@ -82,9 +88,6 @@ function readTokens(usage, key) {
82
88
  const value = usage[key];
83
89
  return typeof value === "number" ? value : null;
84
90
  }
85
- function errorMessage(error) {
86
- return error instanceof Error ? error.message : String(error);
87
- }
88
91
  /** Classify a completion body (Anthropic or OpenAI shaped). */
89
92
  export function analyse(bodyText) {
90
93
  let parsed;
@@ -135,19 +138,13 @@ export function analyse(bodyText) {
135
138
  verdict = "truncated";
136
139
  return { finishReason, contentChars, reasoningChars, outputTokens, toolCalls, verdict };
137
140
  }
138
- function errorBody(status, message) {
139
- return JSON.stringify({
140
- type: "error",
141
- error: { type: status === 503 ? "overloaded_error" : "api_error", message },
142
- });
143
- }
144
141
  export function describeModel(model, options = {}) {
145
142
  if (!model)
146
143
  return null;
147
144
  const { state = "not-loaded", profile = null, createdAt = null } = options;
148
145
  const md = model.metadata || {};
149
146
  const entry = {
150
- // Standard OpenAI fields id is the friendly name, never the file path.
147
+ // Standard OpenAI fields - id is the friendly name, never the file path.
151
148
  id: model.displayName,
152
149
  object: "model",
153
150
  created: Math.floor((createdAt ? createdAt.getTime() : Date.now()) / 1000),
@@ -241,32 +238,38 @@ function handleModelsRoute(req, res, supervisor, getCatalog) {
241
238
  res.end(body);
242
239
  return true;
243
240
  }
244
- function sendError(res, status, message) {
245
- if (res.headersSent) {
246
- if (!res.writableEnded)
247
- res.destroy();
248
- return;
249
- }
250
- const body = errorBody(status, message);
251
- const headers = {
252
- "content-type": "application/json",
253
- "content-length": Buffer.byteLength(body),
254
- };
255
- if (status === 503)
256
- headers["retry-after"] = "5";
257
- res.writeHead(status, headers);
258
- res.end(body);
241
+ /**
242
+ * Correlates the chunks of one proxied stream for the reasoning tracker. A
243
+ * counter rather than a uuid: it never leaves the process and only has to be
244
+ * unique among the handful of streams in flight at once.
245
+ */
246
+ let streamCounter = 0;
247
+ function nextStreamId() {
248
+ streamCounter += 1;
249
+ return `s${streamCounter}`;
259
250
  }
251
+ /**
252
+ * The reasoning tracker is module-scoped rather than per-router because both
253
+ * proxy paths need it and `proxyBuffered` is a free function. One service
254
+ * process hosts exactly one router, so there is nothing to collide with; the
255
+ * bench command builds its own throwaway router and simply never reads it.
256
+ */
257
+ const reasoningTracker = new ReasoningTracker();
260
258
  /**
261
259
  * Forward a buffered completion body to the resident llama-server and stream the
262
260
  * reply back, teeing non-streaming bodies for classification. Resolves once the
263
261
  * client response is fully concluded (it owns the response in every outcome,
264
262
  * including upstream errors), so the scheduler can move to the next turn.
265
263
  */
266
- function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, }) {
264
+ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, reasoning = null, }) {
267
265
  return new Promise((resolve) => {
268
266
  let settled = false;
267
+ const streamId = nextStreamId();
269
268
  const done = () => {
269
+ // Always release the reasoning flag, including on the error and abort
270
+ // paths: a stream that dies mid-thought would otherwise pin the rail on
271
+ // "thinking" until the service restarts.
272
+ reasoning?.end(streamId);
270
273
  if (!settled) {
271
274
  settled = true;
272
275
  resolve();
@@ -300,9 +303,10 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, }
300
303
  let sawReasoning = false;
301
304
  upstreamRes.on("data", (chunk) => {
302
305
  const text = String(chunk);
303
- if (text.includes('"text_delta"') || /"content"\s*:\s*"[^"]/.test(text))
306
+ reasoning?.observe(streamId, text);
307
+ if (chunkHasContent(text))
304
308
  sawContent = true;
305
- if (text.includes("thinking") || text.includes("reasoning"))
309
+ if (chunkHasReasoning(text))
306
310
  sawReasoning = true;
307
311
  });
308
312
  upstreamRes.on("end", () => {
@@ -400,33 +404,6 @@ export function decideModelGate(params) {
400
404
  }
401
405
  return { ok: true, model: resolved };
402
406
  }
403
- /** Buffer a bounded JSON request body (for POST /__host/config). */
404
- function readJsonBody(req, limit, cb) {
405
- const chunks = [];
406
- let size = 0;
407
- let tooBig = false;
408
- req.on("data", (chunk) => {
409
- size += chunk.length;
410
- if (size > limit)
411
- tooBig = true;
412
- else
413
- chunks.push(chunk);
414
- });
415
- req.on("error", () => cb({ ok: false, error: "request stream error" }));
416
- req.on("end", () => {
417
- if (tooBig) {
418
- cb({ ok: false, error: "request body too large" });
419
- return;
420
- }
421
- try {
422
- const text = Buffer.concat(chunks).toString("utf8") || "{}";
423
- cb({ ok: true, body: JSON.parse(text) });
424
- }
425
- catch {
426
- cb({ ok: false, error: "invalid JSON body" });
427
- }
428
- });
429
- }
430
407
  /** Buffer a completion request, resolve its target model, and queue it. */
431
408
  function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, scheduler, modelGate, }) {
432
409
  const chunks = [];
@@ -463,23 +440,41 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
463
440
  }
464
441
  const model = gate.model;
465
442
  scheduler
466
- .submit(model, () => proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body }))
443
+ .submit(model, () => proxyBuffered({
444
+ agent,
445
+ supervisor,
446
+ telemetry,
447
+ logger,
448
+ req,
449
+ res,
450
+ body,
451
+ reasoning: reasoningTracker,
452
+ }))
467
453
  .catch((error) => sendError(res, 502, `could not serve ${model.displayName}: ${errorMessage(error)}`));
468
454
  });
469
455
  }
470
456
  // The bench ranking is read from disk (one JSON per run). A completion request
471
457
  // must not pay that IO, and rankings only change when a bench run finishes
472
458
  // (rare), so the router caches the ranking and re-reads it at most once per
473
- // window the cheap time-based trigger.
459
+ // window - the cheap time-based trigger.
474
460
  const RANKING_TTL_MS = 60000;
475
- 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, }) {
461
+ 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, }) {
476
462
  const agent = new http.Agent({ keepAlive: true, maxSockets: 32 });
477
463
  const scheduler = loadModel
478
464
  ? new Scheduler({ supervisor, loadModel, logger: (m) => logger?.warn?.(m) })
479
465
  : null;
466
+ // A (re)start means whatever produced the current warning no longer applies -
467
+ // either a different model is now resident, or the same one just picked up an
468
+ // edited profile (e.g. a lowered reasoning budget). Either way the recent
469
+ // window is stale, so start it clean rather than let old records blame a
470
+ // config that is no longer running.
471
+ supervisor.on("state", ({ state }) => {
472
+ if (state === "starting")
473
+ telemetry.reset();
474
+ });
480
475
  // GPU total VRAM is static hardware, so it is queried once at startup and
481
476
  // cached. Absent (no nvidia-smi) or not-yet-resolved leaves the fit predicate
482
- // undefined, and the selector skips the VRAM filter mirroring serve.ts.
477
+ // undefined, and the selector skips the VRAM filter - mirroring serve.ts.
483
478
  let fitPredicate;
484
479
  void (async () => {
485
480
  try {
@@ -552,32 +547,57 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
552
547
  resolved: lock ? null : resolveModel(name),
553
548
  });
554
549
  };
555
- const sendJson = (res, payload) => {
556
- const body = JSON.stringify(payload, null, 2);
557
- res.writeHead(200, {
558
- "content-type": "application/json",
559
- "content-length": Buffer.byteLength(body),
560
- });
561
- res.end(body);
562
- };
563
550
  return function handler(req, res) {
564
551
  // Host-management read surface (`/__host/*`): the single API both the TUI and
565
552
  // Otto's GUI consume, so the two never drift. Status is live; config and
566
553
  // evals are point-in-time reads the daemon proxies to its settings UI.
567
- if (req.url === "/__host/status") {
568
- sendJson(res, {
554
+ const path = (req.url || "").split("?")[0];
555
+ if (path === "/__host/status") {
556
+ const schedulerStats = scheduler ? scheduler.stats() : null;
557
+ const base = {
569
558
  version,
570
559
  ...supervisor.status(),
571
560
  telemetry: { ...telemetry.totals, warning: telemetry.warning },
572
- scheduler: scheduler ? scheduler.stats() : null,
561
+ scheduler: schedulerStats,
573
562
  recent: telemetry.records.slice(-10),
574
- });
563
+ logLineCount: supervisor.logLines.length,
564
+ // Carried inline rather than fetched from /__host/capabilities: the
565
+ // daemon polls status constantly, and a separately cached copy would go
566
+ // stale the moment the owner toggles allowRemoteConfig.
567
+ capabilities: hostApi ? hostApi.capabilities() : null,
568
+ // The three signals the Brain rail's icon is derived from. All are cheap
569
+ // enough for the liveness poll: `activity` is one stat of a file that is
570
+ // usually absent, `reasoning` is in-process state, and `queued` is
571
+ // already computed above. Slot phases are the one that costs a round
572
+ // trip, and are fetched below.
573
+ activity: readActivity(),
574
+ reasoning: reasoningTracker.active,
575
+ queued: schedulerStats ? schedulerStats.queued : 0,
576
+ };
577
+ // Slots come from a loopback GET on the resident llama-server. That is
578
+ // cheap enough to pay on every poll - unlike the GPU sampling below, which
579
+ // spawns `nvidia-smi` and stays opt-in. Skipped entirely unless a model is
580
+ // resident, since there is nothing listening otherwise.
581
+ const slotsPromise = supervisor.state === "ready"
582
+ ? sampleSlots({ host: supervisor.host, port: supervisor.internalPort }).catch(() => null)
583
+ : Promise.resolve(null);
584
+ // Resources cost an `nvidia-smi` spawn, so they are opt-in: the daemon's
585
+ // liveness probe polls this route far more often than any UI does, and
586
+ // must not pay for a panel it is not rendering.
587
+ const wantsResources = /[?&]resources=1(&|$)/.test(req.url || "");
588
+ if (!wantsResources || !getResources) {
589
+ void slotsPromise.then((slots) => sendJson(res, { ...base, slots }));
590
+ return;
591
+ }
592
+ Promise.all([slotsPromise, getResources().catch(() => null)])
593
+ .then(([slots, resources]) => sendJson(res, { ...base, slots, resources }))
594
+ .catch(() => sendJson(res, { ...base, slots: null, resources: null }));
575
595
  return;
576
596
  }
577
597
  // Config write: apply an editable patch (model/lock live, the rest persisted).
578
598
  // Must precede the GET read below, which matches the same URL for any method.
579
599
  // Refused unless the owner opted into remote configuration.
580
- if (req.method === "POST" && req.url === "/__host/config") {
600
+ if (req.method === "POST" && path === "/__host/config") {
581
601
  if (!applyConfigPatch || !getAllowConfigWrite()) {
582
602
  sendError(res, 403, "remote configuration is disabled on this brain");
583
603
  return;
@@ -593,20 +613,26 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
593
613
  });
594
614
  return;
595
615
  }
596
- if (req.url === "/__host/config" && getConfig) {
616
+ if (path === "/__host/config" && getConfig) {
597
617
  sendJson(res, getConfig());
598
618
  return;
599
619
  }
600
- if (req.url === "/__host/evals" && getEvals) {
620
+ if (path === "/__host/evals" && getEvals) {
601
621
  sendJson(res, getEvals());
602
622
  return;
603
623
  }
624
+ // The management API: inventory, profiles, budget, load/unload, delete, logs.
625
+ // It claims only the routes it implements and returns false otherwise, so an
626
+ // unknown /__host/* path still falls through to the 503/proxy path below
627
+ // rather than being swallowed here.
628
+ if (hostApi && hostApi.handle(req, res))
629
+ return;
604
630
  // Answer model discovery ourselves so ids are real names (not paths), the
605
631
  // whole catalog is listed, and each carries LM Studio's context fields.
606
632
  if (handleModelsRoute(req, res, supervisor, getCatalog))
607
633
  return;
608
634
  // With a scheduler wired in, completion requests are queued and served in
609
- // turns including loading/switching to the model they ask for instead
635
+ // turns - including loading/switching to the model they ask for - instead
610
636
  // of failing when it is not the resident one.
611
637
  if (scheduler && COMPLETION_RE.test(req.url || "")) {
612
638
  scheduleCompletion({
@@ -663,14 +689,22 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
663
689
  let sawContent = false;
664
690
  let sawReasoning = false;
665
691
  if (isCompletion) {
692
+ const streamId = nextStreamId();
693
+ // Released on close, not just on end: an aborted stream would
694
+ // otherwise pin `/__host/status` on "thinking" forever.
695
+ const releaseReasoning = () => reasoningTracker.end(streamId);
666
696
  upstreamRes.on("data", (chunk) => {
667
697
  const text = String(chunk);
668
- if (text.includes('"text_delta"') || /"content"\s*:\s*"[^"]/.test(text))
698
+ reasoningTracker.observe(streamId, text);
699
+ if (chunkHasContent(text))
669
700
  sawContent = true;
670
- if (text.includes("thinking") || text.includes("reasoning"))
701
+ if (chunkHasReasoning(text))
671
702
  sawReasoning = true;
672
703
  });
704
+ upstreamRes.on("close", releaseReasoning);
705
+ upstreamRes.on("error", releaseReasoning);
673
706
  upstreamRes.on("end", () => {
707
+ releaseReasoning();
674
708
  telemetry.record({
675
709
  at: new Date().toISOString(),
676
710
  path: req.url,
@@ -6,11 +6,11 @@
6
6
  *
7
7
  * - Requests for the resident model run concurrently, up to that model's
8
8
  * `parallelSlots` (the same number of sequence slots llama-server was
9
- * launched with sending more would only queue inside llama-server). Extra
9
+ * launched with - sending more would only queue inside llama-server). Extra
10
10
  * same-model requests wait for a free slot; they never trigger a load.
11
11
  * - Requests for a *different* model wait for a model switch. When the resident
12
12
  * model's current batch drains, the scheduler switches and serves the other
13
- * model's batch so two clients wanting different models share the GPU by
13
+ * model's batch - so two clients wanting different models share the GPU by
14
14
  * taking turns.
15
15
  *
16
16
  * Fairness: after a model finishes a turn, the next turn prefers a *different*
@@ -54,7 +54,7 @@ export class Scheduler {
54
54
  await this.loadModel(pick.model);
55
55
  }
56
56
  catch (error) {
57
- // The model would not load fail exactly its queued jobs and move on.
57
+ // The model would not load - fail exactly its queued jobs and move on.
58
58
  for (const job of __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_take).call(this, (j) => j.modelId === turnId))
59
59
  job.reject(error);
60
60
  continue;
@@ -2,7 +2,7 @@
2
2
  * The headless brain service: the router + supervisor bound to a port, with the
3
3
  * VRAM fit, on-demand model switching, remote auth, built-in TLS, and pid-file
4
4
  * lifecycle. Used both by `otto brain serve` (foreground) and by a detached
5
- * `otto brain start`. It stays provider-neutral about the runtime source it
5
+ * `otto brain start`. It stays provider-neutral about the runtime source - it
6
6
  * takes whatever resolveRuntime picks (managed or LM Studio).
7
7
  *
8
8
  * TLS is served in-process (config.tls): HTTPS with a files / self-signed /
@@ -21,7 +21,7 @@ import { Supervisor } from "./supervisor.js";
21
21
  export declare function extractToken(req: http.IncomingMessage): string | null;
22
22
  /**
23
23
  * The one derivation of the effective auth token. `mode: "token"` with a null or
24
- * empty token is NO auth the bind guard and withAuth both read this, so they
24
+ * empty token is NO auth - the bind guard and withAuth both read this, so they
25
25
  * cannot disagree (a mode-only guard once let mode=token + token=null bind
26
26
  * non-loopback and then serve every route ungated).
27
27
  */
@@ -2,7 +2,7 @@
2
2
  * The headless brain service: the router + supervisor bound to a port, with the
3
3
  * VRAM fit, on-demand model switching, remote auth, built-in TLS, and pid-file
4
4
  * lifecycle. Used both by `otto brain serve` (foreground) and by a detached
5
- * `otto brain start`. It stays provider-neutral about the runtime source it
5
+ * `otto brain start`. It stays provider-neutral about the runtime source - it
6
6
  * takes whatever resolveRuntime picks (managed or LM Studio).
7
7
  *
8
8
  * TLS is served in-process (config.tls): HTTPS with a files / self-signed /
@@ -14,12 +14,14 @@ import https from "node:https";
14
14
  import { getCalibration, forModel, loadPersistedConfig, loadProfilesStore, saveBrainConfig, saveProfilesStore, } from "../config/index.js";
15
15
  import { resolveBrainPaths } from "../config/paths.js";
16
16
  import { query as queryGpu } from "../gpu.js";
17
- import { pickModel, scanModels } from "../models/index.js";
17
+ import { managedModelsDir, pickAutoModel, pickModel, scanModels } from "../models/index.js";
18
18
  import { CommandError } from "../output/types.js";
19
19
  import { resolveRuntime } from "../runtime/index.js";
20
20
  import * as vram from "../vram.js";
21
21
  import { resolveVersion } from "../version.js";
22
22
  import * as results from "../ops/results.js";
23
+ import { createCpuSampler, sample as sampleSystem } from "../sysmon.js";
24
+ import { createHostApi } from "./host-api.js";
23
25
  import { createRouter, Telemetry } from "./router.js";
24
26
  import { Supervisor } from "./supervisor.js";
25
27
  import * as tailscale from "./tailscale.js";
@@ -30,7 +32,7 @@ function redactConfig(config) {
30
32
  return {
31
33
  ...config,
32
34
  auth: { ...config.auth, token: config.auth.token ? "********" : null },
33
- // The Hugging Face token is the owner's account credential never echo it to
35
+ // The Hugging Face token is the owner's account credential - never echo it to
34
36
  // a caller of /__host/config (read is allowed by default on a shared brain).
35
37
  ...(config.hfToken ? { hfToken: "********" } : {}),
36
38
  };
@@ -70,7 +72,7 @@ export function extractToken(req) {
70
72
  }
71
73
  /**
72
74
  * The one derivation of the effective auth token. `mode: "token"` with a null or
73
- * empty token is NO auth the bind guard and withAuth both read this, so they
75
+ * empty token is NO auth - the bind guard and withAuth both read this, so they
74
76
  * cannot disagree (a mode-only guard once let mode=token + token=null bind
75
77
  * non-loopback and then serve every route ungated).
76
78
  */
@@ -110,7 +112,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
110
112
  : config.listen.host;
111
113
  const displayHost = tlsOptions?.hostname ?? bindHost;
112
114
  // Auth is orthogonal to transport: TLS encrypts the pipe, a token authorizes the
113
- // caller. A non-loopback bind still needs an actual token even over HTTPS
115
+ // caller. A non-loopback bind still needs an actual token even over HTTPS -
114
116
  // gate on the token itself, not auth.mode, or mode=token with no token binds open.
115
117
  const authToken = effectiveAuthToken(config);
116
118
  if (!isLoopback(bindHost) &&
@@ -124,10 +126,19 @@ export async function startService({ config, modelNeedle, env = process.env, onL
124
126
  "open trusted-network share (or OTTO_BRAIN_ALLOW_INSECURE=1 to override)",
125
127
  });
126
128
  }
129
+ // `allowRemoteConfig` exists to gate NETWORK control (see its schema comment:
130
+ // "a brain is not remotely controllable until its owner opts in") - it is not
131
+ // meant to lock out the only caller that can ever reach a loopback-bound
132
+ // brain, which is the local daemon that spawned it. When nothing off-machine
133
+ // could possibly be a caller, config writes are always allowed; the flag only
134
+ // starts to matter once the bind is actually reachable from elsewhere.
135
+ const allowWrite = () => isLoopback(bindHost) || config.allowRemoteConfig;
127
136
  const store = loadProfilesStore(paths);
128
- const catalog = scanModels(config, env);
137
+ // Not const: deleting a model through the management API re-scans and replaces
138
+ // this, and every reader goes through a getter so nobody holds a stale array.
139
+ let catalog = scanModels(config, env);
129
140
  const needle = modelNeedle ?? config.defaultModel ?? store.lastModelId ?? undefined;
130
- const model = pickModel(catalog, needle);
141
+ const model = needle ? pickModel(catalog, needle) : pickAutoModel(catalog);
131
142
  let profile = forModel(store, model, config.defaults);
132
143
  const gpu = await queryGpu();
133
144
  if (gpu) {
@@ -218,6 +229,36 @@ export async function startService({ config, modelNeedle, env = process.env, onL
218
229
  }
219
230
  return redactConfig(config);
220
231
  };
232
+ // One CPU sampler for the lifetime of the service: it reports a busy fraction
233
+ // between successive calls, so a fresh one per request would always return null.
234
+ const cpuSampler = createCpuSampler();
235
+ const hostApi = createHostApi({
236
+ supervisor,
237
+ getCatalog: () => catalog,
238
+ rescan: () => {
239
+ catalog = scanModels(config, env);
240
+ return catalog;
241
+ },
242
+ getProfilesStore: () => store,
243
+ saveProfiles: (next) => saveProfilesStore(next, paths),
244
+ getProfileDefaults: () => config.defaults,
245
+ queryGpuInfo: queryGpu,
246
+ getRanking: () => {
247
+ try {
248
+ return results.rankModels(results.loadAll());
249
+ }
250
+ catch {
251
+ return [];
252
+ }
253
+ },
254
+ loadModel,
255
+ // The same gate as POST /__host/config. Deleting someone's model files over
256
+ // the network is strictly more dangerous than changing their default model,
257
+ // so it does not get a weaker one.
258
+ getAllowWrite: allowWrite,
259
+ getModelsDir: () => managedModelsDir(config, env),
260
+ sampleResources: () => sampleSystem(cpuSampler, { host: supervisor.host, port: supervisor.internalPort }),
261
+ });
221
262
  const handler = withAuth(createRouter({
222
263
  supervisor,
223
264
  telemetry,
@@ -230,7 +271,9 @@ export async function startService({ config, modelNeedle, env = process.env, onL
230
271
  getLockModel: () => config.lockModel,
231
272
  getDefaultModel: () => config.defaultModel,
232
273
  applyConfigPatch,
233
- getAllowConfigWrite: () => config.allowRemoteConfig,
274
+ getAllowConfigWrite: allowWrite,
275
+ hostApi,
276
+ getResources: () => sampleSystem(cpuSampler, { host: supervisor.host, port: supervisor.internalPort }),
234
277
  }), authToken);
235
278
  // TLS terminates in-process when configured; otherwise plain HTTP. The cert
236
279
  // manager issues/generates the first keypair before we listen, and hot-swaps
@@ -55,6 +55,12 @@ export declare class Supervisor extends EventEmitter {
55
55
  vramAtReadyBytes: number | null;
56
56
  vramBaselineBytes: number | null;
57
57
  command: string | null;
58
+ /**
59
+ * The argv of the running child, beside the formatted `command`. Kept as the
60
+ * array as well because a benchmark stores it (`ops/results.ts`) and a quoted
61
+ * shell line is for reading, not for re-parsing.
62
+ */
63
+ args: string[] | null;
58
64
  constructor({ runtime, internalPort, host, readyTimeoutMs, }: SupervisorOptions);
59
65
  get upstreamBase(): string;
60
66
  /** Start (or restart) the server for a model + profile. */
@@ -46,6 +46,7 @@ export class Supervisor extends EventEmitter {
46
46
  this.vramAtReadyBytes = null;
47
47
  this.vramBaselineBytes = null;
48
48
  this.command = null;
49
+ this.args = null;
49
50
  }
50
51
  get upstreamBase() {
51
52
  return `http://${this.host}:${this.internalPort}`;
@@ -60,6 +61,7 @@ export class Supervisor extends EventEmitter {
60
61
  this.vramBaselineBytes = await usedBytes();
61
62
  __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "starting");
62
63
  const args = buildArgs({ ...profile, modelPath: model.modelPath, mmprojPath: model.mmprojPath }, { port: this.internalPort, host: this.host });
64
+ this.args = args;
63
65
  this.command = formatCommand(this.runtime, args);
64
66
  __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, `launching: ${this.command}`);
65
67
  const started = Date.now();
@@ -1,5 +1,5 @@
1
1
  /**
2
- * A thin promisified wrapper over the `tailscale` CLI the OS boundary for the
2
+ * A thin promisified wrapper over the `tailscale` CLI - the OS boundary for the
3
3
  * `tailscale` TLS mode and the `listen.host: "tailscale"` bind. All Tailscale
4
4
  * interaction goes through here. Ported from otto-brain-relay's `tailscale.js`.
5
5
  *
@@ -4,11 +4,11 @@
4
4
  * generalized from otto-brain-relay's `cert.js`.
5
5
  *
6
6
  * Modes (resolved from `config.tls`):
7
- * - `files` read the cert/key paths you provide; no renewal.
8
- * - `self-signed` generate a local keypair on first run, cached under
7
+ * - `files` - read the cert/key paths you provide; no renewal.
8
+ * - `self-signed` - generate a local keypair on first run, cached under
9
9
  * `certDir`; regenerated when it nears expiry. Clients must
10
10
  * trust it (or pass `-k`); there is no chain of trust.
11
- * - `tailscale` issue/renew a real Let's Encrypt cert for this machine's
11
+ * - `tailscale` - issue/renew a real Let's Encrypt cert for this machine's
12
12
  * MagicDNS name via `tailscaled`, so tailnet clients see no
13
13
  * warnings. Renewal is hot: `renewed` fires with the new
14
14
  * { cert, key } and the server swaps its secure context
@@ -34,7 +34,7 @@ export interface TlsLogger {
34
34
  info?(message: string): void;
35
35
  warn?(message: string): void;
36
36
  }
37
- /** Concrete, fully-resolved options no nulls, no auto-detection left to do. */
37
+ /** Concrete, fully-resolved options - no nulls, no auto-detection left to do. */
38
38
  export interface CertManagerOptions {
39
39
  mode: "files" | "self-signed" | "tailscale";
40
40
  certFile: string;
@@ -4,11 +4,11 @@
4
4
  * generalized from otto-brain-relay's `cert.js`.
5
5
  *
6
6
  * Modes (resolved from `config.tls`):
7
- * - `files` read the cert/key paths you provide; no renewal.
8
- * - `self-signed` generate a local keypair on first run, cached under
7
+ * - `files` - read the cert/key paths you provide; no renewal.
8
+ * - `self-signed` - generate a local keypair on first run, cached under
9
9
  * `certDir`; regenerated when it nears expiry. Clients must
10
10
  * trust it (or pass `-k`); there is no chain of trust.
11
- * - `tailscale` issue/renew a real Let's Encrypt cert for this machine's
11
+ * - `tailscale` - issue/renew a real Let's Encrypt cert for this machine's
12
12
  * MagicDNS name via `tailscaled`, so tailnet clients see no
13
13
  * warnings. Renewal is hot: `renewed` fires with the new
14
14
  * { cert, key } and the server swaps its secure context