@otto-code/brain 0.8.7 → 0.8.9
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/cli.js +2 -1
- package/dist/commands/bench.js +19 -5
- package/dist/commands/catalog.d.ts +3 -0
- package/dist/commands/catalog.js +2 -0
- package/dist/commands/pull.d.ts +1 -0
- package/dist/commands/pull.js +47 -14
- package/dist/commands/repo-download.d.ts +14 -0
- package/dist/commands/repo-download.js +29 -0
- package/dist/commands/runtime.d.ts +3 -0
- package/dist/commands/runtime.js +65 -18
- package/dist/commands/search.d.ts +3 -0
- package/dist/commands/search.js +38 -17
- package/dist/config/builtin-hosting-profiles.d.ts +8 -0
- package/dist/config/builtin-hosting-profiles.js +32 -0
- package/dist/config/hosting-profiles.d.ts +33 -0
- package/dist/config/hosting-profiles.js +71 -0
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +1 -0
- package/dist/config/paths.d.ts +2 -0
- package/dist/config/paths.js +1 -0
- package/dist/config/profile-edit.d.ts +5 -3
- package/dist/config/profile-edit.js +134 -14
- package/dist/config/profiles.js +66 -3
- package/dist/config/schema.d.ts +998 -0
- package/dist/config/schema.js +79 -0
- package/dist/config/store.js +28 -16
- package/dist/gguf.d.ts +1 -0
- package/dist/gguf.js +1 -0
- package/dist/models/download.d.ts +7 -0
- package/dist/models/download.js +165 -17
- package/dist/models/enrich.d.ts +6 -19
- package/dist/models/enrich.js +138 -4
- package/dist/models/hf.d.ts +14 -1
- package/dist/models/hf.js +239 -6
- package/dist/models/index.d.ts +2 -2
- package/dist/models/index.js +8 -5
- package/dist/models/manage.d.ts +4 -0
- package/dist/models/manage.js +34 -4
- package/dist/models/scan.js +8 -42
- package/dist/ops/calibrate.d.ts +4 -1
- package/dist/ops/calibrate.js +10 -7
- package/dist/ops/sweep.d.ts +3 -1
- package/dist/ops/sweep.js +3 -3
- package/dist/runtime/args.d.ts +2 -2
- package/dist/runtime/args.js +18 -1
- package/dist/runtime/index.d.ts +8 -1
- package/dist/runtime/index.js +11 -1
- package/dist/runtime/managed.d.ts +40 -0
- package/dist/runtime/managed.js +146 -7
- package/dist/service/host-api.d.ts +20 -3
- package/dist/service/host-api.js +313 -20
- package/dist/service/router.d.ts +18 -0
- package/dist/service/router.js +89 -4
- package/dist/service/serve.js +221 -22
- package/dist/service/supervisor.d.ts +32 -4
- package/dist/service/supervisor.js +30 -6
- package/dist/tui/app.js +1 -1
- package/dist/types.d.ts +24 -0
- package/dist/vram.d.ts +3 -0
- package/dist/vram.js +24 -6
- package/package.json +1 -1
package/dist/service/router.js
CHANGED
|
@@ -149,6 +149,7 @@ export function describeModel(model, options = {}) {
|
|
|
149
149
|
// OpenAI-compatible clients send `id`; Otto uses `name` for presentation.
|
|
150
150
|
id: model.id,
|
|
151
151
|
name: model.displayName,
|
|
152
|
+
...(model.family ? { family: model.family } : {}),
|
|
152
153
|
object: "model",
|
|
153
154
|
created: Math.floor((createdAt ? createdAt.getTime() : Date.now()) / 1000),
|
|
154
155
|
owned_by: model.publisher || "local",
|
|
@@ -159,7 +160,13 @@ export function describeModel(model, options = {}) {
|
|
|
159
160
|
compatibility_type: "gguf",
|
|
160
161
|
quantization: model.quant || null,
|
|
161
162
|
state,
|
|
162
|
-
|
|
163
|
+
// The GGUF header remains the native limit, but an actively loaded YaRN
|
|
164
|
+
// profile intentionally extends the server's usable maximum. Publish that
|
|
165
|
+
// effective ceiling so OpenAI-compatible clients do not reject a context
|
|
166
|
+
// that this very llama-server instance has been configured to accept.
|
|
167
|
+
max_context_length: typeof md.contextLength === "number"
|
|
168
|
+
? md.contextLength * (profile?.contextMultiplier ?? 1)
|
|
169
|
+
: null,
|
|
163
170
|
// GGUF template detection is deliberately conservative. A false result
|
|
164
171
|
// means "not detected", not proof that a catalog-marked reasoner is not
|
|
165
172
|
// one, so preserve the catalog's positive capability metadata.
|
|
@@ -290,13 +297,17 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
290
297
|
resolve();
|
|
291
298
|
}
|
|
292
299
|
};
|
|
300
|
+
// Injected here, not at queue time: the scheduler may switch models between
|
|
301
|
+
// buffering and dispatch, and the addendum belongs to whichever model ends
|
|
302
|
+
// up resident, which is the one `supervisor.profile` now describes.
|
|
303
|
+
const outbound = injectSystemAddendum(body, supervisor.profile?.chatSystemAddendum ?? null, completionShape(req.url));
|
|
293
304
|
const headers = {};
|
|
294
305
|
for (const [name, value] of Object.entries(req.headers)) {
|
|
295
306
|
if (!HOP_BY_HOP.has(name.toLowerCase()))
|
|
296
307
|
headers[name] = value;
|
|
297
308
|
}
|
|
298
309
|
headers.host = `${supervisor.host}:${supervisor.internalPort}`;
|
|
299
|
-
headers["content-length"] = Buffer.byteLength(
|
|
310
|
+
headers["content-length"] = Buffer.byteLength(outbound);
|
|
300
311
|
const started = Date.now();
|
|
301
312
|
const upstream = http.request({
|
|
302
313
|
host: supervisor.host,
|
|
@@ -406,9 +417,78 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
406
417
|
// being dispatched to llama-server and is waiting for prompt processing or
|
|
407
418
|
// its first output delta.
|
|
408
419
|
reasoning?.begin(streamId);
|
|
409
|
-
upstream.end(
|
|
420
|
+
upstream.end(outbound);
|
|
410
421
|
});
|
|
411
422
|
}
|
|
423
|
+
export function completionShape(url) {
|
|
424
|
+
return /\/v1\/messages/.test(url ?? "") ? "anthropic" : "openai";
|
|
425
|
+
}
|
|
426
|
+
/** One text block, as both API shapes spell it inside a structured content array. */
|
|
427
|
+
function textBlock(text) {
|
|
428
|
+
return { type: "text", text };
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Append the active hosting profile's system-prompt addendum to a buffered
|
|
432
|
+
* completion body.
|
|
433
|
+
*
|
|
434
|
+
* Appending rather than prepending or replacing is the whole point: the agent's
|
|
435
|
+
* own system prompt still leads, and the profile's instructions are read last.
|
|
436
|
+
* A body this cannot understand is forwarded untouched - a malformed or
|
|
437
|
+
* unfamiliar request must still reach llama-server and get llama-server's own
|
|
438
|
+
* error, not a 400 invented here.
|
|
439
|
+
*
|
|
440
|
+
* Cost is one extra parse/serialize per request, paid only by models whose
|
|
441
|
+
* profile actually sets an addendum.
|
|
442
|
+
*/
|
|
443
|
+
export function injectSystemAddendum(body, addendum, shape) {
|
|
444
|
+
if (!addendum)
|
|
445
|
+
return body;
|
|
446
|
+
let parsed;
|
|
447
|
+
try {
|
|
448
|
+
parsed = JSON.parse(body.toString("utf8"));
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
return body;
|
|
452
|
+
}
|
|
453
|
+
if (!isRecord(parsed))
|
|
454
|
+
return body;
|
|
455
|
+
if (shape === "anthropic") {
|
|
456
|
+
// Anthropic carries the system turn beside `messages`, never inside it.
|
|
457
|
+
const system = parsed.system;
|
|
458
|
+
if (system === undefined || system === null || system === "")
|
|
459
|
+
parsed.system = addendum;
|
|
460
|
+
else if (typeof system === "string")
|
|
461
|
+
parsed.system = `${system}\n\n${addendum}`;
|
|
462
|
+
else if (Array.isArray(system))
|
|
463
|
+
parsed.system = [...system, textBlock(addendum)];
|
|
464
|
+
else
|
|
465
|
+
return body;
|
|
466
|
+
return Buffer.from(JSON.stringify(parsed), "utf8");
|
|
467
|
+
}
|
|
468
|
+
const messages = parsed.messages;
|
|
469
|
+
if (!Array.isArray(messages))
|
|
470
|
+
return body;
|
|
471
|
+
// `developer` is the newer OpenAI spelling of the same turn; either one is
|
|
472
|
+
// the message this addendum belongs on.
|
|
473
|
+
const index = messages.findIndex((message) => isRecord(message) && (message.role === "system" || message.role === "developer"));
|
|
474
|
+
if (index === -1) {
|
|
475
|
+
parsed.messages = [{ role: "system", content: addendum }, ...messages];
|
|
476
|
+
return Buffer.from(JSON.stringify(parsed), "utf8");
|
|
477
|
+
}
|
|
478
|
+
const existing = messages[index];
|
|
479
|
+
const content = existing.content;
|
|
480
|
+
let merged;
|
|
481
|
+
if (content === undefined || content === null || content === "")
|
|
482
|
+
merged = addendum;
|
|
483
|
+
else if (typeof content === "string")
|
|
484
|
+
merged = `${content}\n\n${addendum}`;
|
|
485
|
+
else if (Array.isArray(content))
|
|
486
|
+
merged = [...content, textBlock(addendum)];
|
|
487
|
+
else
|
|
488
|
+
return body;
|
|
489
|
+
parsed.messages = messages.map((message, at) => at === index ? { ...existing, content: merged } : message);
|
|
490
|
+
return Buffer.from(JSON.stringify(parsed), "utf8");
|
|
491
|
+
}
|
|
412
492
|
/**
|
|
413
493
|
* Pure model-admission decision, factored out of the router so it is unit
|
|
414
494
|
* testable. `pinned` is the single model a locked host serves; `resolved` is the
|
|
@@ -497,7 +577,12 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
|
|
|
497
577
|
// window - the cheap time-based trigger.
|
|
498
578
|
const RANKING_TTL_MS = 60000;
|
|
499
579
|
export function createRouter({ supervisor, telemetry, logger, getCatalog = null, loadModel = null, loadRanking = () => rankModels(), queryGpuInfo = queryGpu, version = null, getConfig = null, getEvals = null, getLockModel = () => false, getDefaultModel = () => null, applyConfigPatch = null, getAllowConfigWrite = () => false, hostApi = null, getResources = null, statusEvents = null, }) {
|
|
500
|
-
|
|
580
|
+
// llama-server may close an idle response socket while this scheduler holds
|
|
581
|
+
// the next request in queue. A reused keep-alive socket then fails as
|
|
582
|
+
// ECONNRESET ("socket hang up") before the queued request reaches inference.
|
|
583
|
+
// Inference time dwarfs localhost connection setup, so isolate each request
|
|
584
|
+
// instead of letting a second client inherit a stale upstream connection.
|
|
585
|
+
const agent = new http.Agent({ keepAlive: false, maxSockets: 32 });
|
|
501
586
|
const scheduler = loadModel
|
|
502
587
|
? new Scheduler({
|
|
503
588
|
supervisor,
|
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 { getCalibration, forModel, loadPersistedConfig, loadProfilesStore, saveBrainConfig, saveProfilesStore, } from "../config/index.js";
|
|
16
|
+
import { getCalibration, 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";
|
|
@@ -22,8 +22,13 @@ import { resolveRuntime } from "../runtime/index.js";
|
|
|
22
22
|
import * as vram from "../vram.js";
|
|
23
23
|
import { resolveVersion } from "../version.js";
|
|
24
24
|
import * as results from "../ops/results.js";
|
|
25
|
+
import * as archive from "../ops/archive.js";
|
|
26
|
+
import { calibrate } from "../ops/calibrate.js";
|
|
27
|
+
import { sweep } from "../ops/sweep.js";
|
|
28
|
+
import * as bench from "../bench/index.js";
|
|
25
29
|
import { createCpuSampler, sample as sampleSystem } from "../sysmon.js";
|
|
26
30
|
import { createHostApi } from "./host-api.js";
|
|
31
|
+
import { errorMessage } from "./http-util.js";
|
|
27
32
|
import { createRouter, Telemetry } from "./router.js";
|
|
28
33
|
import { BrainStatusPublisher } from "./status-events.js";
|
|
29
34
|
import { Supervisor } from "./supervisor.js";
|
|
@@ -57,13 +62,10 @@ function collectEvals() {
|
|
|
57
62
|
}
|
|
58
63
|
}
|
|
59
64
|
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
65
|
class ServiceJobRunner {
|
|
66
|
-
constructor() {
|
|
66
|
+
constructor(onPullCompleted, runResidentJob) {
|
|
67
|
+
this.onPullCompleted = onPullCompleted;
|
|
68
|
+
this.runResidentJob = runResidentJob;
|
|
67
69
|
this.jobs = new Map();
|
|
68
70
|
}
|
|
69
71
|
start(kind, target, args) {
|
|
@@ -86,7 +88,26 @@ class ServiceJobRunner {
|
|
|
86
88
|
startedAt: new Date().toISOString(),
|
|
87
89
|
finishedAt: null,
|
|
88
90
|
child: null,
|
|
91
|
+
controller: null,
|
|
89
92
|
};
|
|
93
|
+
this.jobs.set(job.id, job);
|
|
94
|
+
if (kind === "calibrate" || kind === "sweep" || kind === "bench") {
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
job.controller = controller;
|
|
97
|
+
void this.runResidentJob(kind, target, {
|
|
98
|
+
message: (value) => {
|
|
99
|
+
if (job.status === "running")
|
|
100
|
+
job.message = value.slice(-1000);
|
|
101
|
+
},
|
|
102
|
+
percent: (value) => {
|
|
103
|
+
if (job.status === "running")
|
|
104
|
+
job.percent = value;
|
|
105
|
+
},
|
|
106
|
+
}, controller.signal)
|
|
107
|
+
.then(() => this.finish(job, "succeeded", null))
|
|
108
|
+
.catch((error) => this.finish(job, controller.signal.aborted ? "canceled" : "failed", errorMessage(error)));
|
|
109
|
+
return this.publicJob(job);
|
|
110
|
+
}
|
|
90
111
|
// The service is launched by the same CLI entry point as `otto-brain bench`.
|
|
91
112
|
// Reusing that entry point keeps its config/path resolution on this host.
|
|
92
113
|
const entry = process.argv[1];
|
|
@@ -99,18 +120,25 @@ class ServiceJobRunner {
|
|
|
99
120
|
windowsHide: true,
|
|
100
121
|
});
|
|
101
122
|
job.child = child;
|
|
102
|
-
this.jobs.set(job.id, job);
|
|
103
123
|
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
|
-
});
|
|
124
|
+
child.stderr?.on("data", (chunk) => this.ingestOutput(job, chunk));
|
|
109
125
|
child.once("error", (error) => this.finish(job, "failed", error.message));
|
|
110
126
|
child.once("close", (code) => {
|
|
111
127
|
if (job.status !== "running")
|
|
112
128
|
return;
|
|
113
|
-
|
|
129
|
+
if (code === 0 && kind === "pull") {
|
|
130
|
+
try {
|
|
131
|
+
// Downloads happen in a child process, but inventory is served from
|
|
132
|
+
// this process's in-memory scan. Reconcile before reporting success
|
|
133
|
+
// so a newly downloaded bundle component is immediately available.
|
|
134
|
+
this.onPullCompleted();
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
this.finish(job, "failed", `Downloaded files, but could not refresh inventory: ${errorMessage(error)}`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
this.finish(job, code === 0 ? "succeeded" : "failed", code === 0 ? null : (job.message ?? `Exited with code ${code}.`));
|
|
114
142
|
});
|
|
115
143
|
return this.publicJob(job);
|
|
116
144
|
}
|
|
@@ -156,8 +184,13 @@ class ServiceJobRunner {
|
|
|
156
184
|
}
|
|
157
185
|
async cancel(jobId) {
|
|
158
186
|
const job = this.jobs.get(jobId);
|
|
159
|
-
if (!job || job.status !== "running"
|
|
187
|
+
if (!job || job.status !== "running")
|
|
188
|
+
return this.list();
|
|
189
|
+
if (!job.child) {
|
|
190
|
+
job.controller?.abort();
|
|
191
|
+
this.finish(job, "canceled", "Canceled.");
|
|
160
192
|
return this.list();
|
|
193
|
+
}
|
|
161
194
|
const child = job.child;
|
|
162
195
|
if (process.platform === "win32" && child.pid) {
|
|
163
196
|
await new Promise((resolve) => execFile("taskkill", ["/pid", String(child.pid), "/t", "/f"], () => resolve()));
|
|
@@ -168,15 +201,32 @@ class ServiceJobRunner {
|
|
|
168
201
|
this.finish(job, "canceled", "Canceled.");
|
|
169
202
|
return this.list();
|
|
170
203
|
}
|
|
204
|
+
ingestOutput(job, chunk) {
|
|
205
|
+
for (const line of chunk
|
|
206
|
+
.split(/[\r\n]+/u)
|
|
207
|
+
.map((value) => value.trim())
|
|
208
|
+
.filter(Boolean)) {
|
|
209
|
+
const progress = /(\d{1,3})\s*%/u.exec(line);
|
|
210
|
+
if (progress)
|
|
211
|
+
job.percent = Math.max(0, Math.min(100, Number(progress[1])));
|
|
212
|
+
// The final JSON result is not a useful status label. Keep progress and
|
|
213
|
+
// actionable text instead, so a failed bundle pull tells the user why.
|
|
214
|
+
if (line !== "[" && !/^[\]{}",]+$/u.test(line))
|
|
215
|
+
job.message = line.slice(-1000);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
171
218
|
finish(job, status, error) {
|
|
172
219
|
if (job.status !== "running")
|
|
173
220
|
return;
|
|
174
221
|
job.child = null;
|
|
222
|
+
job.controller = null;
|
|
175
223
|
job.status = status;
|
|
176
224
|
job.error = error;
|
|
177
225
|
job.finishedAt = new Date().toISOString();
|
|
226
|
+
if (status === "succeeded")
|
|
227
|
+
job.percent = 100;
|
|
178
228
|
}
|
|
179
|
-
publicJob({ child: _child, ...job }) {
|
|
229
|
+
publicJob({ child: _child, controller: _controller, ...job }) {
|
|
180
230
|
return job;
|
|
181
231
|
}
|
|
182
232
|
}
|
|
@@ -266,6 +316,10 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
266
316
|
// Not const: deleting a model through the management API re-scans and replaces
|
|
267
317
|
// this, and every reader goes through a getter so nobody holds a stale array.
|
|
268
318
|
let catalog = scanModels(config, env);
|
|
319
|
+
const rescanCatalog = () => {
|
|
320
|
+
catalog = scanModels(config, env);
|
|
321
|
+
return catalog;
|
|
322
|
+
};
|
|
269
323
|
const needle = modelNeedle ?? config.defaultModel ?? store.lastModelId ?? undefined;
|
|
270
324
|
let model = null;
|
|
271
325
|
if (catalog.length > 0) {
|
|
@@ -304,7 +358,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
304
358
|
}
|
|
305
359
|
}
|
|
306
360
|
const telemetry = new Telemetry();
|
|
307
|
-
const supervisor = new Supervisor({ runtime });
|
|
361
|
+
const supervisor = new Supervisor({ runtime, paths, getProfilesStore: () => store });
|
|
308
362
|
supervisor.on("log", (line) => {
|
|
309
363
|
runLog.write(line);
|
|
310
364
|
if (/error|failed|warn/i.test(line))
|
|
@@ -337,6 +391,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
337
391
|
fitProfile = fit.profile;
|
|
338
392
|
}
|
|
339
393
|
await supervisor.start(target, fitProfile);
|
|
394
|
+
delete store.pendingReloadModelIds[target.id];
|
|
340
395
|
store.lastModelId = target.id;
|
|
341
396
|
saveProfilesStore(store, paths);
|
|
342
397
|
};
|
|
@@ -389,17 +444,160 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
389
444
|
// /__host/events. One instance, so `capabilities.events` and the stream can
|
|
390
445
|
// never disagree about whether this brain publishes.
|
|
391
446
|
const statusEvents = new BrainStatusPublisher();
|
|
392
|
-
const
|
|
447
|
+
const runResidentJob = async (kind, target, update, signal) => {
|
|
448
|
+
const ensureActive = () => {
|
|
449
|
+
if (signal.aborted)
|
|
450
|
+
throw new Error("Operation canceled.");
|
|
451
|
+
};
|
|
452
|
+
const modelId = target ?? supervisor.model?.id ?? store.lastModelId ?? null;
|
|
453
|
+
const targetModel = modelId
|
|
454
|
+
? catalog.find((candidate) => candidate.id === modelId || candidate.displayName === modelId)
|
|
455
|
+
: null;
|
|
456
|
+
if (!targetModel)
|
|
457
|
+
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
|
+
if (kind === "calibrate") {
|
|
466
|
+
const runtime = supervisor.runtime ?? resolveRuntime(config, env);
|
|
467
|
+
if (!runtime)
|
|
468
|
+
throw new Error("no llama.cpp runtime available; install one from the Library tab");
|
|
469
|
+
const profile = forModel(store, targetModel, config.defaults);
|
|
470
|
+
update.message(`Calibrating ${targetModel.displayName}`);
|
|
471
|
+
supervisor.recordLog(`operation calibrate: ${targetModel.displayName}`);
|
|
472
|
+
try {
|
|
473
|
+
const measurement = await calibrate({
|
|
474
|
+
runtime,
|
|
475
|
+
model: targetModel,
|
|
476
|
+
profile,
|
|
477
|
+
supervisor,
|
|
478
|
+
onProgress: (event) => {
|
|
479
|
+
ensureActive();
|
|
480
|
+
const message = event.phase === "loading"
|
|
481
|
+
? `Calibrating ${event.contextSize.toLocaleString()} context`
|
|
482
|
+
: event.phase === "measured"
|
|
483
|
+
? `Measured ${event.contextSize.toLocaleString()} context`
|
|
484
|
+
: (event.reason ??
|
|
485
|
+
event.error ??
|
|
486
|
+
`Skipped ${event.contextSize.toLocaleString()} context`);
|
|
487
|
+
update.message(message);
|
|
488
|
+
supervisor.recordLog(`operation calibrate: ${message}`);
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
ensureActive();
|
|
492
|
+
putCalibration(store, targetModel, profile, measurement);
|
|
493
|
+
saveProfilesStore(store, paths);
|
|
494
|
+
update.percent(100);
|
|
495
|
+
supervisor.recordLog(`operation calibrate: saved measurement for ${targetModel.displayName}`);
|
|
496
|
+
}
|
|
497
|
+
finally {
|
|
498
|
+
await restore();
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (kind === "sweep") {
|
|
503
|
+
const runtime = supervisor.runtime ?? resolveRuntime(config, env);
|
|
504
|
+
if (!runtime)
|
|
505
|
+
throw new Error("no llama.cpp runtime available; install one from the Library tab");
|
|
506
|
+
const profile = forModel(store, targetModel, config.defaults);
|
|
507
|
+
update.message(`Sweeping ${targetModel.displayName}`);
|
|
508
|
+
supervisor.recordLog(`operation sweep: ${targetModel.displayName}`);
|
|
509
|
+
try {
|
|
510
|
+
const report = await sweep({
|
|
511
|
+
runtime,
|
|
512
|
+
model: targetModel,
|
|
513
|
+
profile,
|
|
514
|
+
supervisor,
|
|
515
|
+
onProgress: (event) => {
|
|
516
|
+
ensureActive();
|
|
517
|
+
const message = event.phase === "loading"
|
|
518
|
+
? `Budget ${event.budget}: loading`
|
|
519
|
+
: event.phase === "generating"
|
|
520
|
+
? `Budget ${event.budget}: generating`
|
|
521
|
+
: event.phase === "done"
|
|
522
|
+
? `Budget ${event.budget}: complete`
|
|
523
|
+
: `Budget ${event.budget}: ${event.error ?? "failed"}`;
|
|
524
|
+
update.message(message);
|
|
525
|
+
supervisor.recordLog(`operation sweep: ${message}`);
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
ensureActive();
|
|
529
|
+
if (report.recommended !== null) {
|
|
530
|
+
profile.reasoningBudget = report.recommended;
|
|
531
|
+
put(store, targetModel, profile);
|
|
532
|
+
saveProfilesStore(store, paths);
|
|
533
|
+
supervisor.recordLog(`operation sweep: saved budget ${report.recommended}`);
|
|
534
|
+
}
|
|
535
|
+
update.percent(100);
|
|
536
|
+
}
|
|
537
|
+
finally {
|
|
538
|
+
await restore();
|
|
539
|
+
}
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
update.message(`Benchmarking ${targetModel.displayName}`);
|
|
543
|
+
supervisor.recordLog(`operation benchmark: ${targetModel.displayName}`);
|
|
544
|
+
await loadModel(targetModel);
|
|
545
|
+
ensureActive();
|
|
546
|
+
supervisor.recordLog(`operation benchmark: resident model ready`);
|
|
547
|
+
const profile = supervisor.profile ?? forModel(store, targetModel, config.defaults);
|
|
548
|
+
const gpuInfo = await queryGpu();
|
|
549
|
+
const calibration = getCalibration(store, targetModel, profile);
|
|
550
|
+
const fit = gpuInfo
|
|
551
|
+
? vram.fitToBudget({
|
|
552
|
+
model: targetModel,
|
|
553
|
+
profile,
|
|
554
|
+
calibration,
|
|
555
|
+
totalVramBytes: gpuInfo.totalBytes,
|
|
556
|
+
})
|
|
557
|
+
: null;
|
|
558
|
+
const archiveId = archive.runId(targetModel);
|
|
559
|
+
const report = await bench.runSuite({
|
|
560
|
+
host: supervisor.host,
|
|
561
|
+
port: supervisor.internalPort,
|
|
562
|
+
concurrency: 3,
|
|
563
|
+
reasoningBudget: profile.reasoningBudget ?? null,
|
|
564
|
+
contextWindow: profile.contextSize ?? null,
|
|
565
|
+
archiveId,
|
|
566
|
+
onProgress: (event) => {
|
|
567
|
+
ensureActive();
|
|
568
|
+
const message = event.title
|
|
569
|
+
? `${event.title}: ${event.phase}`
|
|
570
|
+
: (event.summary ?? event.phase);
|
|
571
|
+
update.message(message);
|
|
572
|
+
supervisor.recordLog(`operation benchmark: ${message}`);
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
ensureActive();
|
|
576
|
+
results.save({
|
|
577
|
+
model: targetModel,
|
|
578
|
+
profile,
|
|
579
|
+
report,
|
|
580
|
+
gpu: gpuInfo,
|
|
581
|
+
runtime: supervisor.runtime
|
|
582
|
+
? `${supervisor.runtime.label} v${supervisor.runtime.version}`
|
|
583
|
+
: "unknown runtime",
|
|
584
|
+
archiveId,
|
|
585
|
+
args: supervisor.args,
|
|
586
|
+
fit,
|
|
587
|
+
calibration,
|
|
588
|
+
suite: { execute: true, concurrency: 3, depths: null, only: null, mined: false },
|
|
589
|
+
});
|
|
590
|
+
update.percent(100);
|
|
591
|
+
supervisor.recordLog(`operation benchmark: saved result for ${targetModel.displayName}`);
|
|
592
|
+
};
|
|
593
|
+
const jobs = new ServiceJobRunner(rescanCatalog, runResidentJob);
|
|
393
594
|
// Assigned once `stop` exists below. This indirection lets the management API
|
|
394
595
|
// answer a remote restart request before closing its own socket.
|
|
395
596
|
let requestRestart = () => { };
|
|
396
597
|
const hostApi = createHostApi({
|
|
397
598
|
supervisor,
|
|
398
599
|
getCatalog: () => catalog,
|
|
399
|
-
rescan:
|
|
400
|
-
catalog = scanModels(config, env);
|
|
401
|
-
return catalog;
|
|
402
|
-
},
|
|
600
|
+
rescan: rescanCatalog,
|
|
403
601
|
getProfilesStore: () => store,
|
|
404
602
|
saveProfiles: (next) => saveProfilesStore(next, paths),
|
|
405
603
|
getProfileDefaults: () => config.defaults,
|
|
@@ -469,6 +667,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
469
667
|
certManager?.start();
|
|
470
668
|
if (model && profile && runtime) {
|
|
471
669
|
await supervisor.start(model, profile);
|
|
670
|
+
delete store.pendingReloadModelIds[model.id];
|
|
472
671
|
store.lastModelId = model.id;
|
|
473
672
|
saveProfilesStore(store, paths);
|
|
474
673
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type ChildProcess } from "node:child_process";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
|
+
import { type BrainPaths } from "../config/paths.js";
|
|
3
4
|
import type { Model, Runtime } from "../types.js";
|
|
4
|
-
import type { Profile } from "../config/schema.js";
|
|
5
|
+
import type { Profile, ProfilesStore } from "../config/schema.js";
|
|
5
6
|
/**
|
|
6
7
|
* Default loopback port for the private llama-server child. Deliberately clear
|
|
7
8
|
* of Otto's space: 8081 (the old default) is the Expo/Metro dev port, so a brain
|
|
@@ -19,6 +20,14 @@ export interface SupervisorOptions {
|
|
|
19
20
|
internalPort?: number;
|
|
20
21
|
host?: string;
|
|
21
22
|
readyTimeoutMs?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Long-lived hosts provide their live store so profile edits applied just
|
|
25
|
+
* before a model switch are visible without a second disk read. Standalone
|
|
26
|
+
* operations use the current persisted store, which still preserves the
|
|
27
|
+
* launch-resolution invariant.
|
|
28
|
+
*/
|
|
29
|
+
paths?: BrainPaths;
|
|
30
|
+
getProfilesStore?: () => ProfilesStore;
|
|
22
31
|
}
|
|
23
32
|
export interface SupervisorStatus {
|
|
24
33
|
state: SupervisorState;
|
|
@@ -44,6 +53,8 @@ export declare class Supervisor extends EventEmitter {
|
|
|
44
53
|
internalPort: number;
|
|
45
54
|
host: string;
|
|
46
55
|
readyTimeoutMs: number;
|
|
56
|
+
paths: BrainPaths;
|
|
57
|
+
getProfilesStore: () => ProfilesStore;
|
|
47
58
|
state: SupervisorState;
|
|
48
59
|
child: ChildProcess | null;
|
|
49
60
|
model: Model | null;
|
|
@@ -61,10 +72,27 @@ export declare class Supervisor extends EventEmitter {
|
|
|
61
72
|
* shell line is for reading, not for re-parsing.
|
|
62
73
|
*/
|
|
63
74
|
args: string[] | null;
|
|
64
|
-
constructor({ runtime, internalPort, host, readyTimeoutMs, }: SupervisorOptions);
|
|
75
|
+
constructor({ runtime, internalPort, host, readyTimeoutMs, paths, getProfilesStore, }: SupervisorOptions);
|
|
65
76
|
get upstreamBase(): string;
|
|
66
|
-
/**
|
|
67
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Add a host-operation event to the same bounded tail as llama-server output.
|
|
79
|
+
*
|
|
80
|
+
* Calibrate, sweep, and benchmark deliberately reuse this supervisor rather
|
|
81
|
+
* than creating invisible sidecar servers. Their lifecycle markers belong in
|
|
82
|
+
* the same log stream as the child they exercise.
|
|
83
|
+
*/
|
|
84
|
+
recordLog(line: string): void;
|
|
85
|
+
/**
|
|
86
|
+
* Start (or restart) the server for a model + profile.
|
|
87
|
+
*
|
|
88
|
+
* This is the sole llama-server launch boundary, so it materializes the
|
|
89
|
+
* selected hosting profile here. Keeping it beside `buildArgs()` makes the
|
|
90
|
+
* Jinja template and router-visible system addendum mandatory for every
|
|
91
|
+
* caller, including future maintenance operations that start a sidecar.
|
|
92
|
+
*/
|
|
93
|
+
start(model: Model, profile: Profile, options?: {
|
|
94
|
+
preserveLogs?: boolean;
|
|
95
|
+
}): Promise<this>;
|
|
68
96
|
/** Fetch /props from the running server (modalities, template caps, defaults). */
|
|
69
97
|
props(): Promise<unknown>;
|
|
70
98
|
stop(): Promise<void>;
|
|
@@ -8,6 +8,9 @@ import http from "node:http";
|
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { EventEmitter } from "node:events";
|
|
10
10
|
import { buildArgs, buildEnv, formatCommand } from "../runtime/index.js";
|
|
11
|
+
import { resolveHostingProfileForLaunch } from "../config/hosting-profiles.js";
|
|
12
|
+
import { resolveBrainPaths } from "../config/paths.js";
|
|
13
|
+
import { loadProfilesStore } from "../config/store.js";
|
|
11
14
|
import { usedBytes } from "../gpu.js";
|
|
12
15
|
const LOG_LINES_KEPT = 300;
|
|
13
16
|
/**
|
|
@@ -28,13 +31,15 @@ export const DEFAULT_INTERNAL_PORT = 20800;
|
|
|
28
31
|
* stable one so switching models never asks a client to reconnect elsewhere.
|
|
29
32
|
*/
|
|
30
33
|
export class Supervisor extends EventEmitter {
|
|
31
|
-
constructor({ runtime, internalPort = DEFAULT_INTERNAL_PORT, host = "127.0.0.1", readyTimeoutMs = 300000, }) {
|
|
34
|
+
constructor({ runtime, internalPort = DEFAULT_INTERNAL_PORT, host = "127.0.0.1", readyTimeoutMs = 300000, paths = resolveBrainPaths(), getProfilesStore = loadProfilesStore, }) {
|
|
32
35
|
super();
|
|
33
36
|
_Supervisor_instances.add(this);
|
|
34
37
|
this.runtime = runtime;
|
|
35
38
|
this.internalPort = internalPort;
|
|
36
39
|
this.host = host;
|
|
37
40
|
this.readyTimeoutMs = readyTimeoutMs;
|
|
41
|
+
this.paths = paths;
|
|
42
|
+
this.getProfilesStore = getProfilesStore;
|
|
38
43
|
this.state = "stopped"; // stopped | starting | ready | failed
|
|
39
44
|
this.child = null;
|
|
40
45
|
this.model = null;
|
|
@@ -51,8 +56,25 @@ export class Supervisor extends EventEmitter {
|
|
|
51
56
|
get upstreamBase() {
|
|
52
57
|
return `http://${this.host}:${this.internalPort}`;
|
|
53
58
|
}
|
|
54
|
-
/**
|
|
55
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Add a host-operation event to the same bounded tail as llama-server output.
|
|
61
|
+
*
|
|
62
|
+
* Calibrate, sweep, and benchmark deliberately reuse this supervisor rather
|
|
63
|
+
* than creating invisible sidecar servers. Their lifecycle markers belong in
|
|
64
|
+
* the same log stream as the child they exercise.
|
|
65
|
+
*/
|
|
66
|
+
recordLog(line) {
|
|
67
|
+
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, line);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Start (or restart) the server for a model + profile.
|
|
71
|
+
*
|
|
72
|
+
* This is the sole llama-server launch boundary, so it materializes the
|
|
73
|
+
* selected hosting profile here. Keeping it beside `buildArgs()` makes the
|
|
74
|
+
* Jinja template and router-visible system addendum mandatory for every
|
|
75
|
+
* caller, including future maintenance operations that start a sidecar.
|
|
76
|
+
*/
|
|
77
|
+
async start(model, profile, options = {}) {
|
|
56
78
|
await this.stop();
|
|
57
79
|
if (!this.runtime) {
|
|
58
80
|
this.lastError = "no llama.cpp runtime available";
|
|
@@ -60,13 +82,15 @@ export class Supervisor extends EventEmitter {
|
|
|
60
82
|
throw new Error(this.lastError);
|
|
61
83
|
}
|
|
62
84
|
const runtime = this.runtime;
|
|
85
|
+
const launchProfile = resolveHostingProfileForLaunch(this.paths, this.getProfilesStore(), profile, model.family);
|
|
63
86
|
this.model = model;
|
|
64
|
-
this.profile =
|
|
87
|
+
this.profile = launchProfile;
|
|
65
88
|
this.lastError = null;
|
|
66
|
-
|
|
89
|
+
if (!options.preserveLogs)
|
|
90
|
+
this.logLines = [];
|
|
67
91
|
this.vramBaselineBytes = await usedBytes();
|
|
68
92
|
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "starting");
|
|
69
|
-
const args = buildArgs({ ...
|
|
93
|
+
const args = buildArgs({ ...launchProfile, modelPath: model.modelPath, mmprojPath: model.mmprojPath }, { port: this.internalPort, host: this.host }, model);
|
|
70
94
|
this.args = args;
|
|
71
95
|
this.command = formatCommand(runtime, args);
|
|
72
96
|
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, `launching: ${this.command}`);
|
package/dist/tui/app.js
CHANGED
|
@@ -162,7 +162,7 @@ export class App {
|
|
|
162
162
|
this.rankings = new Map(); // model id/name -> averaged benchmark rank + score
|
|
163
163
|
this.rankedModels = []; // ranked list (mean of runs), best first | help
|
|
164
164
|
this.telemetry = new Telemetry();
|
|
165
|
-
this.supervisor = new Supervisor({ runtime });
|
|
165
|
+
this.supervisor = new Supervisor({ runtime, getProfilesStore: () => this.store });
|
|
166
166
|
this.routerServer = null;
|
|
167
167
|
this.supervisor.on("state", () => this.draw());
|
|
168
168
|
this.supervisor.on("log", () => {
|