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