@otto-code/brain 0.8.1 → 0.8.3
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/lifecycle.js +16 -2
- package/dist/config/paths.d.ts +2 -0
- package/dist/config/paths.js +2 -0
- package/dist/models/download.js +15 -5
- package/dist/models/index.js +7 -0
- package/dist/models/rename-map.d.ts +12 -0
- package/dist/models/rename-map.js +39 -0
- package/dist/service/activity.d.ts +22 -2
- package/dist/service/activity.js +109 -23
- package/dist/service/host-api.d.ts +30 -0
- package/dist/service/host-api.js +152 -0
- package/dist/service/router.d.ts +19 -4
- package/dist/service/router.js +104 -39
- package/dist/service/run-log.d.ts +8 -0
- package/dist/service/run-log.js +39 -0
- package/dist/service/scheduler.d.ts +9 -1
- package/dist/service/scheduler.js +15 -3
- package/dist/service/serve.d.ts +2 -1
- package/dist/service/serve.js +73 -24
- package/dist/service/status-events.d.ts +88 -0
- package/dist/service/status-events.js +256 -0
- package/dist/service/supervisor.d.ts +2 -2
- package/dist/service/supervisor.js +11 -5
- package/dist/sysmon.d.ts +13 -0
- package/dist/sysmon.js +87 -1
- package/dist/tui/app.d.ts +26 -1
- package/dist/tui/app.js +250 -10
- package/package.json +1 -1
package/dist/service/serve.js
CHANGED
|
@@ -23,10 +23,12 @@ import * as results from "../ops/results.js";
|
|
|
23
23
|
import { createCpuSampler, sample as sampleSystem } from "../sysmon.js";
|
|
24
24
|
import { createHostApi } from "./host-api.js";
|
|
25
25
|
import { createRouter, Telemetry } from "./router.js";
|
|
26
|
+
import { BrainStatusPublisher } from "./status-events.js";
|
|
26
27
|
import { Supervisor } from "./supervisor.js";
|
|
27
28
|
import * as tailscale from "./tailscale.js";
|
|
28
29
|
import { CertManager, resolveTlsOptions } from "./tls.js";
|
|
29
30
|
import { removePidFile, writePidFile } from "./pid-lock.js";
|
|
31
|
+
import { createBrainRunLog } from "./run-log.js";
|
|
30
32
|
/** The effective config with secrets masked, for the `/__host/config` read. */
|
|
31
33
|
function redactConfig(config) {
|
|
32
34
|
return {
|
|
@@ -93,14 +95,15 @@ function withAuth(inner, token) {
|
|
|
93
95
|
};
|
|
94
96
|
}
|
|
95
97
|
export async function startService({ config, modelNeedle, env = process.env, onLog = () => { }, }) {
|
|
98
|
+
const runLog = createBrainRunLog(env);
|
|
99
|
+
const log = (line) => {
|
|
100
|
+
runLog.write(line);
|
|
101
|
+
onLog(line);
|
|
102
|
+
};
|
|
103
|
+
// The management API must be useful before any setup exists: the Brain page
|
|
104
|
+
// is where the owner downloads both a runtime and their first model. Keep the
|
|
105
|
+
// listener up without either; loadModel reports the missing prerequisite.
|
|
96
106
|
const runtime = resolveRuntime(config, env);
|
|
97
|
-
if (!runtime) {
|
|
98
|
-
throw new CommandError({
|
|
99
|
-
code: "NO_RUNTIME",
|
|
100
|
-
message: "no llama.cpp runtime available",
|
|
101
|
-
details: "run `otto brain runtime install` to download one, or install LM Studio",
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
107
|
const paths = resolveBrainPaths(env);
|
|
105
108
|
const tlsOptions = await resolveTlsOptions(config, paths);
|
|
106
109
|
// `listen.host: "tailscale"` binds the tailnet interface only (invisible to the
|
|
@@ -138,10 +141,22 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
138
141
|
// this, and every reader goes through a getter so nobody holds a stale array.
|
|
139
142
|
let catalog = scanModels(config, env);
|
|
140
143
|
const needle = modelNeedle ?? config.defaultModel ?? store.lastModelId ?? undefined;
|
|
141
|
-
|
|
142
|
-
|
|
144
|
+
let model = null;
|
|
145
|
+
if (catalog.length > 0) {
|
|
146
|
+
try {
|
|
147
|
+
model = needle ? pickModel(catalog, needle) : pickAutoModel(catalog);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
// A removed default or last-used model must not take the management
|
|
151
|
+
// service down. An explicit CLI selection remains an actionable error.
|
|
152
|
+
if (modelNeedle)
|
|
153
|
+
throw error;
|
|
154
|
+
log(`note: ${error instanceof Error ? error.message : "configured model is unavailable"}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
let profile = model ? forModel(store, model, config.defaults) : null;
|
|
143
158
|
const gpu = await queryGpu();
|
|
144
|
-
if (gpu) {
|
|
159
|
+
if (gpu && model && profile) {
|
|
145
160
|
const fit = vram.fitToBudget({
|
|
146
161
|
model,
|
|
147
162
|
profile,
|
|
@@ -149,28 +164,39 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
149
164
|
totalVramBytes: gpu.totalBytes,
|
|
150
165
|
});
|
|
151
166
|
if (!fit.adjusted && !fit.budget.fits) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
167
|
+
// Starting the host is what exposes the Library and model profile UI.
|
|
168
|
+
// An automatic startup candidate that cannot load must therefore leave
|
|
169
|
+
// the host alive and unloaded, not make the only recovery surface vanish.
|
|
170
|
+
log(`note: not loading ${model.displayName}: ${fit.reason ?? "does not fit in available VRAM"}`);
|
|
171
|
+
model = null;
|
|
172
|
+
profile = null;
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
if (fit.adjusted && fit.reason)
|
|
176
|
+
log(`note: ${fit.reason}`);
|
|
177
|
+
profile = fit.profile;
|
|
157
178
|
}
|
|
158
|
-
if (fit.adjusted && fit.reason)
|
|
159
|
-
onLog(`note: ${fit.reason}`);
|
|
160
|
-
profile = fit.profile;
|
|
161
179
|
}
|
|
162
180
|
const telemetry = new Telemetry();
|
|
163
181
|
const supervisor = new Supervisor({ runtime });
|
|
164
182
|
supervisor.on("log", (line) => {
|
|
183
|
+
runLog.write(line);
|
|
165
184
|
if (/error|failed|warn/i.test(line))
|
|
166
185
|
onLog(line);
|
|
167
186
|
});
|
|
187
|
+
supervisor.on("crashed", (error) => runLog.write(`FATAL ${error}`));
|
|
168
188
|
// Serialize model switches: the router queues request-driven switches, but the
|
|
169
189
|
// config path (POST /__host/config) calls loadModel directly. Chaining here
|
|
170
190
|
// guarantees two switches (e.g. a config write racing a request-driven switch)
|
|
171
191
|
// can never overlap two supervisor.start() calls, whichever caller triggers them.
|
|
172
192
|
let modelSwitchChain = Promise.resolve();
|
|
173
193
|
const loadModelUnsafe = async (target) => {
|
|
194
|
+
// A runtime can be installed from the Library tab after this service starts.
|
|
195
|
+
// Resolve it at load time so the user does not have to restart the brain.
|
|
196
|
+
supervisor.runtime = resolveRuntime(config, env);
|
|
197
|
+
if (!supervisor.runtime) {
|
|
198
|
+
throw new Error("no llama.cpp runtime available; install one from the Library tab");
|
|
199
|
+
}
|
|
174
200
|
const gpuInfo = await queryGpu();
|
|
175
201
|
let fitProfile = forModel(store, target, config.defaults);
|
|
176
202
|
if (gpuInfo) {
|
|
@@ -232,6 +258,11 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
232
258
|
// One CPU sampler for the lifetime of the service: it reports a busy fraction
|
|
233
259
|
// between successive calls, so a fresh one per request would always return null.
|
|
234
260
|
const cpuSampler = createCpuSampler();
|
|
261
|
+
// Constructed here, ahead of both consumers, because the router installs the
|
|
262
|
+
// snapshot builder into it and the management API serves it at
|
|
263
|
+
// /__host/events. One instance, so `capabilities.events` and the stream can
|
|
264
|
+
// never disagree about whether this brain publishes.
|
|
265
|
+
const statusEvents = new BrainStatusPublisher();
|
|
235
266
|
const hostApi = createHostApi({
|
|
236
267
|
supervisor,
|
|
237
268
|
getCatalog: () => catalog,
|
|
@@ -258,11 +289,12 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
258
289
|
getAllowWrite: allowWrite,
|
|
259
290
|
getModelsDir: () => managedModelsDir(config, env),
|
|
260
291
|
sampleResources: () => sampleSystem(cpuSampler, { host: supervisor.host, port: supervisor.internalPort }),
|
|
292
|
+
statusEvents,
|
|
261
293
|
});
|
|
262
294
|
const handler = withAuth(createRouter({
|
|
263
295
|
supervisor,
|
|
264
296
|
telemetry,
|
|
265
|
-
logger: { warn: (m) =>
|
|
297
|
+
logger: { warn: (m) => log(`WARN ${m}`) },
|
|
266
298
|
getCatalog: () => catalog,
|
|
267
299
|
loadModel,
|
|
268
300
|
version: resolveVersion(),
|
|
@@ -273,7 +305,10 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
273
305
|
applyConfigPatch,
|
|
274
306
|
getAllowConfigWrite: allowWrite,
|
|
275
307
|
hostApi,
|
|
276
|
-
|
|
308
|
+
// `buildCheapStatus` already samples `/slots`. Do not hit it a second
|
|
309
|
+
// time in parallel: the shared rate tracker needs one ordered timeline.
|
|
310
|
+
getResources: () => sampleSystem(cpuSampler),
|
|
311
|
+
statusEvents,
|
|
277
312
|
}), authToken);
|
|
278
313
|
// TLS terminates in-process when configured; otherwise plain HTTP. The cert
|
|
279
314
|
// manager issues/generates the first keypair before we listen, and hot-swaps
|
|
@@ -300,9 +335,17 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
300
335
|
server.listen(port, bindHost, resolve);
|
|
301
336
|
});
|
|
302
337
|
certManager?.start();
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
338
|
+
if (model && profile && runtime) {
|
|
339
|
+
await supervisor.start(model, profile);
|
|
340
|
+
store.lastModelId = model.id;
|
|
341
|
+
saveProfilesStore(store, paths);
|
|
342
|
+
}
|
|
343
|
+
else if (!runtime) {
|
|
344
|
+
log("ready: no llama.cpp runtime installed; use the Library tab to download one");
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
log("ready: no model installed; use the Library tab to download one");
|
|
348
|
+
}
|
|
306
349
|
writePidFile({
|
|
307
350
|
pid: process.pid,
|
|
308
351
|
host: bindHost,
|
|
@@ -311,9 +354,15 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
311
354
|
secure: Boolean(tlsOptions),
|
|
312
355
|
displayHost,
|
|
313
356
|
}, env);
|
|
357
|
+
log(`ready: ${supervisor.model?.displayName ?? "no model loaded"} on ${bindHost}:${port}; run log ${runLog.path}`);
|
|
314
358
|
const stop = async () => {
|
|
315
359
|
certManager?.stop();
|
|
360
|
+
log("Brain service stopping");
|
|
361
|
+
// Before server.close(), which waits on open connections: a subscribed
|
|
362
|
+
// daemon holds an SSE response open indefinitely by design.
|
|
363
|
+
statusEvents.close();
|
|
316
364
|
await supervisor.stop();
|
|
365
|
+
server.closeIdleConnections?.();
|
|
317
366
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
318
367
|
removePidFile(env);
|
|
319
368
|
};
|
|
@@ -322,7 +371,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
322
371
|
supervisor,
|
|
323
372
|
host: bindHost,
|
|
324
373
|
port,
|
|
325
|
-
model,
|
|
374
|
+
model: supervisor.model,
|
|
326
375
|
secure: Boolean(tlsOptions),
|
|
327
376
|
displayHost,
|
|
328
377
|
stop,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The brain's authoritative status event source.
|
|
3
|
+
*
|
|
4
|
+
* The brain owns its own state, so it publishes it rather than being polled for
|
|
5
|
+
* it. One daemon subscribes over SSE (`GET /__host/events`) and fans the result
|
|
6
|
+
* out to every connected Otto client, replacing a per-client status poll that
|
|
7
|
+
* could never show a transition sooner than its own interval.
|
|
8
|
+
*
|
|
9
|
+
* Three properties this has to keep:
|
|
10
|
+
*
|
|
11
|
+
* - **Complete snapshots, never deltas.** A reader that missed an event, or
|
|
12
|
+
* reconnected after one, must not have to reconstruct anything: the newest
|
|
13
|
+
* snapshot is the whole truth. That is what makes reconnect repair on both
|
|
14
|
+
* sides idempotent.
|
|
15
|
+
* - **Coalescing lives here, not in the reader.** A timer tick that finds
|
|
16
|
+
* nothing changed emits nothing, and a completion's traffic counters are not
|
|
17
|
+
* a state change. Otherwise "push" would just be a poll with extra steps, and
|
|
18
|
+
* a busy brain would broadcast to every client for every token.
|
|
19
|
+
* - **No work while nobody is listening.** The sampler only runs while a
|
|
20
|
+
* subscriber is attached, so a brain the daemon never subscribed to (an older
|
|
21
|
+
* daemon, or one that is not running) pays nothing.
|
|
22
|
+
*
|
|
23
|
+
* The snapshot itself is deliberately the *cheap* status: no `resources`, whose
|
|
24
|
+
* collection spawns `nvidia-smi`. That stays an opt-in pull for the Overview tab.
|
|
25
|
+
*/
|
|
26
|
+
/** A complete cheap `/__host/status` body. Opaque here; the router assembles it. */
|
|
27
|
+
export type BrainStatusSnapshot = Record<string, unknown>;
|
|
28
|
+
export type BrainStatusListener = (snapshot: BrainStatusSnapshot) => void;
|
|
29
|
+
/**
|
|
30
|
+
* The fields whose change is worth waking every connected client for.
|
|
31
|
+
*
|
|
32
|
+
* Everything omitted here still rides in the payload; it just cannot *trigger*
|
|
33
|
+
* a payload on its own. The omissions are the churning ones:
|
|
34
|
+
*
|
|
35
|
+
* - `telemetry` totals and `recent` advance on every completion.
|
|
36
|
+
* - `logLineCount` advances on every llama-server log line.
|
|
37
|
+
* - `slots.contexts` is capacity detail that changes independently of work.
|
|
38
|
+
*
|
|
39
|
+
* `slots.threads` is intentionally included. It is sampled at a bounded 4 Hz,
|
|
40
|
+
* not notified per model token, so it gives the Overview live counts and rates
|
|
41
|
+
* without turning a fast model into a token-rate broadcast.
|
|
42
|
+
*
|
|
43
|
+
* `telemetry.warning` is kept: the reasoning-only advice is a state the UI
|
|
44
|
+
* shows, not a counter.
|
|
45
|
+
*/
|
|
46
|
+
export declare function statusChangeKey(snapshot: BrainStatusSnapshot): string;
|
|
47
|
+
export interface BrainStatusPublisherOptions {
|
|
48
|
+
/** Resample cadence while subscribed. See DEFAULT_SAMPLE_INTERVAL_MS. */
|
|
49
|
+
sampleIntervalMs?: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Owns the current snapshot and decides when it changed.
|
|
53
|
+
*
|
|
54
|
+
* Constructed before the router (so `serve.ts` can hand the same instance to
|
|
55
|
+
* both the router and the management API) and inert until the router installs
|
|
56
|
+
* the snapshot source with {@link setSource}. `/__host/capabilities` advertises
|
|
57
|
+
* `events` only once it is {@link ready}, so a brain can never claim a stream it
|
|
58
|
+
* cannot actually serve.
|
|
59
|
+
*/
|
|
60
|
+
export declare class BrainStatusPublisher {
|
|
61
|
+
#private;
|
|
62
|
+
constructor(options?: BrainStatusPublisherOptions);
|
|
63
|
+
/** Whether a snapshot source has been installed - i.e. events can be served. */
|
|
64
|
+
get ready(): boolean;
|
|
65
|
+
get listenerCount(): number;
|
|
66
|
+
/** Install the snapshot builder. Called once, by the router. */
|
|
67
|
+
setSource(source: () => Promise<BrainStatusSnapshot>): void;
|
|
68
|
+
/**
|
|
69
|
+
* Attach a listener. It receives the current snapshot immediately when one is
|
|
70
|
+
* known, and a fresh sample is taken right away so a subscriber that arrives
|
|
71
|
+
* during a transition is not handed a stale first frame.
|
|
72
|
+
*/
|
|
73
|
+
subscribe(listener: BrainStatusListener, onClose?: () => void): () => void;
|
|
74
|
+
/**
|
|
75
|
+
* Something authoritative changed - resample now rather than at the next tick.
|
|
76
|
+
* Cheap to over-call: an unchanged snapshot emits nothing.
|
|
77
|
+
*/
|
|
78
|
+
notify(): void;
|
|
79
|
+
/**
|
|
80
|
+
* Drop every listener and end the streams behind them.
|
|
81
|
+
*
|
|
82
|
+
* Ending them is not optional at shutdown: an open SSE response is an open
|
|
83
|
+
* connection, and `server.close()` waits for those, so a brain that only
|
|
84
|
+
* unsubscribed would hang on stop until its daemon happened to disconnect.
|
|
85
|
+
*/
|
|
86
|
+
close(): void;
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=status-events.d.ts.map
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The brain's authoritative status event source.
|
|
3
|
+
*
|
|
4
|
+
* The brain owns its own state, so it publishes it rather than being polled for
|
|
5
|
+
* it. One daemon subscribes over SSE (`GET /__host/events`) and fans the result
|
|
6
|
+
* out to every connected Otto client, replacing a per-client status poll that
|
|
7
|
+
* could never show a transition sooner than its own interval.
|
|
8
|
+
*
|
|
9
|
+
* Three properties this has to keep:
|
|
10
|
+
*
|
|
11
|
+
* - **Complete snapshots, never deltas.** A reader that missed an event, or
|
|
12
|
+
* reconnected after one, must not have to reconstruct anything: the newest
|
|
13
|
+
* snapshot is the whole truth. That is what makes reconnect repair on both
|
|
14
|
+
* sides idempotent.
|
|
15
|
+
* - **Coalescing lives here, not in the reader.** A timer tick that finds
|
|
16
|
+
* nothing changed emits nothing, and a completion's traffic counters are not
|
|
17
|
+
* a state change. Otherwise "push" would just be a poll with extra steps, and
|
|
18
|
+
* a busy brain would broadcast to every client for every token.
|
|
19
|
+
* - **No work while nobody is listening.** The sampler only runs while a
|
|
20
|
+
* subscriber is attached, so a brain the daemon never subscribed to (an older
|
|
21
|
+
* daemon, or one that is not running) pays nothing.
|
|
22
|
+
*
|
|
23
|
+
* The snapshot itself is deliberately the *cheap* status: no `resources`, whose
|
|
24
|
+
* collection spawns `nvidia-smi`. That stays an opt-in pull for the Overview tab.
|
|
25
|
+
*/
|
|
26
|
+
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
27
|
+
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
28
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
|
29
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
30
|
+
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
31
|
+
};
|
|
32
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
33
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
34
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
35
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
36
|
+
};
|
|
37
|
+
var _BrainStatusPublisher_instances, _BrainStatusPublisher_listeners, _BrainStatusPublisher_closers, _BrainStatusPublisher_sampleIntervalMs, _BrainStatusPublisher_source, _BrainStatusPublisher_timer, _BrainStatusPublisher_last, _BrainStatusPublisher_lastKey, _BrainStatusPublisher_sampling, _BrainStatusPublisher_resample, _BrainStatusPublisher_closed, _BrainStatusPublisher_sample, _BrainStatusPublisher_startTimer, _BrainStatusPublisher_stopTimer;
|
|
38
|
+
/**
|
|
39
|
+
* How often the publisher resamples while at least one listener is attached.
|
|
40
|
+
*
|
|
41
|
+
* A sample is needed at all because two inputs have no event to hang off:
|
|
42
|
+
* `activity` is a file written by a *different* process (a `calibrate` or
|
|
43
|
+
* `pull` CLI run), and the slot phase split is a loopback read of
|
|
44
|
+
* llama-server's `/slots`. Everything else notifies directly. Sampling is not
|
|
45
|
+
* a heartbeat: an unchanged sample emits nothing.
|
|
46
|
+
*/
|
|
47
|
+
const DEFAULT_SAMPLE_INTERVAL_MS = 250;
|
|
48
|
+
function isRecord(value) {
|
|
49
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The fields whose change is worth waking every connected client for.
|
|
53
|
+
*
|
|
54
|
+
* Everything omitted here still rides in the payload; it just cannot *trigger*
|
|
55
|
+
* a payload on its own. The omissions are the churning ones:
|
|
56
|
+
*
|
|
57
|
+
* - `telemetry` totals and `recent` advance on every completion.
|
|
58
|
+
* - `logLineCount` advances on every llama-server log line.
|
|
59
|
+
* - `slots.contexts` is capacity detail that changes independently of work.
|
|
60
|
+
*
|
|
61
|
+
* `slots.threads` is intentionally included. It is sampled at a bounded 4 Hz,
|
|
62
|
+
* not notified per model token, so it gives the Overview live counts and rates
|
|
63
|
+
* without turning a fast model into a token-rate broadcast.
|
|
64
|
+
*
|
|
65
|
+
* `telemetry.warning` is kept: the reasoning-only advice is a state the UI
|
|
66
|
+
* shows, not a counter.
|
|
67
|
+
*/
|
|
68
|
+
export function statusChangeKey(snapshot) {
|
|
69
|
+
const scheduler = isRecord(snapshot.scheduler) ? snapshot.scheduler : null;
|
|
70
|
+
const telemetry = isRecord(snapshot.telemetry) ? snapshot.telemetry : null;
|
|
71
|
+
const slots = isRecord(snapshot.slots) ? snapshot.slots : null;
|
|
72
|
+
return JSON.stringify({
|
|
73
|
+
version: snapshot.version ?? null,
|
|
74
|
+
apiVersion: snapshot.apiVersion ?? null,
|
|
75
|
+
state: snapshot.state ?? null,
|
|
76
|
+
model: snapshot.model ?? null,
|
|
77
|
+
modelId: snapshot.modelId ?? null,
|
|
78
|
+
pid: snapshot.pid ?? null,
|
|
79
|
+
vramBytes: snapshot.vramBytes ?? null,
|
|
80
|
+
loadSeconds: snapshot.loadSeconds ?? null,
|
|
81
|
+
startedAt: snapshot.startedAt ?? null,
|
|
82
|
+
lastError: snapshot.lastError ?? null,
|
|
83
|
+
upstream: snapshot.upstream ?? null,
|
|
84
|
+
runtime: snapshot.runtime ?? null,
|
|
85
|
+
capabilities: snapshot.capabilities ?? null,
|
|
86
|
+
activity: snapshot.activity ?? null,
|
|
87
|
+
reasoning: snapshot.reasoning ?? null,
|
|
88
|
+
inference: snapshot.inference ?? null,
|
|
89
|
+
queued: snapshot.queued ?? null,
|
|
90
|
+
schedulerWaiting: scheduler?.waiting ?? null,
|
|
91
|
+
schedulerLastTurn: scheduler?.lastTurn ?? null,
|
|
92
|
+
warning: telemetry?.warning ?? null,
|
|
93
|
+
slots: slots
|
|
94
|
+
? {
|
|
95
|
+
total: slots.total ?? null,
|
|
96
|
+
busy: slots.busy ?? null,
|
|
97
|
+
idle: slots.idle ?? null,
|
|
98
|
+
prefill: slots.prefill ?? null,
|
|
99
|
+
decode: slots.decode ?? null,
|
|
100
|
+
threads: slots.threads ?? null,
|
|
101
|
+
}
|
|
102
|
+
: null,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Owns the current snapshot and decides when it changed.
|
|
107
|
+
*
|
|
108
|
+
* Constructed before the router (so `serve.ts` can hand the same instance to
|
|
109
|
+
* both the router and the management API) and inert until the router installs
|
|
110
|
+
* the snapshot source with {@link setSource}. `/__host/capabilities` advertises
|
|
111
|
+
* `events` only once it is {@link ready}, so a brain can never claim a stream it
|
|
112
|
+
* cannot actually serve.
|
|
113
|
+
*/
|
|
114
|
+
export class BrainStatusPublisher {
|
|
115
|
+
constructor(options = {}) {
|
|
116
|
+
_BrainStatusPublisher_instances.add(this);
|
|
117
|
+
_BrainStatusPublisher_listeners.set(this, new Set());
|
|
118
|
+
/** Per-subscription teardown, so `close()` can end the responses it feeds. */
|
|
119
|
+
_BrainStatusPublisher_closers.set(this, new Set());
|
|
120
|
+
_BrainStatusPublisher_sampleIntervalMs.set(this, void 0);
|
|
121
|
+
_BrainStatusPublisher_source.set(this, null);
|
|
122
|
+
_BrainStatusPublisher_timer.set(this, null);
|
|
123
|
+
/** The last snapshot that was actually emitted, replayed to a late subscriber. */
|
|
124
|
+
_BrainStatusPublisher_last.set(this, null);
|
|
125
|
+
_BrainStatusPublisher_lastKey.set(this, null);
|
|
126
|
+
/** True while a sample is in flight, so overlapping triggers collapse into one. */
|
|
127
|
+
_BrainStatusPublisher_sampling.set(this, false);
|
|
128
|
+
/** A trigger that arrived mid-sample; runs exactly one more sample afterwards. */
|
|
129
|
+
_BrainStatusPublisher_resample.set(this, false);
|
|
130
|
+
_BrainStatusPublisher_closed.set(this, false);
|
|
131
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_sampleIntervalMs, options.sampleIntervalMs ?? DEFAULT_SAMPLE_INTERVAL_MS, "f");
|
|
132
|
+
}
|
|
133
|
+
/** Whether a snapshot source has been installed - i.e. events can be served. */
|
|
134
|
+
get ready() {
|
|
135
|
+
return __classPrivateFieldGet(this, _BrainStatusPublisher_source, "f") !== null && !__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f");
|
|
136
|
+
}
|
|
137
|
+
get listenerCount() {
|
|
138
|
+
return __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").size;
|
|
139
|
+
}
|
|
140
|
+
/** Install the snapshot builder. Called once, by the router. */
|
|
141
|
+
setSource(source) {
|
|
142
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_source, source, "f");
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Attach a listener. It receives the current snapshot immediately when one is
|
|
146
|
+
* known, and a fresh sample is taken right away so a subscriber that arrives
|
|
147
|
+
* during a transition is not handed a stale first frame.
|
|
148
|
+
*/
|
|
149
|
+
subscribe(listener, onClose) {
|
|
150
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f")) {
|
|
151
|
+
onClose?.();
|
|
152
|
+
return () => { };
|
|
153
|
+
}
|
|
154
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").add(listener);
|
|
155
|
+
if (onClose)
|
|
156
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_closers, "f").add(onClose);
|
|
157
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_last, "f"))
|
|
158
|
+
listener(__classPrivateFieldGet(this, _BrainStatusPublisher_last, "f"));
|
|
159
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_startTimer).call(this);
|
|
160
|
+
this.notify();
|
|
161
|
+
return () => {
|
|
162
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").delete(listener);
|
|
163
|
+
if (onClose)
|
|
164
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_closers, "f").delete(onClose);
|
|
165
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").size === 0)
|
|
166
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_stopTimer).call(this);
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Something authoritative changed - resample now rather than at the next tick.
|
|
171
|
+
* Cheap to over-call: an unchanged snapshot emits nothing.
|
|
172
|
+
*/
|
|
173
|
+
notify() {
|
|
174
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f") || __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").size === 0)
|
|
175
|
+
return;
|
|
176
|
+
void __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_sample).call(this);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Drop every listener and end the streams behind them.
|
|
180
|
+
*
|
|
181
|
+
* Ending them is not optional at shutdown: an open SSE response is an open
|
|
182
|
+
* connection, and `server.close()` waits for those, so a brain that only
|
|
183
|
+
* unsubscribed would hang on stop until its daemon happened to disconnect.
|
|
184
|
+
*/
|
|
185
|
+
close() {
|
|
186
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
|
|
187
|
+
return;
|
|
188
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_closed, true, "f");
|
|
189
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_stopTimer).call(this);
|
|
190
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").clear();
|
|
191
|
+
const closers = [...__classPrivateFieldGet(this, _BrainStatusPublisher_closers, "f")];
|
|
192
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_closers, "f").clear();
|
|
193
|
+
for (const onClose of closers) {
|
|
194
|
+
try {
|
|
195
|
+
onClose();
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// Shutdown must not be blocked by one stream failing to end.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
_BrainStatusPublisher_listeners = new WeakMap(), _BrainStatusPublisher_closers = new WeakMap(), _BrainStatusPublisher_sampleIntervalMs = new WeakMap(), _BrainStatusPublisher_source = new WeakMap(), _BrainStatusPublisher_timer = new WeakMap(), _BrainStatusPublisher_last = new WeakMap(), _BrainStatusPublisher_lastKey = new WeakMap(), _BrainStatusPublisher_sampling = new WeakMap(), _BrainStatusPublisher_resample = new WeakMap(), _BrainStatusPublisher_closed = new WeakMap(), _BrainStatusPublisher_instances = new WeakSet(), _BrainStatusPublisher_sample = async function _BrainStatusPublisher_sample() {
|
|
204
|
+
const source = __classPrivateFieldGet(this, _BrainStatusPublisher_source, "f");
|
|
205
|
+
if (!source || __classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
|
|
206
|
+
return;
|
|
207
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_sampling, "f")) {
|
|
208
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_resample, true, "f");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_sampling, true, "f");
|
|
212
|
+
try {
|
|
213
|
+
const snapshot = await source();
|
|
214
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
|
|
215
|
+
return;
|
|
216
|
+
const key = statusChangeKey(snapshot);
|
|
217
|
+
// Always keep the newest body for replay, even when nothing significant
|
|
218
|
+
// changed: a late subscriber should not get last minute's counters.
|
|
219
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_last, snapshot, "f");
|
|
220
|
+
if (key === __classPrivateFieldGet(this, _BrainStatusPublisher_lastKey, "f"))
|
|
221
|
+
return;
|
|
222
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_lastKey, key, "f");
|
|
223
|
+
// Set iteration tolerates a listener unsubscribing mid-emit, which the
|
|
224
|
+
// SSE teardown does when a daemon disconnects during a snapshot.
|
|
225
|
+
for (const listener of __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f")) {
|
|
226
|
+
try {
|
|
227
|
+
listener(snapshot);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// A broken listener is that listener's problem; the others still get it.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// A failed sample is not a state change. The next tick tries again.
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_sampling, false, "f");
|
|
239
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_resample, "f")) {
|
|
240
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_resample, false, "f");
|
|
241
|
+
void __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_sample).call(this);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}, _BrainStatusPublisher_startTimer = function _BrainStatusPublisher_startTimer() {
|
|
245
|
+
if (__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f") || __classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
|
|
246
|
+
return;
|
|
247
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_timer, setInterval(() => this.notify(), __classPrivateFieldGet(this, _BrainStatusPublisher_sampleIntervalMs, "f")), "f");
|
|
248
|
+
// Never hold the process open for status reporting alone.
|
|
249
|
+
__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f").unref?.();
|
|
250
|
+
}, _BrainStatusPublisher_stopTimer = function _BrainStatusPublisher_stopTimer() {
|
|
251
|
+
if (!__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f"))
|
|
252
|
+
return;
|
|
253
|
+
clearInterval(__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f"));
|
|
254
|
+
__classPrivateFieldSet(this, _BrainStatusPublisher_timer, null, "f");
|
|
255
|
+
};
|
|
256
|
+
//# sourceMappingURL=status-events.js.map
|
|
@@ -15,7 +15,7 @@ import type { Profile } from "../config/schema.js";
|
|
|
15
15
|
export declare const DEFAULT_INTERNAL_PORT = 20800;
|
|
16
16
|
export type SupervisorState = "stopped" | "starting" | "ready" | "failed" | "stopping";
|
|
17
17
|
export interface SupervisorOptions {
|
|
18
|
-
runtime: Runtime;
|
|
18
|
+
runtime: Runtime | null;
|
|
19
19
|
internalPort?: number;
|
|
20
20
|
host?: string;
|
|
21
21
|
readyTimeoutMs?: number;
|
|
@@ -40,7 +40,7 @@ export interface SupervisorStatus {
|
|
|
40
40
|
*/
|
|
41
41
|
export declare class Supervisor extends EventEmitter {
|
|
42
42
|
#private;
|
|
43
|
-
runtime: Runtime;
|
|
43
|
+
runtime: Runtime | null;
|
|
44
44
|
internalPort: number;
|
|
45
45
|
host: string;
|
|
46
46
|
readyTimeoutMs: number;
|
|
@@ -54,6 +54,12 @@ export class Supervisor extends EventEmitter {
|
|
|
54
54
|
/** Start (or restart) the server for a model + profile. */
|
|
55
55
|
async start(model, profile) {
|
|
56
56
|
await this.stop();
|
|
57
|
+
if (!this.runtime) {
|
|
58
|
+
this.lastError = "no llama.cpp runtime available";
|
|
59
|
+
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "failed", this.lastError);
|
|
60
|
+
throw new Error(this.lastError);
|
|
61
|
+
}
|
|
62
|
+
const runtime = this.runtime;
|
|
57
63
|
this.model = model;
|
|
58
64
|
this.profile = profile;
|
|
59
65
|
this.lastError = null;
|
|
@@ -62,12 +68,12 @@ export class Supervisor extends EventEmitter {
|
|
|
62
68
|
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "starting");
|
|
63
69
|
const args = buildArgs({ ...profile, modelPath: model.modelPath, mmprojPath: model.mmprojPath }, { port: this.internalPort, host: this.host });
|
|
64
70
|
this.args = args;
|
|
65
|
-
this.command = formatCommand(
|
|
71
|
+
this.command = formatCommand(runtime, args);
|
|
66
72
|
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, `launching: ${this.command}`);
|
|
67
73
|
const started = Date.now();
|
|
68
|
-
this.child = spawn(
|
|
69
|
-
cwd:
|
|
70
|
-
env: buildEnv(
|
|
74
|
+
this.child = spawn(runtime.exe, args, {
|
|
75
|
+
cwd: runtime.dir,
|
|
76
|
+
env: buildEnv(runtime),
|
|
71
77
|
windowsHide: true,
|
|
72
78
|
stdio: ["ignore", "pipe", "pipe"],
|
|
73
79
|
});
|
|
@@ -194,7 +200,7 @@ export class Supervisor extends EventEmitter {
|
|
|
194
200
|
startedAt: this.startedAt ? this.startedAt.toISOString() : null,
|
|
195
201
|
lastError: this.lastError,
|
|
196
202
|
upstream: this.upstreamBase,
|
|
197
|
-
runtime: `${this.runtime.label} v${this.runtime.version}
|
|
203
|
+
runtime: this.runtime ? `${this.runtime.label} v${this.runtime.version}` : "not installed",
|
|
198
204
|
};
|
|
199
205
|
}
|
|
200
206
|
}
|
package/dist/sysmon.d.ts
CHANGED
|
@@ -31,6 +31,14 @@ export interface SlotInfo {
|
|
|
31
31
|
/** Slots emitting tokens. */
|
|
32
32
|
decode: number;
|
|
33
33
|
contexts: number[];
|
|
34
|
+
threads?: Array<{
|
|
35
|
+
slot: number;
|
|
36
|
+
phase: "prefill" | "decode";
|
|
37
|
+
promptTokens: number | null;
|
|
38
|
+
generatedTokens: number;
|
|
39
|
+
promptTokensPerSecond: number | null;
|
|
40
|
+
tokensPerSecond: number | null;
|
|
41
|
+
}>;
|
|
34
42
|
}
|
|
35
43
|
/** One combined reading for the status panel. */
|
|
36
44
|
export interface SystemSample {
|
|
@@ -55,6 +63,11 @@ export declare function createCpuSampler(): CpuSampler;
|
|
|
55
63
|
* the field spellings real llama.cpp builds emit, without a live server.
|
|
56
64
|
*/
|
|
57
65
|
export declare function summariseSlots(rows: unknown[]): SlotInfo;
|
|
66
|
+
/** Measures throughput from successive `/slots` snapshots, without guessing. */
|
|
67
|
+
export declare class SlotActivityTracker {
|
|
68
|
+
#private;
|
|
69
|
+
sample(rows: unknown[], now?: number): SlotInfo;
|
|
70
|
+
}
|
|
58
71
|
/** Slot occupancy from the running server. */
|
|
59
72
|
declare function slots({ host, port }: {
|
|
60
73
|
host: string;
|