@otto-code/brain 0.8.5 → 0.8.7

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.
@@ -95,21 +95,37 @@ export function getCalibration(store, model, profile) {
95
95
  export function putCalibration(store, model, profile, measurement) {
96
96
  if (!store.calibrations)
97
97
  store.calibrations = {};
98
- if (!store.calibrations[model.id])
99
- store.calibrations[model.id] = {};
100
- store.calibrations[model.id][calibrationKey(profile)] = measurement;
98
+ const calibrations = store.calibrations;
99
+ if (!Object.hasOwn(calibrations, model.id)) {
100
+ Object.defineProperty(calibrations, model.id, {
101
+ configurable: true,
102
+ enumerable: true,
103
+ value: {},
104
+ });
105
+ }
106
+ Object.defineProperty(calibrations[model.id], calibrationKey(profile), {
107
+ configurable: true,
108
+ enumerable: true,
109
+ value: measurement,
110
+ writable: true,
111
+ });
101
112
  const key = geometryKey(model, profile);
102
113
  const layers = model?.metadata?.blockCount;
103
114
  if (key && layers) {
104
115
  if (!store.geometryCalibrations)
105
116
  store.geometryCalibrations = {};
106
- store.geometryCalibrations[key] = {
107
- kvBytesPerTokenPerLayer: measurement.kvBytesPerToken / layers,
108
- baseOverheadBytes: measurement.baseOverheadBytes,
109
- measuredAt: measurement.measuredAt,
110
- measuredOn: model.displayName,
111
- measuredLayers: layers,
112
- };
117
+ Object.defineProperty(store.geometryCalibrations, key, {
118
+ configurable: true,
119
+ enumerable: true,
120
+ value: {
121
+ kvBytesPerTokenPerLayer: measurement.kvBytesPerToken / layers,
122
+ baseOverheadBytes: measurement.baseOverheadBytes,
123
+ measuredAt: measurement.measuredAt,
124
+ measuredOn: model.displayName,
125
+ measuredLayers: layers,
126
+ },
127
+ writable: true,
128
+ });
113
129
  }
114
130
  return store;
115
131
  }
@@ -1,6 +1,11 @@
1
1
  /** Normalize a repo/id path: forward slashes, lowercased, trailing slashes trimmed. */
2
2
  function normalizePath(value) {
3
- return value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
3
+ const normalized = value.replaceAll("\\", "/");
4
+ let end = normalized.length;
5
+ while (end > 0 && normalized.charCodeAt(end - 1) === 47) {
6
+ end -= 1;
7
+ }
8
+ return normalized.slice(0, end).toLowerCase();
4
9
  }
5
10
  /** The final path segment (file name) of a scanned model's id. */
6
11
  function basenameOf(id) {
@@ -35,7 +35,7 @@ import type { Supervisor } from "./supervisor.js";
35
35
  * the API is this" for the rare change that no single flag describes. A daemon
36
36
  * reads both and never requires an exact package-version match.
37
37
  */
38
- export declare const HOST_API_VERSION = 2;
38
+ export declare const HOST_API_VERSION = 3;
39
39
  /**
40
40
  * What this brain can serve. The daemon folds this into `brain.host.status` and
41
41
  * Otto gates each tab on it, because the daemon and the brain version
@@ -74,6 +74,29 @@ export interface HostCapabilities {
74
74
  liveInference: boolean;
75
75
  /** Whether writes are currently permitted (allowRemoteConfig). */
76
76
  writable: boolean;
77
+ /** POST/GET /__host/jobs and POST /__host/jobs/cancel. */
78
+ jobs: boolean;
79
+ /** POST /__host/restart delegates a restart to the service owner. */
80
+ restart: boolean;
81
+ }
82
+ /** A long-running operation owned by this brain host, not its caller. */
83
+ export interface HostJob {
84
+ id: string;
85
+ kind: "pull" | "runtime-install" | "calibrate" | "sweep" | "bench";
86
+ label: string;
87
+ target: string | null;
88
+ status: "running" | "succeeded" | "failed" | "canceled";
89
+ percent: null;
90
+ message: string | null;
91
+ error: string | null;
92
+ startedAt: string;
93
+ finishedAt: string | null;
94
+ }
95
+ export interface HostJobRunner {
96
+ start: (kind: HostJob["kind"], target: string | null, args: string[]) => HostJob;
97
+ list: () => HostJob[];
98
+ cancel: (jobId: string) => Promise<HostJob[]>;
99
+ query: (args: string[]) => Promise<unknown>;
77
100
  }
78
101
  export interface HostApiDeps {
79
102
  supervisor: Supervisor;
@@ -98,6 +121,10 @@ export interface HostApiDeps {
98
121
  * its daemon keeps polling status.
99
122
  */
100
123
  statusEvents?: BrainStatusPublisher | null;
124
+ /** Long operations that must execute on this brain's machine. */
125
+ jobs?: HostJobRunner;
126
+ /** Gracefully restart the serving process after its HTTP acknowledgement. */
127
+ restart?: () => void;
101
128
  }
102
129
  /** One row of the model inventory: the scan, metadata, profile and score joined. */
103
130
  export interface InventoryRow {
@@ -14,7 +14,7 @@ const DEFAULT_LOG_LINES = 200;
14
14
  * the API is this" for the rare change that no single flag describes. A daemon
15
15
  * reads both and never requires an exact package-version match.
16
16
  */
17
- export const HOST_API_VERSION = 2;
17
+ export const HOST_API_VERSION = 3;
18
18
  /**
19
19
  * How often the SSE stream writes a comment line when nothing has changed.
20
20
  *
@@ -128,6 +128,8 @@ export function createHostApi(deps) {
128
128
  events: Boolean(deps.statusEvents?.ready),
129
129
  liveInference: Boolean(deps.statusEvents?.ready),
130
130
  writable: deps.getAllowWrite(),
131
+ jobs: Boolean(deps.jobs),
132
+ restart: Boolean(deps.restart),
131
133
  });
132
134
  /** Refuse a write unless the owner opted into remote configuration. */
133
135
  const guardWrite = (res) => {
@@ -432,10 +434,193 @@ export function createHostApi(deps) {
432
434
  handleEvents(req, res, publisher);
433
435
  return true;
434
436
  }
437
+ // A remote caller can restart a managed brain without gaining start/stop
438
+ // control over the host daemon. Acknowledge first so it does not race the
439
+ // connection it is about to close.
440
+ if (route === "/__host/restart" && method === "POST") {
441
+ if (!deps.restart) {
442
+ sendError(res, 404, "this brain cannot restart itself");
443
+ return true;
444
+ }
445
+ if (!guardWrite(res))
446
+ return true;
447
+ sendJson(res, { accepted: true });
448
+ queueMicrotask(() => deps.restart?.());
449
+ return true;
450
+ }
435
451
  if (route === "/__host/logs" && method === "GET") {
436
452
  handleLogs(res, params);
437
453
  return true;
438
454
  }
455
+ // Benchmark jobs are deliberately host-owned. A remote daemon only proxies
456
+ // these calls, so selecting a remote brain can never wake the local GPU.
457
+ if (route === "/__host/jobs" && method === "GET") {
458
+ if (!deps.jobs) {
459
+ sendError(res, 404, "this brain does not serve remote jobs");
460
+ }
461
+ else {
462
+ sendJson(res, { jobs: deps.jobs.list() });
463
+ }
464
+ return true;
465
+ }
466
+ if (route === "/__host/jobs/bench" && method === "POST") {
467
+ if (!deps.jobs) {
468
+ sendError(res, 404, "this brain does not serve remote jobs");
469
+ return true;
470
+ }
471
+ readJsonBody(req, 4096, (result) => {
472
+ if (!result.ok) {
473
+ sendError(res, 400, result.error);
474
+ return;
475
+ }
476
+ const model = result.body.model;
477
+ if (model !== undefined && model !== null && typeof model !== "string") {
478
+ sendError(res, 400, "model must be a string or null");
479
+ return;
480
+ }
481
+ try {
482
+ sendJson(res, {
483
+ job: deps.jobs?.start("bench", model ?? null, [
484
+ "bench",
485
+ ...(model ? ["--model", model] : []),
486
+ ]) ?? null,
487
+ });
488
+ }
489
+ catch (error) {
490
+ sendError(res, 409, errorMessage(error));
491
+ }
492
+ });
493
+ return true;
494
+ }
495
+ const jobStarts = {
496
+ "/__host/jobs/pull": {
497
+ kind: "pull",
498
+ makeArgs: (body) => {
499
+ const model = body.model;
500
+ if (typeof model !== "string" || !model)
501
+ throw new Error("model is required");
502
+ return { target: model, args: ["pull", "--json", "--", model] };
503
+ },
504
+ },
505
+ "/__host/jobs/add": {
506
+ kind: "pull",
507
+ makeArgs: (body) => {
508
+ const repo = body.repo;
509
+ const quant = body.quant;
510
+ if (typeof repo !== "string" || !repo || typeof quant !== "string" || !quant)
511
+ throw new Error("repo and quant are required");
512
+ return {
513
+ target: `${repo}#${quant}`,
514
+ args: ["add", "--quant", quant, "--json", "--", repo],
515
+ };
516
+ },
517
+ },
518
+ "/__host/jobs/runtime-install": {
519
+ kind: "runtime-install",
520
+ makeArgs: (body) => {
521
+ const build = body.build;
522
+ if (build !== undefined && build !== null && typeof build !== "string")
523
+ throw new Error("build must be a string or null");
524
+ return {
525
+ target: typeof build === "string" ? build : null,
526
+ args: [
527
+ "runtime",
528
+ "install",
529
+ "--json",
530
+ ...(typeof build === "string" ? ["--build", build] : []),
531
+ ],
532
+ };
533
+ },
534
+ },
535
+ "/__host/jobs/calibrate": {
536
+ kind: "calibrate",
537
+ makeArgs: (body) => {
538
+ const model = body.model;
539
+ if (typeof model !== "string" || !model)
540
+ throw new Error("model is required");
541
+ return { target: model, args: ["calibrate", "--model", model, "--json"] };
542
+ },
543
+ },
544
+ "/__host/jobs/sweep": {
545
+ kind: "sweep",
546
+ makeArgs: (body) => {
547
+ const model = body.model;
548
+ if (typeof model !== "string" || !model)
549
+ throw new Error("model is required");
550
+ return { target: model, args: ["sweep", "--model", model, "--json"] };
551
+ },
552
+ },
553
+ };
554
+ const start = jobStarts[route];
555
+ if (start && method === "POST") {
556
+ if (!deps.jobs) {
557
+ sendError(res, 404, "this brain does not serve remote jobs");
558
+ return true;
559
+ }
560
+ if (!guardWrite(res))
561
+ return true;
562
+ readJsonBody(req, 4096, (result) => {
563
+ if (!result.ok) {
564
+ sendError(res, 400, result.error);
565
+ return;
566
+ }
567
+ try {
568
+ const spec = start.makeArgs(result.body);
569
+ sendJson(res, { job: deps.jobs?.start(start.kind, spec.target, spec.args) ?? null });
570
+ }
571
+ catch (error) {
572
+ sendError(res, 400, errorMessage(error));
573
+ }
574
+ });
575
+ return true;
576
+ }
577
+ if (route === "/__host/catalog" && method === "GET") {
578
+ void deps.jobs?.query(["catalog", "--json"]).then((models) => sendJson(res, { models }));
579
+ return true;
580
+ }
581
+ if (route === "/__host/runtimes" && method === "GET") {
582
+ void deps.jobs
583
+ ?.query(["runtime", "list", "--json"])
584
+ .then((runtimes) => sendJson(res, { runtimes }));
585
+ return true;
586
+ }
587
+ if (route === "/__host/hf/search" && method === "GET") {
588
+ const query = params.get("query") ?? "";
589
+ const limit = Math.max(1, Math.min(100, Number(params.get("limit")) || 25));
590
+ void deps.jobs
591
+ ?.query(["search", "--json", "--limit", String(limit), "--", query])
592
+ .then((results) => sendJson(res, { results }));
593
+ return true;
594
+ }
595
+ if (route === "/__host/hf/quants" && method === "GET") {
596
+ const repo = params.get("repo") ?? "";
597
+ void deps.jobs
598
+ ?.query(["add", "--list-quants", "--json", "--", repo])
599
+ .then((quants) => sendJson(res, { quants }));
600
+ return true;
601
+ }
602
+ if (route === "/__host/jobs/cancel" && method === "POST") {
603
+ if (!deps.jobs) {
604
+ sendError(res, 404, "this brain does not serve remote jobs");
605
+ return true;
606
+ }
607
+ readJsonBody(req, 4096, (result) => {
608
+ if (!result.ok) {
609
+ sendError(res, 400, result.error);
610
+ return;
611
+ }
612
+ const jobId = result.body.jobId;
613
+ if (typeof jobId !== "string" || !jobId) {
614
+ sendError(res, 400, "jobId is required");
615
+ return;
616
+ }
617
+ void deps.jobs
618
+ ?.cancel(jobId)
619
+ .then((jobs) => sendJson(res, { jobs }))
620
+ .catch((error) => sendError(res, 500, errorMessage(error)));
621
+ });
622
+ return true;
623
+ }
439
624
  if (route === "/__host/resources" && method === "GET") {
440
625
  void (async () => {
441
626
  try {
@@ -1,14 +1,3 @@
1
- /**
2
- * The headless brain service: the router + supervisor bound to a port, with the
3
- * VRAM fit, on-demand model switching, remote auth, built-in TLS, and pid-file
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
6
- * takes whatever resolveRuntime picks (managed or LM Studio).
7
- *
8
- * TLS is served in-process (config.tls): HTTPS with a files / self-signed /
9
- * tailscale certificate, hot-swapped on renewal. This is what lets the brain be
10
- * exposed securely over a network with no relay in front of it.
11
- */
12
1
  import http from "node:http";
13
2
  import type { BrainConfig } from "../config/schema.js";
14
3
  import type { Model } from "../types.js";
@@ -9,6 +9,8 @@
9
9
  * tailscale certificate, hot-swapped on renewal. This is what lets the brain be
10
10
  * exposed securely over a network with no relay in front of it.
11
11
  */
12
+ import { execFile, spawn } from "node:child_process";
13
+ import { randomUUID } from "node:crypto";
12
14
  import http from "node:http";
13
15
  import https from "node:https";
14
16
  import { getCalibration, forModel, loadPersistedConfig, loadProfilesStore, saveBrainConfig, saveProfilesStore, } from "../config/index.js";
@@ -54,6 +56,130 @@ function collectEvals() {
54
56
  return { rankings: [], latest: [], variance: [], runCount: 0 };
55
57
  }
56
58
  }
59
+ const REMOTE_JOB_RETENTION_MS = 5 * 60000;
60
+ /**
61
+ * Runs a benchmark as a child of the brain service. This is intentionally here
62
+ * rather than in the connecting daemon: its process, model store, results
63
+ * directory and GPU all belong to the host that is being benchmarked.
64
+ */
65
+ class ServiceJobRunner {
66
+ constructor() {
67
+ this.jobs = new Map();
68
+ }
69
+ start(kind, target, args) {
70
+ const running = [...this.jobs.values()].find((job) => job.status === "running");
71
+ if (running)
72
+ throw new Error(`Another operation is already running (${running.label}).`);
73
+ const job = {
74
+ id: `brainjob_${randomUUID()}`,
75
+ kind,
76
+ label: kind === "bench"
77
+ ? target
78
+ ? `Benchmark ${target}`
79
+ : "Benchmark models"
80
+ : `${kind} ${target ?? ""}`.trim(),
81
+ target,
82
+ status: "running",
83
+ percent: null,
84
+ message: null,
85
+ error: null,
86
+ startedAt: new Date().toISOString(),
87
+ finishedAt: null,
88
+ child: null,
89
+ };
90
+ // The service is launched by the same CLI entry point as `otto-brain bench`.
91
+ // Reusing that entry point keeps its config/path resolution on this host.
92
+ const entry = process.argv[1];
93
+ if (!entry)
94
+ throw new Error("The brain service has no CLI entry point.");
95
+ const child = spawn(process.execPath, [entry, ...args], {
96
+ cwd: process.cwd(),
97
+ env: process.env,
98
+ stdio: ["ignore", "ignore", "pipe"],
99
+ windowsHide: true,
100
+ });
101
+ job.child = child;
102
+ this.jobs.set(job.id, job);
103
+ child.stderr?.setEncoding("utf8");
104
+ child.stderr?.on("data", (chunk) => {
105
+ const last = chunk.trim().split(/\r?\n/).at(-1)?.trim();
106
+ if (last)
107
+ job.message = last.slice(-1000);
108
+ });
109
+ child.once("error", (error) => this.finish(job, "failed", error.message));
110
+ child.once("close", (code) => {
111
+ if (job.status !== "running")
112
+ return;
113
+ this.finish(job, code === 0 ? "succeeded" : "failed", code === 0 ? null : `Exited with code ${code}.`);
114
+ });
115
+ return this.publicJob(job);
116
+ }
117
+ async query(args) {
118
+ const entry = process.argv[1];
119
+ if (!entry)
120
+ throw new Error("The brain service has no CLI entry point.");
121
+ return new Promise((resolve, reject) => {
122
+ const child = spawn(process.execPath, [entry, ...args], {
123
+ cwd: process.cwd(),
124
+ env: process.env,
125
+ stdio: ["ignore", "pipe", "pipe"],
126
+ windowsHide: true,
127
+ });
128
+ let out = "";
129
+ let err = "";
130
+ child.stdout?.setEncoding("utf8");
131
+ child.stderr?.setEncoding("utf8");
132
+ child.stdout?.on("data", (chunk) => (out += chunk));
133
+ child.stderr?.on("data", (chunk) => (err += chunk));
134
+ child.once("error", reject);
135
+ child.once("close", (code) => {
136
+ if (code !== 0)
137
+ return reject(new Error(err.trim() || `Exited with code ${code}.`));
138
+ try {
139
+ resolve(JSON.parse(out));
140
+ }
141
+ catch {
142
+ reject(new Error("The brain command returned invalid JSON."));
143
+ }
144
+ });
145
+ });
146
+ }
147
+ list() {
148
+ const cutoff = Date.now() - REMOTE_JOB_RETENTION_MS;
149
+ for (const [id, job] of this.jobs) {
150
+ if (job.finishedAt && Date.parse(job.finishedAt) < cutoff)
151
+ this.jobs.delete(id);
152
+ }
153
+ return [...this.jobs.values()]
154
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt))
155
+ .map((job) => this.publicJob(job));
156
+ }
157
+ async cancel(jobId) {
158
+ const job = this.jobs.get(jobId);
159
+ if (!job || job.status !== "running" || !job.child)
160
+ return this.list();
161
+ const child = job.child;
162
+ if (process.platform === "win32" && child.pid) {
163
+ await new Promise((resolve) => execFile("taskkill", ["/pid", String(child.pid), "/t", "/f"], () => resolve()));
164
+ }
165
+ else {
166
+ child.kill("SIGTERM");
167
+ }
168
+ this.finish(job, "canceled", "Canceled.");
169
+ return this.list();
170
+ }
171
+ finish(job, status, error) {
172
+ if (job.status !== "running")
173
+ return;
174
+ job.child = null;
175
+ job.status = status;
176
+ job.error = error;
177
+ job.finishedAt = new Date().toISOString();
178
+ }
179
+ publicJob({ child: _child, ...job }) {
180
+ return job;
181
+ }
182
+ }
57
183
  function isLoopback(host) {
58
184
  return host === "127.0.0.1" || host === "::1" || host === "localhost";
59
185
  }
@@ -263,6 +389,10 @@ export async function startService({ config, modelNeedle, env = process.env, onL
263
389
  // /__host/events. One instance, so `capabilities.events` and the stream can
264
390
  // never disagree about whether this brain publishes.
265
391
  const statusEvents = new BrainStatusPublisher();
392
+ const jobs = new ServiceJobRunner();
393
+ // Assigned once `stop` exists below. This indirection lets the management API
394
+ // answer a remote restart request before closing its own socket.
395
+ let requestRestart = () => { };
266
396
  const hostApi = createHostApi({
267
397
  supervisor,
268
398
  getCatalog: () => catalog,
@@ -290,6 +420,8 @@ export async function startService({ config, modelNeedle, env = process.env, onL
290
420
  getModelsDir: () => managedModelsDir(config, env),
291
421
  sampleResources: () => sampleSystem(cpuSampler, { host: supervisor.host, port: supervisor.internalPort }),
292
422
  statusEvents,
423
+ jobs,
424
+ restart: () => requestRestart(),
293
425
  });
294
426
  const handler = withAuth(createRouter({
295
427
  supervisor,
@@ -366,6 +498,9 @@ export async function startService({ config, modelNeedle, env = process.env, onL
366
498
  await new Promise((resolve) => server.close(() => resolve()));
367
499
  removePidFile(env);
368
500
  };
501
+ requestRestart = () => {
502
+ void stop().finally(() => process.exit(75));
503
+ };
369
504
  return {
370
505
  server,
371
506
  supervisor,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otto-code/brain",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
4
4
  "description": "Otto Brain - self-contained host for local GGUF models, with measured VRAM budgeting and reasoning-budget control",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "bin": {