@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.
- package/dist/commands/bench.js +2 -2
- package/dist/commands/calibrate.js +11 -2
- package/dist/commands/catalog.d.ts +1 -0
- package/dist/commands/catalog.js +1 -0
- package/dist/commands/pull.d.ts +1 -0
- package/dist/commands/pull.js +12 -3
- package/dist/commands/search.d.ts +1 -0
- package/dist/commands/search.js +12 -2
- package/dist/config/index.d.ts +2 -2
- package/dist/config/index.js +2 -2
- package/dist/config/profile-edit.d.ts +88 -1
- package/dist/config/profile-edit.js +294 -43
- package/dist/config/profiles.d.ts +19 -3
- package/dist/config/profiles.js +52 -4
- package/dist/config/schema.d.ts +616 -0
- package/dist/config/schema.js +65 -3
- package/dist/config/store.js +7 -4
- package/dist/gguf.d.ts +7 -0
- package/dist/gguf.js +15 -2
- package/dist/models/download.d.ts +1 -1
- package/dist/models/download.js +2 -2
- package/dist/models/enrich.d.ts +6 -0
- package/dist/models/enrich.js +27 -1
- package/dist/models/index.d.ts +1 -1
- package/dist/models/index.js +4 -3
- package/dist/ops/archive.d.ts +14 -1
- package/dist/ops/archive.js +9 -5
- package/dist/ops/calibrate.d.ts +38 -3
- package/dist/ops/calibrate.js +68 -19
- package/dist/ops/report.js +51 -1
- package/dist/ops/results.d.ts +77 -11
- package/dist/ops/results.js +84 -14
- package/dist/ops/sweep.d.ts +38 -1
- package/dist/ops/sweep.js +61 -10
- package/dist/runtime/args.d.ts +15 -2
- package/dist/runtime/args.js +60 -5
- package/dist/runtime/managed.js +2 -2
- package/dist/service/activity.d.ts +19 -0
- package/dist/service/activity.js +47 -4
- package/dist/service/host-api.d.ts +28 -4
- package/dist/service/host-api.js +109 -28
- package/dist/service/log-format.d.ts +18 -0
- package/dist/service/log-format.js +32 -0
- package/dist/service/process-pool.d.ts +45 -0
- package/dist/service/process-pool.js +271 -0
- package/dist/service/router.d.ts +74 -3
- package/dist/service/router.js +277 -51
- package/dist/service/run-log.d.ts +6 -1
- package/dist/service/run-log.js +46 -4
- package/dist/service/scheduler.d.ts +250 -31
- package/dist/service/scheduler.js +408 -63
- package/dist/service/serve.d.ts +4 -0
- package/dist/service/serve.js +376 -142
- package/dist/service/status-events.d.ts +14 -1
- package/dist/service/status-events.js +112 -12
- package/dist/service/supervisor.d.ts +9 -7
- package/dist/service/supervisor.js +37 -12
- package/dist/sysmon.d.ts +15 -0
- package/dist/sysmon.js +56 -9
- package/dist/tui/app.d.ts +8 -2
- package/dist/tui/app.js +83 -26
- package/dist/types.d.ts +18 -0
- package/dist/vram.d.ts +37 -0
- package/dist/vram.js +57 -18
- package/package.json +1 -1
package/dist/service/serve.js
CHANGED
|
@@ -13,7 +13,7 @@ import { execFile, spawn } from "node:child_process";
|
|
|
13
13
|
import { randomUUID } from "node:crypto";
|
|
14
14
|
import http from "node:http";
|
|
15
15
|
import https from "node:https";
|
|
16
|
-
import {
|
|
16
|
+
import { getCalibrationForBudget, forModel, loadPersistedConfig, loadProfilesStore, put, putCalibration, saveBrainConfig, saveProfilesStore, } from "../config/index.js";
|
|
17
17
|
import { resolveBrainPaths } from "../config/paths.js";
|
|
18
18
|
import { query as queryGpu } from "../gpu.js";
|
|
19
19
|
import { managedModelsDir, pickAutoModel, pickModel, scanModels } from "../models/index.js";
|
|
@@ -26,16 +26,19 @@ import * as archive from "../ops/archive.js";
|
|
|
26
26
|
import { calibrate } from "../ops/calibrate.js";
|
|
27
27
|
import { sweep } from "../ops/sweep.js";
|
|
28
28
|
import * as bench from "../bench/index.js";
|
|
29
|
-
import { createCpuSampler, sample as sampleSystem } from "../sysmon.js";
|
|
29
|
+
import { createCpuSampler, sample as sampleSystem, slots as sampleSlots } from "../sysmon.js";
|
|
30
30
|
import { createHostApi } from "./host-api.js";
|
|
31
31
|
import { errorMessage } from "./http-util.js";
|
|
32
|
-
import { createRouter, Telemetry } from "./router.js";
|
|
33
|
-
import {
|
|
32
|
+
import { createRouter, createSlotEraser, Telemetry } from "./router.js";
|
|
33
|
+
import { Scheduler } from "./scheduler.js";
|
|
34
|
+
import { ModelProcessPool } from "./process-pool.js";
|
|
35
|
+
import { BrainLogPublisher, BrainStatusPublisher } from "./status-events.js";
|
|
34
36
|
import { Supervisor } from "./supervisor.js";
|
|
35
37
|
import * as tailscale from "./tailscale.js";
|
|
36
38
|
import { CertManager, resolveTlsOptions } from "./tls.js";
|
|
37
39
|
import { removePidFile, writePidFile } from "./pid-lock.js";
|
|
38
40
|
import { createBrainRunLog } from "./run-log.js";
|
|
41
|
+
import { formatBrainLog } from "./log-format.js";
|
|
39
42
|
/** The effective config with secrets masked, for the `/__host/config` read. */
|
|
40
43
|
function redactConfig(config) {
|
|
41
44
|
return {
|
|
@@ -62,16 +65,77 @@ function collectEvals() {
|
|
|
62
65
|
}
|
|
63
66
|
}
|
|
64
67
|
const REMOTE_JOB_RETENTION_MS = 5 * 60000;
|
|
68
|
+
/** Model pulls only write the model store, so independent entries can transfer together. */
|
|
69
|
+
export function canRunAlongsideModelPull(kind) {
|
|
70
|
+
return kind === "pull" || kind === "runtime-remove";
|
|
71
|
+
}
|
|
72
|
+
export function componentOnlyArgs(args, components) {
|
|
73
|
+
const separator = args.lastIndexOf("--");
|
|
74
|
+
const beforeTarget = separator === -1 ? args : args.slice(0, separator);
|
|
75
|
+
const target = separator === -1 ? [] : args.slice(separator);
|
|
76
|
+
const base = [];
|
|
77
|
+
for (let index = 0; index < beforeTarget.length; index += 1) {
|
|
78
|
+
const arg = beforeTarget[index];
|
|
79
|
+
if (arg === "--primary-only")
|
|
80
|
+
continue;
|
|
81
|
+
if (arg === "--component") {
|
|
82
|
+
index += 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
base.push(arg);
|
|
86
|
+
}
|
|
87
|
+
return [
|
|
88
|
+
...base,
|
|
89
|
+
"--components-only",
|
|
90
|
+
...components.flatMap((component) => ["--component", component]),
|
|
91
|
+
...target,
|
|
92
|
+
];
|
|
93
|
+
}
|
|
65
94
|
class ServiceJobRunner {
|
|
66
|
-
constructor(onPullCompleted, runResidentJob) {
|
|
95
|
+
constructor(onPullCompleted, runResidentJob, scheduler, resolveTarget, log) {
|
|
67
96
|
this.onPullCompleted = onPullCompleted;
|
|
68
97
|
this.runResidentJob = runResidentJob;
|
|
98
|
+
this.scheduler = scheduler;
|
|
99
|
+
this.resolveTarget = resolveTarget;
|
|
100
|
+
this.log = log;
|
|
69
101
|
this.jobs = new Map();
|
|
70
102
|
}
|
|
71
|
-
|
|
103
|
+
area(kind) {
|
|
104
|
+
return kind === "pull" || kind === "runtime-install" || kind === "runtime-remove"
|
|
105
|
+
? "library"
|
|
106
|
+
: "model";
|
|
107
|
+
}
|
|
108
|
+
start(kind, target, args, pull) {
|
|
109
|
+
if (kind === "pull" && pull) {
|
|
110
|
+
const existing = [...this.jobs.values()].find((job) => job.kind === "pull" && job.status === "running" && job.pull?.entryKey === pull.entryKey);
|
|
111
|
+
if (existing?.pull) {
|
|
112
|
+
const components = pull.components.filter((component) => !existing.pull.components.has(component));
|
|
113
|
+
if (components.length > 0) {
|
|
114
|
+
for (const component of components)
|
|
115
|
+
existing.pull.components.add(component);
|
|
116
|
+
existing.pull.queue.push({ args: componentOnlyArgs(args, components), components });
|
|
117
|
+
existing.message = `Queued ${components.join(", ")}…`;
|
|
118
|
+
this.log("library", `job ${existing.id} queued bundle components: ${components.join(", ")}`);
|
|
119
|
+
}
|
|
120
|
+
return this.publicJob(existing);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const isResidentOperation = kind === "calibrate" || kind === "sweep" || kind === "bench";
|
|
72
124
|
const running = [...this.jobs.values()].find((job) => job.status === "running");
|
|
73
|
-
|
|
74
|
-
|
|
125
|
+
const activeNonDownload = [...this.jobs.values()].find((job) => job.status === "running" && job.kind !== "pull");
|
|
126
|
+
// Resident operations join Scheduler instead of rejecting one another. It
|
|
127
|
+
// owns their turn order with API requests and performs every model swap.
|
|
128
|
+
const conflict = isResidentOperation
|
|
129
|
+
? undefined
|
|
130
|
+
: canRunAlongsideModelPull(kind)
|
|
131
|
+
? activeNonDownload
|
|
132
|
+
: running;
|
|
133
|
+
if (conflict)
|
|
134
|
+
throw new Error(`Another operation is already running (${conflict.label}).`);
|
|
135
|
+
const residentTarget = isResidentOperation ? this.resolveTarget(target) : null;
|
|
136
|
+
if (isResidentOperation && !residentTarget) {
|
|
137
|
+
throw new Error("No installed model is available for this operation.");
|
|
138
|
+
}
|
|
75
139
|
const job = {
|
|
76
140
|
id: `brainjob_${randomUUID()}`,
|
|
77
141
|
kind,
|
|
@@ -89,21 +153,43 @@ class ServiceJobRunner {
|
|
|
89
153
|
finishedAt: null,
|
|
90
154
|
child: null,
|
|
91
155
|
controller: null,
|
|
156
|
+
...(kind === "pull" && pull
|
|
157
|
+
? {
|
|
158
|
+
pull: {
|
|
159
|
+
entryKey: pull.entryKey,
|
|
160
|
+
components: new Set(pull.components),
|
|
161
|
+
queue: [],
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
: {}),
|
|
92
165
|
};
|
|
93
166
|
this.jobs.set(job.id, job);
|
|
167
|
+
this.log(this.area(job.kind), `job ${job.id} started: ${job.label}`);
|
|
94
168
|
if (kind === "calibrate" || kind === "sweep" || kind === "bench") {
|
|
169
|
+
const model = residentTarget;
|
|
95
170
|
const controller = new AbortController();
|
|
96
171
|
job.controller = controller;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
job.
|
|
172
|
+
job.queuePosition = this.scheduler.stats().queued + 1;
|
|
173
|
+
job.message = `Queued for ${model.displayName}`;
|
|
174
|
+
void this.scheduler
|
|
175
|
+
.submit(model, (supervisor) => controller.signal.aborted
|
|
176
|
+
? Promise.reject(new Error("Operation canceled."))
|
|
177
|
+
: this.runResidentJob(supervisor, kind, model.id, {
|
|
178
|
+
message: (value) => {
|
|
179
|
+
if (job.status === "running")
|
|
180
|
+
job.message = value.slice(-1000);
|
|
181
|
+
},
|
|
182
|
+
percent: (value) => {
|
|
183
|
+
if (job.status === "running")
|
|
184
|
+
job.percent = value;
|
|
185
|
+
},
|
|
186
|
+
}, controller.signal), {
|
|
187
|
+
kind: kind === "bench" ? "benchmark" : kind,
|
|
188
|
+
onStart: () => {
|
|
189
|
+
job.queuePosition = null;
|
|
190
|
+
job.message = `${job.label} started`;
|
|
105
191
|
},
|
|
106
|
-
}
|
|
192
|
+
})
|
|
107
193
|
.then(() => this.finish(job, "succeeded", null))
|
|
108
194
|
.catch((error) => this.finish(job, controller.signal.aborted ? "canceled" : "failed", errorMessage(error)));
|
|
109
195
|
return this.publicJob(job);
|
|
@@ -113,20 +199,36 @@ class ServiceJobRunner {
|
|
|
113
199
|
const entry = process.argv[1];
|
|
114
200
|
if (!entry)
|
|
115
201
|
throw new Error("The brain service has no CLI entry point.");
|
|
202
|
+
this.startChild(job, args, entry);
|
|
203
|
+
return this.publicJob(job);
|
|
204
|
+
}
|
|
205
|
+
startChild(job, args, entry) {
|
|
116
206
|
const child = spawn(process.execPath, [entry, ...args], {
|
|
117
207
|
cwd: process.cwd(),
|
|
118
208
|
env: process.env,
|
|
119
|
-
stdio: ["ignore", "
|
|
209
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
120
210
|
windowsHide: true,
|
|
121
211
|
});
|
|
122
212
|
job.child = child;
|
|
213
|
+
this.log(this.area(job.kind), `job ${job.id} spawned pid ${child.pid ?? "unknown"}: ${args.join(" ")}`);
|
|
214
|
+
child.stdout?.setEncoding("utf8");
|
|
123
215
|
child.stderr?.setEncoding("utf8");
|
|
124
|
-
child.
|
|
216
|
+
child.stdout?.on("data", (chunk) => this.ingestOutput(job, "stdout", chunk));
|
|
217
|
+
child.stderr?.on("data", (chunk) => this.ingestOutput(job, "stderr", chunk));
|
|
125
218
|
child.once("error", (error) => this.finish(job, "failed", error.message));
|
|
126
219
|
child.once("close", (code) => {
|
|
127
220
|
if (job.status !== "running")
|
|
128
221
|
return;
|
|
129
|
-
|
|
222
|
+
job.child = null;
|
|
223
|
+
if (code === 0 && job.kind === "pull" && job.pull) {
|
|
224
|
+
const next = job.pull.queue.shift();
|
|
225
|
+
if (next) {
|
|
226
|
+
job.message = `Queued ${next.components.join(", ")}…`;
|
|
227
|
+
this.startChild(job, next.args, entry);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (code === 0 && job.kind === "pull") {
|
|
130
232
|
try {
|
|
131
233
|
// Downloads happen in a child process, but inventory is served from
|
|
132
234
|
// this process's in-memory scan. Reconcile before reporting success
|
|
@@ -140,9 +242,8 @@ class ServiceJobRunner {
|
|
|
140
242
|
}
|
|
141
243
|
this.finish(job, code === 0 ? "succeeded" : "failed", code === 0 ? null : (job.message ?? `Exited with code ${code}.`));
|
|
142
244
|
});
|
|
143
|
-
return this.publicJob(job);
|
|
144
245
|
}
|
|
145
|
-
async query(args) {
|
|
246
|
+
async query(args, area = "library") {
|
|
146
247
|
const entry = process.argv[1];
|
|
147
248
|
if (!entry)
|
|
148
249
|
throw new Error("The brain service has no CLI entry point.");
|
|
@@ -157,10 +258,21 @@ class ServiceJobRunner {
|
|
|
157
258
|
let err = "";
|
|
158
259
|
child.stdout?.setEncoding("utf8");
|
|
159
260
|
child.stderr?.setEncoding("utf8");
|
|
160
|
-
|
|
161
|
-
child.
|
|
162
|
-
|
|
261
|
+
this.log(area, `query started: ${args.join(" ")}`);
|
|
262
|
+
child.stdout?.on("data", (chunk) => {
|
|
263
|
+
out += chunk;
|
|
264
|
+
this.logOutput(area, "query stdout", chunk);
|
|
265
|
+
});
|
|
266
|
+
child.stderr?.on("data", (chunk) => {
|
|
267
|
+
err += chunk;
|
|
268
|
+
this.logOutput(area, "query stderr", chunk);
|
|
269
|
+
});
|
|
270
|
+
child.once("error", (error) => {
|
|
271
|
+
this.log(area, `query failed to start: ${error.message}`);
|
|
272
|
+
reject(error);
|
|
273
|
+
});
|
|
163
274
|
child.once("close", (code) => {
|
|
275
|
+
this.log(area, `query exited with code ${code}`);
|
|
164
276
|
if (code !== 0)
|
|
165
277
|
return reject(new Error(err.trim() || `Exited with code ${code}.`));
|
|
166
278
|
try {
|
|
@@ -198,10 +310,11 @@ class ServiceJobRunner {
|
|
|
198
310
|
else {
|
|
199
311
|
child.kill("SIGTERM");
|
|
200
312
|
}
|
|
313
|
+
this.log(this.area(job.kind), `canceling job ${job.id}`);
|
|
201
314
|
this.finish(job, "canceled", "Canceled.");
|
|
202
315
|
return this.list();
|
|
203
316
|
}
|
|
204
|
-
ingestOutput(job, chunk) {
|
|
317
|
+
ingestOutput(job, source, chunk) {
|
|
205
318
|
for (const line of chunk
|
|
206
319
|
.split(/[\r\n]+/u)
|
|
207
320
|
.map((value) => value.trim())
|
|
@@ -209,6 +322,7 @@ class ServiceJobRunner {
|
|
|
209
322
|
const progress = /(\d{1,3})\s*%/u.exec(line);
|
|
210
323
|
if (progress)
|
|
211
324
|
job.percent = Math.max(0, Math.min(100, Number(progress[1])));
|
|
325
|
+
this.log(this.area(job.kind), `job ${job.id} ${source}: ${line}`);
|
|
212
326
|
// The final JSON result is not a useful status label. Keep progress and
|
|
213
327
|
// actionable text instead, so a failed bundle pull tells the user why.
|
|
214
328
|
if (line !== "[" && !/^[\]{}",]+$/u.test(line))
|
|
@@ -225,8 +339,17 @@ class ServiceJobRunner {
|
|
|
225
339
|
job.finishedAt = new Date().toISOString();
|
|
226
340
|
if (status === "succeeded")
|
|
227
341
|
job.percent = 100;
|
|
342
|
+
this.log(this.area(job.kind), `job ${job.id} ${status}${error ? `: ${error}` : ""}`);
|
|
228
343
|
}
|
|
229
|
-
|
|
344
|
+
logOutput(area, source, chunk) {
|
|
345
|
+
for (const line of chunk
|
|
346
|
+
.split(/[\r\n]+/u)
|
|
347
|
+
.map((entry) => entry.trim())
|
|
348
|
+
.filter(Boolean)) {
|
|
349
|
+
this.log(area, `${source}: ${line}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
publicJob({ child: _child, controller: _controller, pull: _pull, ...job }) {
|
|
230
353
|
return job;
|
|
231
354
|
}
|
|
232
355
|
}
|
|
@@ -272,9 +395,16 @@ function withAuth(inner, token) {
|
|
|
272
395
|
}
|
|
273
396
|
export async function startService({ config, modelNeedle, env = process.env, onLog = () => { }, }) {
|
|
274
397
|
const runLog = createBrainRunLog(env);
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
398
|
+
const logEvents = new BrainLogPublisher();
|
|
399
|
+
const log = (area, message) => {
|
|
400
|
+
const line = formatBrainLog(area, message);
|
|
401
|
+
for (const entry of runLog.write(line)) {
|
|
402
|
+
logEvents.publish(entry);
|
|
403
|
+
// The daemon also captures this foreground child's stderr before the
|
|
404
|
+
// management listener exists. Give it the exact durable entry, rather
|
|
405
|
+
// than a second un-timestamped rendering of the same event.
|
|
406
|
+
onLog(entry);
|
|
407
|
+
}
|
|
278
408
|
};
|
|
279
409
|
// The management API must be useful before any setup exists: the Brain page
|
|
280
410
|
// is where the owner downloads both a runtime and their first model. Keep the
|
|
@@ -331,7 +461,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
331
461
|
// service down. An explicit CLI selection remains an actionable error.
|
|
332
462
|
if (modelNeedle)
|
|
333
463
|
throw error;
|
|
334
|
-
log(`note: ${error instanceof Error ? error.message : "configured model is unavailable"}`);
|
|
464
|
+
log("server", `note: ${error instanceof Error ? error.message : "configured model is unavailable"}`);
|
|
335
465
|
}
|
|
336
466
|
}
|
|
337
467
|
let profile = model ? forModel(store, model, config.defaults) : null;
|
|
@@ -340,66 +470,69 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
340
470
|
const fit = vram.fitToBudget({
|
|
341
471
|
model,
|
|
342
472
|
profile,
|
|
343
|
-
calibration:
|
|
473
|
+
calibration: getCalibrationForBudget(store, model, profile),
|
|
344
474
|
totalVramBytes: gpu.totalBytes,
|
|
345
475
|
});
|
|
346
476
|
if (!fit.adjusted && !fit.budget.fits) {
|
|
347
477
|
// Starting the host is what exposes the Library and model profile UI.
|
|
348
478
|
// An automatic startup candidate that cannot load must therefore leave
|
|
349
479
|
// the host alive and unloaded, not make the only recovery surface vanish.
|
|
350
|
-
log(`note: not loading ${model.displayName}: ${fit.reason ?? "does not fit in available VRAM"}`);
|
|
480
|
+
log("model", `note: not loading ${model.displayName}: ${fit.reason ?? "does not fit in available VRAM"}`);
|
|
351
481
|
model = null;
|
|
352
482
|
profile = null;
|
|
353
483
|
}
|
|
354
484
|
else {
|
|
355
485
|
if (fit.adjusted && fit.reason)
|
|
356
|
-
log(`note: ${fit.reason}`);
|
|
486
|
+
log("model", `note: ${fit.reason}`);
|
|
357
487
|
profile = fit.profile;
|
|
358
488
|
}
|
|
359
489
|
}
|
|
360
490
|
const telemetry = new Telemetry();
|
|
361
|
-
const supervisor = new Supervisor({
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
491
|
+
const supervisor = new Supervisor({
|
|
492
|
+
runtime,
|
|
493
|
+
paths,
|
|
494
|
+
getProfilesStore: () => store,
|
|
495
|
+
logVerbosity: config.runtime.logVerbosity,
|
|
366
496
|
});
|
|
367
|
-
supervisor.on("
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
// guarantees two switches (e.g. a config write racing a request-driven switch)
|
|
371
|
-
// can never overlap two supervisor.start() calls, whichever caller triggers them.
|
|
372
|
-
let modelSwitchChain = Promise.resolve();
|
|
373
|
-
const loadModelUnsafe = async (target) => {
|
|
497
|
+
supervisor.on("log", (line) => log("server", line));
|
|
498
|
+
supervisor.on("crashed", (error) => log("model", `FATAL ${error}`));
|
|
499
|
+
const loadModelInto = async (resident, target, reservedElsewhereBytes) => {
|
|
374
500
|
// A runtime can be installed from the Library tab after this service starts.
|
|
375
501
|
// Resolve it at load time so the user does not have to restart the brain.
|
|
376
|
-
|
|
377
|
-
if (!
|
|
502
|
+
resident.runtime = resolveRuntime(config, env);
|
|
503
|
+
if (!resident.runtime) {
|
|
378
504
|
throw new Error("no llama.cpp runtime available; install one from the Library tab");
|
|
379
505
|
}
|
|
380
506
|
const gpuInfo = await queryGpu();
|
|
381
507
|
let fitProfile = forModel(store, target, config.defaults);
|
|
508
|
+
let reservationBytes = 0;
|
|
382
509
|
if (gpuInfo) {
|
|
383
510
|
const fit = vram.fitToBudget({
|
|
384
511
|
model: target,
|
|
385
512
|
profile: fitProfile,
|
|
386
|
-
calibration:
|
|
387
|
-
|
|
513
|
+
calibration: getCalibrationForBudget(store, target, fitProfile),
|
|
514
|
+
// Every resident process keeps its complete budget reserved. Fit this
|
|
515
|
+
// process against the capacity left after those independent allocations.
|
|
516
|
+
totalVramBytes: Math.max(0, gpuInfo.totalBytes - reservedElsewhereBytes),
|
|
388
517
|
});
|
|
389
518
|
if (!fit.adjusted && !fit.budget.fits)
|
|
390
519
|
throw new Error(fit.reason ?? "does not fit");
|
|
391
520
|
fitProfile = fit.profile;
|
|
521
|
+
reservationBytes = fit.budget.totalBytes;
|
|
392
522
|
}
|
|
393
|
-
await
|
|
523
|
+
await resident.start(target, fitProfile);
|
|
394
524
|
delete store.pendingReloadModelIds[target.id];
|
|
395
525
|
store.lastModelId = target.id;
|
|
396
526
|
saveProfilesStore(store, paths);
|
|
527
|
+
return reservationBytes;
|
|
397
528
|
};
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
529
|
+
let processPool = null;
|
|
530
|
+
const loadModel = async (target) => {
|
|
531
|
+
if (processPool) {
|
|
532
|
+
await processPool.preload(target);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
await loadModelInto(supervisor, target, 0);
|
|
403
536
|
};
|
|
404
537
|
// Apply an editable config patch from POST /__host/config: mutate the live
|
|
405
538
|
// config (so the lock/default getters and future starts see it), persist it to
|
|
@@ -425,15 +558,42 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
425
558
|
throw new Error("lockModel must be a boolean");
|
|
426
559
|
config.lockModel = p.lockModel;
|
|
427
560
|
}
|
|
561
|
+
if ("maxLoadedModels" in p) {
|
|
562
|
+
const next = p.maxLoadedModels;
|
|
563
|
+
if (!Number.isInteger(next) || next < 1 || next > 16) {
|
|
564
|
+
throw new Error("maxLoadedModels must be an integer from 1 to 16");
|
|
565
|
+
}
|
|
566
|
+
config.maxLoadedModels = next;
|
|
567
|
+
}
|
|
568
|
+
if ("lockedModels" in p) {
|
|
569
|
+
const next = p.lockedModels;
|
|
570
|
+
if (!Array.isArray(next) || !next.every((value) => typeof value === "string")) {
|
|
571
|
+
throw new Error("lockedModels must be an array of model ids");
|
|
572
|
+
}
|
|
573
|
+
config.lockedModels = [...new Set(next)].slice(0, config.maxLoadedModels);
|
|
574
|
+
}
|
|
575
|
+
if (config.lockedModels.length > config.maxLoadedModels) {
|
|
576
|
+
config.lockedModels = config.lockedModels.slice(0, config.maxLoadedModels);
|
|
577
|
+
}
|
|
428
578
|
const persisted = loadPersistedConfig(paths);
|
|
429
579
|
persisted.defaultModel = config.defaultModel;
|
|
430
580
|
persisted.lockModel = config.lockModel;
|
|
581
|
+
persisted.maxLoadedModels = config.maxLoadedModels;
|
|
582
|
+
persisted.lockedModels = config.lockedModels;
|
|
431
583
|
saveBrainConfig(persisted, paths);
|
|
584
|
+
await processPool?.configure(config.maxLoadedModels);
|
|
432
585
|
if (switchTo) {
|
|
433
586
|
const target = catalog.find((m) => m.displayName === switchTo || m.id === switchTo);
|
|
434
587
|
if (target)
|
|
435
588
|
await loadModel(target);
|
|
436
589
|
}
|
|
590
|
+
if (config.lockModel) {
|
|
591
|
+
for (const id of config.lockedModels) {
|
|
592
|
+
const target = catalog.find((candidate) => candidate.id === id || candidate.displayName === id);
|
|
593
|
+
if (target)
|
|
594
|
+
await loadModel(target);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
437
597
|
return redactConfig(config);
|
|
438
598
|
};
|
|
439
599
|
// One CPU sampler for the lifetime of the service: it reports a busy fraction
|
|
@@ -444,7 +604,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
444
604
|
// /__host/events. One instance, so `capabilities.events` and the stream can
|
|
445
605
|
// never disagree about whether this brain publishes.
|
|
446
606
|
const statusEvents = new BrainStatusPublisher();
|
|
447
|
-
const runResidentJob = async (kind, target, update, signal) => {
|
|
607
|
+
const runResidentJob = async (supervisor, kind, target, update, signal) => {
|
|
448
608
|
const ensureActive = () => {
|
|
449
609
|
if (signal.aborted)
|
|
450
610
|
throw new Error("Operation canceled.");
|
|
@@ -455,13 +615,6 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
455
615
|
: null;
|
|
456
616
|
if (!targetModel)
|
|
457
617
|
throw new Error("No installed model is available for this operation.");
|
|
458
|
-
const restoreModel = supervisor.model;
|
|
459
|
-
const restoreProfile = supervisor.profile;
|
|
460
|
-
const restore = async () => {
|
|
461
|
-
if (restoreModel && restoreProfile && !signal.aborted) {
|
|
462
|
-
await supervisor.start(restoreModel, restoreProfile, { preserveLogs: true });
|
|
463
|
-
}
|
|
464
|
-
};
|
|
465
618
|
if (kind === "calibrate") {
|
|
466
619
|
const runtime = supervisor.runtime ?? resolveRuntime(config, env);
|
|
467
620
|
if (!runtime)
|
|
@@ -469,34 +622,29 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
469
622
|
const profile = forModel(store, targetModel, config.defaults);
|
|
470
623
|
update.message(`Calibrating ${targetModel.displayName}`);
|
|
471
624
|
supervisor.recordLog(`operation calibrate: ${targetModel.displayName}`);
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
supervisor.recordLog(`operation calibrate: saved measurement for ${targetModel.displayName}`);
|
|
496
|
-
}
|
|
497
|
-
finally {
|
|
498
|
-
await restore();
|
|
499
|
-
}
|
|
625
|
+
const measurement = await calibrate({
|
|
626
|
+
runtime,
|
|
627
|
+
model: targetModel,
|
|
628
|
+
profile,
|
|
629
|
+
supervisor,
|
|
630
|
+
onProgress: (event) => {
|
|
631
|
+
ensureActive();
|
|
632
|
+
const message = event.phase === "loading"
|
|
633
|
+
? `Calibrating ${event.contextSize.toLocaleString()} context`
|
|
634
|
+
: event.phase === "measured"
|
|
635
|
+
? `Measured ${event.contextSize.toLocaleString()} context`
|
|
636
|
+
: (event.reason ??
|
|
637
|
+
event.error ??
|
|
638
|
+
`Skipped ${event.contextSize.toLocaleString()} context`);
|
|
639
|
+
update.message(message);
|
|
640
|
+
supervisor.recordLog(`operation calibrate: ${message}`);
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
ensureActive();
|
|
644
|
+
putCalibration(store, targetModel, profile, measurement);
|
|
645
|
+
saveProfilesStore(store, paths);
|
|
646
|
+
update.percent(100);
|
|
647
|
+
supervisor.recordLog(`operation calibrate: saved measurement for ${targetModel.displayName}`);
|
|
500
648
|
return;
|
|
501
649
|
}
|
|
502
650
|
if (kind === "sweep") {
|
|
@@ -506,47 +654,42 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
506
654
|
const profile = forModel(store, targetModel, config.defaults);
|
|
507
655
|
update.message(`Sweeping ${targetModel.displayName}`);
|
|
508
656
|
supervisor.recordLog(`operation sweep: ${targetModel.displayName}`);
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
supervisor.recordLog(`operation sweep: saved budget ${report.recommended}`);
|
|
534
|
-
}
|
|
535
|
-
update.percent(100);
|
|
536
|
-
}
|
|
537
|
-
finally {
|
|
538
|
-
await restore();
|
|
657
|
+
const report = await sweep({
|
|
658
|
+
runtime,
|
|
659
|
+
model: targetModel,
|
|
660
|
+
profile,
|
|
661
|
+
supervisor,
|
|
662
|
+
onProgress: (event) => {
|
|
663
|
+
ensureActive();
|
|
664
|
+
const message = event.phase === "loading"
|
|
665
|
+
? `Budget ${event.budget}: loading`
|
|
666
|
+
: event.phase === "generating"
|
|
667
|
+
? `Budget ${event.budget}: generating`
|
|
668
|
+
: event.phase === "done"
|
|
669
|
+
? `Budget ${event.budget}: complete`
|
|
670
|
+
: `Budget ${event.budget}: ${event.error ?? "failed"}`;
|
|
671
|
+
update.message(message);
|
|
672
|
+
supervisor.recordLog(`operation sweep: ${message}`);
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
ensureActive();
|
|
676
|
+
if (report.recommended !== null) {
|
|
677
|
+
profile.reasoningBudget = report.recommended;
|
|
678
|
+
put(store, targetModel, profile);
|
|
679
|
+
saveProfilesStore(store, paths);
|
|
680
|
+
supervisor.recordLog(`operation sweep: saved budget ${report.recommended}`);
|
|
539
681
|
}
|
|
682
|
+
update.percent(100);
|
|
540
683
|
return;
|
|
541
684
|
}
|
|
542
685
|
update.message(`Benchmarking ${targetModel.displayName}`);
|
|
543
686
|
supervisor.recordLog(`operation benchmark: ${targetModel.displayName}`);
|
|
544
|
-
|
|
687
|
+
// Scheduler loaded this exact model before admitting the exclusive turn.
|
|
545
688
|
ensureActive();
|
|
546
689
|
supervisor.recordLog(`operation benchmark: resident model ready`);
|
|
547
690
|
const profile = supervisor.profile ?? forModel(store, targetModel, config.defaults);
|
|
548
691
|
const gpuInfo = await queryGpu();
|
|
549
|
-
const calibration =
|
|
692
|
+
const calibration = getCalibrationForBudget(store, targetModel, profile);
|
|
550
693
|
const fit = gpuInfo
|
|
551
694
|
? vram.fitToBudget({
|
|
552
695
|
model: targetModel,
|
|
@@ -590,7 +733,58 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
590
733
|
update.percent(100);
|
|
591
734
|
supervisor.recordLog(`operation benchmark: saved result for ${targetModel.displayName}`);
|
|
592
735
|
};
|
|
593
|
-
const
|
|
736
|
+
const attachSupervisorLogs = (resident) => {
|
|
737
|
+
if (resident === supervisor)
|
|
738
|
+
return;
|
|
739
|
+
resident.on("log", (line) => log("server", line));
|
|
740
|
+
resident.on("crashed", (error) => log("model", `FATAL ${error}`));
|
|
741
|
+
resident.on("state", () => statusEvents.notify());
|
|
742
|
+
};
|
|
743
|
+
const createPooledSupervisor = (index) => {
|
|
744
|
+
const resident = new Supervisor({
|
|
745
|
+
runtime: resolveRuntime(config, env),
|
|
746
|
+
internalPort: supervisor.internalPort + index,
|
|
747
|
+
paths,
|
|
748
|
+
getProfilesStore: () => store,
|
|
749
|
+
logVerbosity: config.runtime.logVerbosity,
|
|
750
|
+
});
|
|
751
|
+
attachSupervisorLogs(resident);
|
|
752
|
+
return resident;
|
|
753
|
+
};
|
|
754
|
+
const createResidentScheduler = (resident, loadResidentModel, onChange) => new Scheduler({
|
|
755
|
+
supervisor: resident,
|
|
756
|
+
loadModel: loadResidentModel,
|
|
757
|
+
logger: (message) => log("api", `WARN ${message}`),
|
|
758
|
+
onChange,
|
|
759
|
+
freeSlots: async () => {
|
|
760
|
+
if (resident.state !== "ready")
|
|
761
|
+
return null;
|
|
762
|
+
try {
|
|
763
|
+
const slots = await sampleSlots({ host: resident.host, port: resident.internalPort });
|
|
764
|
+
return slots ? { idle: slots.idle, ids: slots.idleSlots } : null;
|
|
765
|
+
}
|
|
766
|
+
catch {
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
eraseSlot: createSlotEraser(resident.host, resident.internalPort),
|
|
771
|
+
});
|
|
772
|
+
const scheduler = new ModelProcessPool({
|
|
773
|
+
initialSupervisor: supervisor,
|
|
774
|
+
maxModels: config.maxLoadedModels,
|
|
775
|
+
createSupervisor: createPooledSupervisor,
|
|
776
|
+
createScheduler: createResidentScheduler,
|
|
777
|
+
loadModel: loadModelInto,
|
|
778
|
+
logger: (message) => log("model", message),
|
|
779
|
+
onChange: () => statusEvents.notify(),
|
|
780
|
+
});
|
|
781
|
+
processPool = scheduler;
|
|
782
|
+
const jobs = new ServiceJobRunner(rescanCatalog, runResidentJob, scheduler, (target) => {
|
|
783
|
+
const modelId = target ?? scheduler.residentSupervisors()[0]?.model?.id ?? store.lastModelId ?? null;
|
|
784
|
+
return modelId
|
|
785
|
+
? (catalog.find((candidate) => candidate.id === modelId || candidate.displayName === modelId) ?? null)
|
|
786
|
+
: null;
|
|
787
|
+
}, log);
|
|
594
788
|
// Assigned once `stop` exists below. This indirection lets the management API
|
|
595
789
|
// answer a remote restart request before closing its own socket.
|
|
596
790
|
let requestRestart = () => { };
|
|
@@ -611,6 +805,8 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
611
805
|
}
|
|
612
806
|
},
|
|
613
807
|
loadModel,
|
|
808
|
+
unloadModels: () => scheduler.unload(),
|
|
809
|
+
scheduler,
|
|
614
810
|
// The same gate as POST /__host/config. Deleting someone's model files over
|
|
615
811
|
// the network is strictly more dangerous than changing their default model,
|
|
616
812
|
// so it does not get a weaker one.
|
|
@@ -618,13 +814,19 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
618
814
|
getModelsDir: () => managedModelsDir(config, env),
|
|
619
815
|
sampleResources: () => sampleSystem(cpuSampler, { host: supervisor.host, port: supervisor.internalPort }),
|
|
620
816
|
statusEvents,
|
|
817
|
+
logEvents,
|
|
621
818
|
jobs,
|
|
819
|
+
runLog,
|
|
622
820
|
restart: () => requestRestart(),
|
|
821
|
+
log,
|
|
623
822
|
});
|
|
624
823
|
const handler = withAuth(createRouter({
|
|
625
824
|
supervisor,
|
|
626
825
|
telemetry,
|
|
627
|
-
logger: {
|
|
826
|
+
logger: {
|
|
827
|
+
info: (m) => log("api", m),
|
|
828
|
+
warn: (m) => log("api", `WARN ${m}`),
|
|
829
|
+
},
|
|
628
830
|
getCatalog: () => catalog,
|
|
629
831
|
loadModel,
|
|
630
832
|
version: resolveVersion(),
|
|
@@ -632,6 +834,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
632
834
|
getEvals: collectEvals,
|
|
633
835
|
getLockModel: () => config.lockModel,
|
|
634
836
|
getDefaultModel: () => config.defaultModel,
|
|
837
|
+
getLockedModels: () => config.lockedModels,
|
|
635
838
|
applyConfigPatch,
|
|
636
839
|
getAllowConfigWrite: allowWrite,
|
|
637
840
|
hostApi,
|
|
@@ -639,6 +842,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
639
842
|
// time in parallel: the shared rate tracker needs one ordered timeline.
|
|
640
843
|
getResources: () => sampleSystem(cpuSampler),
|
|
641
844
|
statusEvents,
|
|
845
|
+
scheduler,
|
|
642
846
|
}), authToken);
|
|
643
847
|
// TLS terminates in-process when configured; otherwise plain HTTP. The cert
|
|
644
848
|
// manager issues/generates the first keypair before we listen, and hot-swaps
|
|
@@ -646,12 +850,18 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
646
850
|
let certManager = null;
|
|
647
851
|
let server;
|
|
648
852
|
if (tlsOptions) {
|
|
649
|
-
certManager = new CertManager({
|
|
853
|
+
certManager = new CertManager({
|
|
854
|
+
...tlsOptions,
|
|
855
|
+
logger: {
|
|
856
|
+
info: (message) => log("server", message),
|
|
857
|
+
warn: (message) => log("server", message),
|
|
858
|
+
},
|
|
859
|
+
});
|
|
650
860
|
const secure = await certManager.load();
|
|
651
861
|
const httpsServer = https.createServer({ key: secure.key, cert: secure.cert }, handler);
|
|
652
862
|
certManager.on("renewed", (pair) => {
|
|
653
863
|
httpsServer.setSecureContext({ key: pair.key, cert: pair.cert });
|
|
654
|
-
|
|
864
|
+
log("server", "note: TLS certificate hot-swapped");
|
|
655
865
|
});
|
|
656
866
|
server = httpsServer;
|
|
657
867
|
}
|
|
@@ -660,22 +870,38 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
660
870
|
}
|
|
661
871
|
server.keepAliveTimeout = 75000;
|
|
662
872
|
server.requestTimeout = 0;
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
873
|
+
try {
|
|
874
|
+
await new Promise((resolve, reject) => {
|
|
875
|
+
server.once("error", reject);
|
|
876
|
+
server.listen(port, bindHost, resolve);
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
catch (error) {
|
|
880
|
+
// A listener can fail before the host API exists, so this cannot travel
|
|
881
|
+
// through the host's own SSE endpoint. It still belongs to this service
|
|
882
|
+
// session: the foreground daemon child relays this timestamped entry until
|
|
883
|
+
// that endpoint becomes available.
|
|
884
|
+
log("server", `FATAL Brain service startup failed: ${errorMessage(error)}`);
|
|
885
|
+
throw error;
|
|
886
|
+
}
|
|
667
887
|
certManager?.start();
|
|
668
|
-
if (
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
888
|
+
if (runtime && config.lockModel && config.lockedModels.length > 0) {
|
|
889
|
+
for (const lockedId of config.lockedModels.slice(0, config.maxLoadedModels)) {
|
|
890
|
+
const locked = catalog.find((candidate) => candidate.id === lockedId || candidate.displayName === lockedId);
|
|
891
|
+
if (locked)
|
|
892
|
+
await scheduler.preload(locked);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
else if (model && profile && runtime) {
|
|
896
|
+
// Default model remains one auto-load. Additional process slots stay empty
|
|
897
|
+
// until a request names another model.
|
|
898
|
+
await scheduler.preload(model);
|
|
673
899
|
}
|
|
674
900
|
else if (!runtime) {
|
|
675
|
-
log("ready: no llama.cpp runtime installed; use the Library tab to download one");
|
|
901
|
+
log("server", "ready: no llama.cpp runtime installed; use the Library tab to download one");
|
|
676
902
|
}
|
|
677
903
|
else {
|
|
678
|
-
log("ready: no model installed; use the Library tab to download one");
|
|
904
|
+
log("server", "ready: no model installed; use the Library tab to download one");
|
|
679
905
|
}
|
|
680
906
|
writePidFile({
|
|
681
907
|
pid: process.pid,
|
|
@@ -685,14 +911,22 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
685
911
|
secure: Boolean(tlsOptions),
|
|
686
912
|
displayHost,
|
|
687
913
|
}, env);
|
|
688
|
-
log(`ready: ${
|
|
914
|
+
log("server", `ready: ${scheduler
|
|
915
|
+
.residentSupervisors()
|
|
916
|
+
.map((resident) => resident.model?.displayName)
|
|
917
|
+
.filter(Boolean)
|
|
918
|
+
.join(", ") || "no model loaded"} on ${bindHost}:${port}; run log ${runLog.path}`);
|
|
689
919
|
const stop = async () => {
|
|
690
920
|
certManager?.stop();
|
|
691
|
-
log("Brain service stopping");
|
|
921
|
+
log("server", "Brain service stopping");
|
|
922
|
+
await scheduler.stop();
|
|
923
|
+
// Publish the terminal outcome before closing the SSE responses below.
|
|
924
|
+
// Once the listener is closed, the daemon can still report the child exit,
|
|
925
|
+
// but it cannot receive this service-owned, durable session-log entry.
|
|
926
|
+
log("server", "Brain service stopped");
|
|
692
927
|
// Before server.close(), which waits on open connections: a subscribed
|
|
693
928
|
// daemon holds an SSE response open indefinitely by design.
|
|
694
929
|
statusEvents.close();
|
|
695
|
-
await supervisor.stop();
|
|
696
930
|
server.closeIdleConnections?.();
|
|
697
931
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
698
932
|
removePidFile(env);
|