@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/host-api.js
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
import { calibrationInfo, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, } from "../config/profile-edit.js";
|
|
2
2
|
import { forModel, getCalibration, put } from "../config/profiles.js";
|
|
3
3
|
import { deleteModelFiles, diskUsage, planDelete, totalModelBytes } from "../models/manage.js";
|
|
4
|
+
import { deleteDisplayName, updateDisplayName } from "../models/rename-map.js";
|
|
4
5
|
import * as vram from "../vram.js";
|
|
5
6
|
import { errorMessage, readJsonBody, sendError, sendJson } from "./http-util.js";
|
|
6
7
|
const MAX_PATCH_BYTES = 256 * 1024;
|
|
8
|
+
const MAX_DISPLAY_NAME = 200;
|
|
7
9
|
const DEFAULT_LOG_LINES = 200;
|
|
10
|
+
/**
|
|
11
|
+
* The management API's own version, additive to the capability flags.
|
|
12
|
+
*
|
|
13
|
+
* Capabilities answer "can this brain do X"; this answers "which generation of
|
|
14
|
+
* the API is this" for the rare change that no single flag describes. A daemon
|
|
15
|
+
* reads both and never requires an exact package-version match.
|
|
16
|
+
*/
|
|
17
|
+
export const HOST_API_VERSION = 2;
|
|
18
|
+
/**
|
|
19
|
+
* How often the SSE stream writes a comment line when nothing has changed.
|
|
20
|
+
*
|
|
21
|
+
* This is transport keepalive, not a status event: a proxy or a NAT table with
|
|
22
|
+
* an idle timeout would otherwise silently drop a stream from a brain that is
|
|
23
|
+
* simply sitting still, and the daemon would report an unreachable brain that is
|
|
24
|
+
* fine. Comments are ignored by every SSE parser, so no reader sees them.
|
|
25
|
+
*/
|
|
26
|
+
const SSE_KEEPALIVE_MS = 20000;
|
|
8
27
|
function stateOf(supervisor, model) {
|
|
9
28
|
if (!supervisor.model || supervisor.model.id !== model.id)
|
|
10
29
|
return "not-loaded";
|
|
@@ -101,6 +120,13 @@ export function createHostApi(deps) {
|
|
|
101
120
|
load: true,
|
|
102
121
|
resources: true,
|
|
103
122
|
inventory: true,
|
|
123
|
+
rename: true,
|
|
124
|
+
reset: true,
|
|
125
|
+
// Read live rather than captured: the publisher is inert until the router
|
|
126
|
+
// installs its snapshot source, and advertising a stream we cannot serve
|
|
127
|
+
// would make a daemon stop polling and see nothing.
|
|
128
|
+
events: Boolean(deps.statusEvents?.ready),
|
|
129
|
+
liveInference: Boolean(deps.statusEvents?.ready),
|
|
104
130
|
writable: deps.getAllowWrite(),
|
|
105
131
|
});
|
|
106
132
|
/** Refuse a write unless the owner opted into remote configuration. */
|
|
@@ -180,6 +206,59 @@ export function createHostApi(deps) {
|
|
|
180
206
|
})();
|
|
181
207
|
});
|
|
182
208
|
};
|
|
209
|
+
const handleRename = (req, res, model) => {
|
|
210
|
+
readJsonBody(req, MAX_DISPLAY_NAME + 64, (result) => {
|
|
211
|
+
if (!result.ok) {
|
|
212
|
+
sendError(res, 400, result.error);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const body = result.body;
|
|
216
|
+
const displayName = body.displayName;
|
|
217
|
+
if (typeof displayName !== "string" || displayName.trim().length === 0) {
|
|
218
|
+
sendError(res, 400, "displayName must be a non-empty string");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (displayName.length > MAX_DISPLAY_NAME) {
|
|
222
|
+
sendError(res, 400, `displayName must be at most ${MAX_DISPLAY_NAME} characters`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (/[^\x20-\x7E]/.test(displayName)) {
|
|
226
|
+
sendError(res, 400, "displayName must not contain control characters or non-ASCII");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// /v1/models keys its `id` on displayName (router.ts) and both the
|
|
230
|
+
// completion path and defaultModel/switchTo resolve a model by
|
|
231
|
+
// `displayName === name || id === name` - a collision here would make
|
|
232
|
+
// one of the two models unreachable by name with no error anywhere.
|
|
233
|
+
const conflict = deps
|
|
234
|
+
.getCatalog()
|
|
235
|
+
.find((m) => m.id !== model.id && (m.displayName === displayName || m.id === displayName));
|
|
236
|
+
if (conflict) {
|
|
237
|
+
sendError(res, 409, `another model is already named "${displayName}"`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
updateDisplayName(model.id, displayName);
|
|
241
|
+
// The catalog is kept in memory between requests. Refresh it here so
|
|
242
|
+
// the next inventory request, and all model lookups, see the persisted
|
|
243
|
+
// name immediately instead of reverting to the scan-derived name until
|
|
244
|
+
// the brain is restarted. Reset already follows this pattern below.
|
|
245
|
+
const catalog = deps.rescan();
|
|
246
|
+
const updated = resolveModel(catalog, model.id);
|
|
247
|
+
sendJson(res, { displayName: updated ? updated.displayName : displayName });
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
const handleReset = (req, res, model) => {
|
|
251
|
+
readJsonBody(req, 4096, (result) => {
|
|
252
|
+
if (!result.ok) {
|
|
253
|
+
sendError(res, 400, result.error);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
deleteDisplayName(model.id);
|
|
257
|
+
const catalog = deps.rescan();
|
|
258
|
+
const updated = resolveModel(catalog, model.id);
|
|
259
|
+
sendJson(res, { displayName: updated ? updated.displayName : model.displayName });
|
|
260
|
+
});
|
|
261
|
+
};
|
|
183
262
|
const handleBudget = (res, model, params) => {
|
|
184
263
|
void (async () => {
|
|
185
264
|
try {
|
|
@@ -282,6 +361,56 @@ export function createHostApi(deps) {
|
|
|
282
361
|
command: deps.supervisor.command,
|
|
283
362
|
});
|
|
284
363
|
};
|
|
364
|
+
/**
|
|
365
|
+
* Stream complete status snapshots as SSE.
|
|
366
|
+
*
|
|
367
|
+
* Authentication is the listener's, not this route's: `withAuth` in serve.ts
|
|
368
|
+
* gates every `/__host/*` path with the same token and TLS policy, so an
|
|
369
|
+
* unauthenticated caller never reaches this function.
|
|
370
|
+
*
|
|
371
|
+
* The stream is unidirectional and outlives the request, which is exactly why
|
|
372
|
+
* SSE rather than a socket the brain would have to dial back to a daemon: a
|
|
373
|
+
* remote brain has no idea where its daemon is, and the daemon already knows
|
|
374
|
+
* how to reach the brain over an authenticated HTTP(S) endpoint.
|
|
375
|
+
*/
|
|
376
|
+
const handleEvents = (req, res, publisher) => {
|
|
377
|
+
res.writeHead(200, {
|
|
378
|
+
"content-type": "text/event-stream",
|
|
379
|
+
"cache-control": "no-cache, no-transform",
|
|
380
|
+
connection: "keep-alive",
|
|
381
|
+
// Tells nginx-shaped intermediaries not to buffer, which would defeat the
|
|
382
|
+
// whole point by holding each snapshot until the response ended.
|
|
383
|
+
"x-accel-buffering": "no",
|
|
384
|
+
});
|
|
385
|
+
res.flushHeaders?.();
|
|
386
|
+
const write = (snapshot) => {
|
|
387
|
+
if (res.writableEnded || res.destroyed)
|
|
388
|
+
return;
|
|
389
|
+
res.write(`event: status\ndata: ${JSON.stringify(snapshot)}\n\n`);
|
|
390
|
+
};
|
|
391
|
+
let unsubscribe = () => { };
|
|
392
|
+
const keepalive = setInterval(() => {
|
|
393
|
+
if (res.writableEnded || res.destroyed)
|
|
394
|
+
return;
|
|
395
|
+
res.write(": keepalive\n\n");
|
|
396
|
+
}, SSE_KEEPALIVE_MS);
|
|
397
|
+
keepalive.unref?.();
|
|
398
|
+
const teardown = () => {
|
|
399
|
+
clearInterval(keepalive);
|
|
400
|
+
unsubscribe();
|
|
401
|
+
};
|
|
402
|
+
// The publisher ends the response on host shutdown: an open SSE response is
|
|
403
|
+
// an open connection, and `server.close()` waits for those.
|
|
404
|
+
unsubscribe = publisher.subscribe(write, () => {
|
|
405
|
+
clearInterval(keepalive);
|
|
406
|
+
if (!res.writableEnded && !res.destroyed)
|
|
407
|
+
res.end();
|
|
408
|
+
});
|
|
409
|
+
// Both ends matter: `close` on the request covers a client that walked away,
|
|
410
|
+
// and `close` on the response covers the service shutting the socket down.
|
|
411
|
+
req.on("close", teardown);
|
|
412
|
+
res.on("close", teardown);
|
|
413
|
+
};
|
|
285
414
|
function handleHostApi(req, res) {
|
|
286
415
|
const raw = req.url || "";
|
|
287
416
|
if (!raw.startsWith("/__host/"))
|
|
@@ -294,6 +423,15 @@ export function createHostApi(deps) {
|
|
|
294
423
|
sendJson(res, capabilities());
|
|
295
424
|
return true;
|
|
296
425
|
}
|
|
426
|
+
if (route === "/__host/events" && method === "GET") {
|
|
427
|
+
const publisher = deps.statusEvents;
|
|
428
|
+
if (!publisher?.ready) {
|
|
429
|
+
sendError(res, 404, "this brain does not serve a status event stream");
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
handleEvents(req, res, publisher);
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
297
435
|
if (route === "/__host/logs" && method === "GET") {
|
|
298
436
|
handleLogs(res, params);
|
|
299
437
|
return true;
|
|
@@ -326,6 +464,8 @@ export function createHostApi(deps) {
|
|
|
326
464
|
"/__host/model/budget",
|
|
327
465
|
"/__host/model/load",
|
|
328
466
|
"/__host/model/fields",
|
|
467
|
+
"/__host/model/rename",
|
|
468
|
+
"/__host/model/rename/reset",
|
|
329
469
|
]);
|
|
330
470
|
if (!modelRoutes.has(route))
|
|
331
471
|
return false;
|
|
@@ -359,6 +499,18 @@ export function createHostApi(deps) {
|
|
|
359
499
|
handleLoad(res, model);
|
|
360
500
|
return true;
|
|
361
501
|
}
|
|
502
|
+
if (route === "/__host/model/rename" && method === "POST") {
|
|
503
|
+
if (!guardWrite(res))
|
|
504
|
+
return true;
|
|
505
|
+
handleRename(req, res, model);
|
|
506
|
+
return true;
|
|
507
|
+
}
|
|
508
|
+
if (route === "/__host/model/rename/reset" && method === "POST") {
|
|
509
|
+
if (!guardWrite(res))
|
|
510
|
+
return true;
|
|
511
|
+
handleReset(req, res, model);
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
362
514
|
if (route === "/__host/model" && method === "DELETE") {
|
|
363
515
|
if (!guardWrite(res))
|
|
364
516
|
return true;
|
package/dist/service/router.d.ts
CHANGED
|
@@ -3,7 +3,8 @@ import type { Supervisor } from "./supervisor.js";
|
|
|
3
3
|
import { type RankedModel } from "../ops/results.js";
|
|
4
4
|
import type { GpuInfo, Model } from "../types.js";
|
|
5
5
|
import type { Profile } from "../config/schema.js";
|
|
6
|
-
import type
|
|
6
|
+
import { type HostApi } from "./host-api.js";
|
|
7
|
+
import type { BrainStatusPublisher } from "./status-events.js";
|
|
7
8
|
type Verdict = "ok" | "reasoning-only" | "truncated" | "failed";
|
|
8
9
|
/** A logger sink; only `warn` is used by the router. */
|
|
9
10
|
export interface Logger {
|
|
@@ -70,6 +71,8 @@ interface DescribeOptions {
|
|
|
70
71
|
/** An LM Studio-style model description, an OpenAI model object enriched. */
|
|
71
72
|
export interface ModelEntry {
|
|
72
73
|
id: string;
|
|
74
|
+
/** Brain's editable human-facing name; `id` remains the stable model key. */
|
|
75
|
+
name: string;
|
|
73
76
|
object: "model";
|
|
74
77
|
created: number;
|
|
75
78
|
owned_by: string;
|
|
@@ -80,6 +83,10 @@ export interface ModelEntry {
|
|
|
80
83
|
quantization: string | null;
|
|
81
84
|
state: ModelState;
|
|
82
85
|
max_context_length: number | null;
|
|
86
|
+
/** Whether the model exposes a chat-template reasoning channel. */
|
|
87
|
+
reasoning: boolean;
|
|
88
|
+
/** Optional per-model values accepted by the OpenAI-compatible endpoint. */
|
|
89
|
+
reasoning_efforts?: string[];
|
|
83
90
|
loaded_context_length?: number;
|
|
84
91
|
}
|
|
85
92
|
/**
|
|
@@ -156,13 +163,21 @@ export interface RouterOptions {
|
|
|
156
163
|
*/
|
|
157
164
|
hostApi?: HostApi | null;
|
|
158
165
|
/**
|
|
159
|
-
* Live system telemetry (CPU, RAM
|
|
166
|
+
* Live system telemetry (CPU, RAM and GPU), folded into `/__host/status`
|
|
160
167
|
* ONLY when the caller asks with `?resources=1`. The daemon's liveness probe
|
|
161
|
-
* polls status frequently and must not pay an `nvidia-smi` spawn for it
|
|
168
|
+
* polls status frequently and must not pay an `nvidia-smi` spawn for it. Slot
|
|
169
|
+
* activity is already part of the cheap status; the
|
|
162
170
|
* Brain page's Overview tab opts in.
|
|
163
171
|
*/
|
|
164
172
|
getResources?: (() => Promise<unknown>) | null;
|
|
173
|
+
/**
|
|
174
|
+
* The live status source served at `GET /__host/events`. The router installs
|
|
175
|
+
* its snapshot builder here and notifies it whenever something authoritative
|
|
176
|
+
* moves, so the same assembly answers both the pull and the push and the two
|
|
177
|
+
* can never disagree. Absent means this brain does not advertise events.
|
|
178
|
+
*/
|
|
179
|
+
statusEvents?: BrainStatusPublisher | null;
|
|
165
180
|
}
|
|
166
|
-
export declare function createRouter({ supervisor, telemetry, logger, getCatalog, loadModel, loadRanking, queryGpuInfo, version, getConfig, getEvals, getLockModel, getDefaultModel, applyConfigPatch, getAllowConfigWrite, hostApi, getResources, }: RouterOptions): (req: http.IncomingMessage, res: http.ServerResponse) => void;
|
|
181
|
+
export declare function createRouter({ supervisor, telemetry, logger, getCatalog, loadModel, loadRanking, queryGpuInfo, version, getConfig, getEvals, getLockModel, getDefaultModel, applyConfigPatch, getAllowConfigWrite, hostApi, getResources, statusEvents, }: RouterOptions): (req: http.IncomingMessage, res: http.ServerResponse) => void;
|
|
167
182
|
export {};
|
|
168
183
|
//# sourceMappingURL=router.d.ts.map
|
package/dist/service/router.js
CHANGED
|
@@ -5,6 +5,7 @@ import { slots as sampleSlots } from "../sysmon.js";
|
|
|
5
5
|
import { makeVramFitPredicate, selectCodingModel } from "./model-selector.js";
|
|
6
6
|
import { query as queryGpu } from "../gpu.js";
|
|
7
7
|
import { rankModels } from "../ops/results.js";
|
|
8
|
+
import { HOST_API_VERSION } from "./host-api.js";
|
|
8
9
|
import { errorBody, errorMessage, HOP_BY_HOP, readJsonBody, sendError, sendJson, } from "./http-util.js";
|
|
9
10
|
/**
|
|
10
11
|
* Fronts the supervised llama-server on a stable port.
|
|
@@ -144,8 +145,10 @@ export function describeModel(model, options = {}) {
|
|
|
144
145
|
const { state = "not-loaded", profile = null, createdAt = null } = options;
|
|
145
146
|
const md = model.metadata || {};
|
|
146
147
|
const entry = {
|
|
147
|
-
//
|
|
148
|
-
id
|
|
148
|
+
// Keep the stable model key separate from Brain's editable display name.
|
|
149
|
+
// OpenAI-compatible clients send `id`; Otto uses `name` for presentation.
|
|
150
|
+
id: model.id,
|
|
151
|
+
name: model.displayName,
|
|
149
152
|
object: "model",
|
|
150
153
|
created: Math.floor((createdAt ? createdAt.getTime() : Date.now()) / 1000),
|
|
151
154
|
owned_by: model.publisher || "local",
|
|
@@ -157,7 +160,13 @@ export function describeModel(model, options = {}) {
|
|
|
157
160
|
quantization: model.quant || null,
|
|
158
161
|
state,
|
|
159
162
|
max_context_length: md.contextLength ?? null,
|
|
163
|
+
reasoning: Boolean(md.reasoning ?? model.thinking),
|
|
160
164
|
};
|
|
165
|
+
const reasoningEfforts = md["reasoning_efforts"];
|
|
166
|
+
if (Array.isArray(reasoningEfforts) &&
|
|
167
|
+
reasoningEfforts.every((value) => typeof value === "string")) {
|
|
168
|
+
entry.reasoning_efforts = reasoningEfforts;
|
|
169
|
+
}
|
|
161
170
|
if (state === "loaded" && profile && profile.contextSize) {
|
|
162
171
|
// llama-server splits -c across --parallel slots, so the window a single
|
|
163
172
|
// request actually gets is the total divided by the concurrency.
|
|
@@ -298,6 +307,25 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
298
307
|
}
|
|
299
308
|
res.writeHead(upstreamRes.statusCode ?? 502, outHeaders);
|
|
300
309
|
const isStream = String(upstreamRes.headers["content-type"] || "").includes("event-stream");
|
|
310
|
+
const upstreamResponseFailed = (error) => {
|
|
311
|
+
if (settled)
|
|
312
|
+
return;
|
|
313
|
+
const message = `llama-server response ended unexpectedly: ${error.message}`;
|
|
314
|
+
telemetry.record({
|
|
315
|
+
at: new Date().toISOString(),
|
|
316
|
+
path: req.url,
|
|
317
|
+
verdict: "failed",
|
|
318
|
+
error: message,
|
|
319
|
+
});
|
|
320
|
+
logger?.warn?.(message);
|
|
321
|
+
// Headers may already be on the wire for an SSE response. Destroying
|
|
322
|
+
// it is the only honest result, but `done()` still releases the queue.
|
|
323
|
+
if (!res.writableEnded && !res.destroyed)
|
|
324
|
+
res.destroy(error);
|
|
325
|
+
done();
|
|
326
|
+
};
|
|
327
|
+
upstreamRes.once("aborted", () => upstreamResponseFailed(new Error("upstream response aborted")));
|
|
328
|
+
upstreamRes.once("error", upstreamResponseFailed);
|
|
301
329
|
if (isStream) {
|
|
302
330
|
let sawContent = false;
|
|
303
331
|
let sawReasoning = false;
|
|
@@ -368,6 +396,10 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
368
396
|
sendError(res, 502, `Upstream llama-server error: ${error.message}`);
|
|
369
397
|
done();
|
|
370
398
|
});
|
|
399
|
+
// This is the first authoritative inference-stage signal: the request is
|
|
400
|
+
// being dispatched to llama-server and is waiting for prompt processing or
|
|
401
|
+
// its first output delta.
|
|
402
|
+
reasoning?.begin(streamId);
|
|
371
403
|
upstream.end(body);
|
|
372
404
|
});
|
|
373
405
|
}
|
|
@@ -458,10 +490,15 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
|
|
|
458
490
|
// (rare), so the router caches the ranking and re-reads it at most once per
|
|
459
491
|
// window - the cheap time-based trigger.
|
|
460
492
|
const RANKING_TTL_MS = 60000;
|
|
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, }) {
|
|
493
|
+
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, }) {
|
|
462
494
|
const agent = new http.Agent({ keepAlive: true, maxSockets: 32 });
|
|
463
495
|
const scheduler = loadModel
|
|
464
|
-
? new Scheduler({
|
|
496
|
+
? new Scheduler({
|
|
497
|
+
supervisor,
|
|
498
|
+
loadModel,
|
|
499
|
+
logger: (m) => logger?.warn?.(m),
|
|
500
|
+
onChange: statusEvents ? () => statusEvents.notify() : null,
|
|
501
|
+
})
|
|
465
502
|
: null;
|
|
466
503
|
// A (re)start means whatever produced the current warning no longer applies -
|
|
467
504
|
// either a different model is now resident, or the same one just picked up an
|
|
@@ -547,51 +584,79 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
547
584
|
resolved: lock ? null : resolveModel(name),
|
|
548
585
|
});
|
|
549
586
|
};
|
|
587
|
+
/**
|
|
588
|
+
* The cheap host status: everything `/__host/status` answers except the
|
|
589
|
+
* opt-in `resources` block.
|
|
590
|
+
*
|
|
591
|
+
* One assembly feeds both the pull (`/__host/status`) and the push
|
|
592
|
+
* (`/__host/events`). Keeping them as one function is the point: a field that
|
|
593
|
+
* only the polled answer carried would be a field the rail silently lost the
|
|
594
|
+
* moment a daemon stopped polling.
|
|
595
|
+
*/
|
|
596
|
+
const buildCheapStatus = async () => {
|
|
597
|
+
const schedulerStats = scheduler ? scheduler.stats() : null;
|
|
598
|
+
// Slots come from a loopback GET on the resident llama-server. That is
|
|
599
|
+
// cheap enough to pay on every sample - unlike GPU sampling, which spawns
|
|
600
|
+
// `nvidia-smi` and stays opt-in. Skipped entirely unless a model is
|
|
601
|
+
// resident, since there is nothing listening otherwise.
|
|
602
|
+
const slots = supervisor.state === "ready"
|
|
603
|
+
? await sampleSlots({ host: supervisor.host, port: supervisor.internalPort }).catch(() => null)
|
|
604
|
+
: null;
|
|
605
|
+
return {
|
|
606
|
+
version,
|
|
607
|
+
// Additive, and separate from `version`: the package version says which
|
|
608
|
+
// build this is, this says which generation of the management contract it
|
|
609
|
+
// speaks. A daemon reads this and `capabilities` rather than pinning a
|
|
610
|
+
// package version.
|
|
611
|
+
apiVersion: HOST_API_VERSION,
|
|
612
|
+
...supervisor.status(),
|
|
613
|
+
telemetry: { ...telemetry.totals, warning: telemetry.warning },
|
|
614
|
+
scheduler: schedulerStats,
|
|
615
|
+
recent: telemetry.records.slice(-10),
|
|
616
|
+
logLineCount: supervisor.logLines.length,
|
|
617
|
+
// Carried inline rather than fetched from /__host/capabilities: the
|
|
618
|
+
// daemon reads status constantly, and a separately cached copy would go
|
|
619
|
+
// stale the moment the owner toggles allowRemoteConfig.
|
|
620
|
+
capabilities: hostApi ? hostApi.capabilities() : null,
|
|
621
|
+
// The three signals the Brain rail's icon is derived from. All are cheap
|
|
622
|
+
// enough for the liveness path: `activity` is one stat of a file that is
|
|
623
|
+
// usually absent, `reasoning` is in-process state, and `queued` is
|
|
624
|
+
// already computed above.
|
|
625
|
+
activity: readActivity(),
|
|
626
|
+
reasoning: reasoningTracker.active,
|
|
627
|
+
// Exact aggregate request stages from the proxy lifecycle. Unlike slot
|
|
628
|
+
// phase sampling, this distinguishes silent prompt processing, reasoning
|
|
629
|
+
// deltas and user-visible content even when several requests overlap.
|
|
630
|
+
inference: reasoningTracker.snapshot,
|
|
631
|
+
queued: schedulerStats ? schedulerStats.queued : 0,
|
|
632
|
+
slots,
|
|
633
|
+
};
|
|
634
|
+
};
|
|
635
|
+
// Publish rather than be polled. The publisher decides what counts as a
|
|
636
|
+
// change (see status-events.ts); everything here just says "look again".
|
|
637
|
+
if (statusEvents) {
|
|
638
|
+
statusEvents.setSource(buildCheapStatus);
|
|
639
|
+
supervisor.on("state", () => statusEvents.notify());
|
|
640
|
+
supervisor.on("crashed", () => statusEvents.notify());
|
|
641
|
+
reasoningTracker.onChange(() => statusEvents.notify());
|
|
642
|
+
}
|
|
550
643
|
return function handler(req, res) {
|
|
551
644
|
// Host-management read surface (`/__host/*`): the single API both the TUI and
|
|
552
645
|
// Otto's GUI consume, so the two never drift. Status is live; config and
|
|
553
646
|
// evals are point-in-time reads the daemon proxies to its settings UI.
|
|
554
647
|
const path = (req.url || "").split("?")[0];
|
|
555
648
|
if (path === "/__host/status") {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
...supervisor.status(),
|
|
560
|
-
telemetry: { ...telemetry.totals, warning: telemetry.warning },
|
|
561
|
-
scheduler: schedulerStats,
|
|
562
|
-
recent: telemetry.records.slice(-10),
|
|
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.
|
|
649
|
+
// Resources cost an `nvidia-smi` spawn, so they are opt-in: the daemon
|
|
650
|
+
// reads this route far more often than any UI does, and must not pay for
|
|
651
|
+
// a panel it is not rendering.
|
|
587
652
|
const wantsResources = /[?&]resources=1(&|$)/.test(req.url || "");
|
|
588
653
|
if (!wantsResources || !getResources) {
|
|
589
|
-
void
|
|
654
|
+
void buildCheapStatus().then((base) => sendJson(res, base));
|
|
590
655
|
return;
|
|
591
656
|
}
|
|
592
|
-
Promise.all([
|
|
593
|
-
.then(([
|
|
594
|
-
.catch(() =>
|
|
657
|
+
Promise.all([buildCheapStatus(), getResources().catch(() => null)])
|
|
658
|
+
.then(([base, resources]) => sendJson(res, { ...base, resources }))
|
|
659
|
+
.catch((error) => sendError(res, 500, `could not build the host status: ${errorMessage(error)}`));
|
|
595
660
|
return;
|
|
596
661
|
}
|
|
597
662
|
// Config write: apply an editable patch (model/lock live, the rest persisted).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface BrainRunLog {
|
|
2
|
+
path: string;
|
|
3
|
+
write(line: string): void;
|
|
4
|
+
}
|
|
5
|
+
/** Start a fresh Brain log and prune only expired Brain run logs. */
|
|
6
|
+
export declare function createBrainRunLog(env?: NodeJS.ProcessEnv): BrainRunLog;
|
|
7
|
+
export declare function pruneBrainRunLogs(logsDir: string, now?: number): void;
|
|
8
|
+
//# sourceMappingURL=run-log.d.ts.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Durable, per-service-run diagnostics for Otto Brain. */
|
|
2
|
+
import { appendFileSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { resolveBrainPaths } from "../config/paths.js";
|
|
5
|
+
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
6
|
+
const RUN_LOG_SUFFIX = "-brain.log";
|
|
7
|
+
/** Start a fresh Brain log and prune only expired Brain run logs. */
|
|
8
|
+
export function createBrainRunLog(env = process.env) {
|
|
9
|
+
const { logsDir } = resolveBrainPaths(env);
|
|
10
|
+
const startedAt = new Date();
|
|
11
|
+
const stamp = startedAt
|
|
12
|
+
.toISOString()
|
|
13
|
+
.replace(/[-:]/g, "")
|
|
14
|
+
.replace(/\.\d{3}Z$/, "Z");
|
|
15
|
+
const filePath = path.join(logsDir, `${stamp}-${process.pid}${RUN_LOG_SUFFIX}`);
|
|
16
|
+
try {
|
|
17
|
+
mkdirSync(logsDir, { recursive: true });
|
|
18
|
+
pruneBrainRunLogs(logsDir, startedAt.getTime());
|
|
19
|
+
}
|
|
20
|
+
catch { }
|
|
21
|
+
const write = (line) => {
|
|
22
|
+
try {
|
|
23
|
+
appendFileSync(filePath, `${new Date().toISOString()} ${line}\n`, "utf8");
|
|
24
|
+
}
|
|
25
|
+
catch { }
|
|
26
|
+
};
|
|
27
|
+
write(`Brain service started (pid ${process.pid})`);
|
|
28
|
+
return { path: filePath, write };
|
|
29
|
+
}
|
|
30
|
+
export function pruneBrainRunLogs(logsDir, now = Date.now()) {
|
|
31
|
+
for (const entry of readdirSync(logsDir, { withFileTypes: true })) {
|
|
32
|
+
if (!entry.isFile() || !entry.name.endsWith(RUN_LOG_SUFFIX))
|
|
33
|
+
continue;
|
|
34
|
+
const filePath = path.join(logsDir, entry.name);
|
|
35
|
+
if (now - statSync(filePath).mtimeMs > RETENTION_MS)
|
|
36
|
+
rmSync(filePath, { force: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=run-log.js.map
|
|
@@ -33,6 +33,13 @@ export interface SchedulerOptions {
|
|
|
33
33
|
supervisor: SchedulerSupervisor;
|
|
34
34
|
loadModel: (model: Model) => Promise<void>;
|
|
35
35
|
logger?: ((message: string) => void) | null;
|
|
36
|
+
/**
|
|
37
|
+
* Called whenever the queue depth or the turn changes - i.e. whenever
|
|
38
|
+
* `stats()` would answer differently. The status event stream publishes from
|
|
39
|
+
* this instead of sampling, so "queued behind a model switch" reaches the UI
|
|
40
|
+
* the moment it becomes true rather than up to a poll later.
|
|
41
|
+
*/
|
|
42
|
+
onChange?: (() => void) | null;
|
|
36
43
|
}
|
|
37
44
|
/** A queued completion request bound to a resolved catalog model. */
|
|
38
45
|
export interface QueuedJob {
|
|
@@ -55,7 +62,8 @@ export declare class Scheduler {
|
|
|
55
62
|
queue: QueuedJob[];
|
|
56
63
|
lastTurnId: string | null;
|
|
57
64
|
pumping: boolean;
|
|
58
|
-
|
|
65
|
+
onChange: (() => void) | null;
|
|
66
|
+
constructor({ supervisor, loadModel, logger, onChange }: SchedulerOptions);
|
|
59
67
|
/** Id of the model that is actually loaded and ready, or null. */
|
|
60
68
|
get loadedId(): string | null;
|
|
61
69
|
/** How many requests may run at once against the resident model. */
|
|
@@ -3,10 +3,10 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
|
|
|
3
3
|
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");
|
|
4
4
|
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
5
5
|
};
|
|
6
|
-
var _Scheduler_instances, _Scheduler_take, _Scheduler_serveTurn;
|
|
6
|
+
var _Scheduler_instances, _Scheduler_announce, _Scheduler_take, _Scheduler_serveTurn;
|
|
7
7
|
const MAX_CONCURRENCY = 16;
|
|
8
8
|
export class Scheduler {
|
|
9
|
-
constructor({ supervisor, loadModel, logger = null }) {
|
|
9
|
+
constructor({ supervisor, loadModel, logger = null, onChange = null }) {
|
|
10
10
|
_Scheduler_instances.add(this);
|
|
11
11
|
this.supervisor = supervisor;
|
|
12
12
|
this.loadModel = loadModel; // async (model) => resolves once it is ready
|
|
@@ -14,6 +14,7 @@ export class Scheduler {
|
|
|
14
14
|
this.queue = []; // { modelId, model, run, resolve, reject }
|
|
15
15
|
this.lastTurnId = null;
|
|
16
16
|
this.pumping = false;
|
|
17
|
+
this.onChange = onChange;
|
|
17
18
|
}
|
|
18
19
|
/** Id of the model that is actually loaded and ready, or null. */
|
|
19
20
|
get loadedId() {
|
|
@@ -34,6 +35,7 @@ export class Scheduler {
|
|
|
34
35
|
submit(model, run) {
|
|
35
36
|
return new Promise((resolve, reject) => {
|
|
36
37
|
this.queue.push({ modelId: model.id, model, run, resolve, reject });
|
|
38
|
+
__classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_announce).call(this);
|
|
37
39
|
queueMicrotask(() => this.pump());
|
|
38
40
|
});
|
|
39
41
|
}
|
|
@@ -61,6 +63,7 @@ export class Scheduler {
|
|
|
61
63
|
}
|
|
62
64
|
}
|
|
63
65
|
this.lastTurnId = turnId;
|
|
66
|
+
__classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_announce).call(this);
|
|
64
67
|
await __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_serveTurn).call(this, turnId);
|
|
65
68
|
}
|
|
66
69
|
}
|
|
@@ -78,12 +81,21 @@ export class Scheduler {
|
|
|
78
81
|
return { queued: this.queue.length, waiting, lastTurn: this.lastTurnId };
|
|
79
82
|
}
|
|
80
83
|
}
|
|
81
|
-
_Scheduler_instances = new WeakSet(),
|
|
84
|
+
_Scheduler_instances = new WeakSet(), _Scheduler_announce = function _Scheduler_announce() {
|
|
85
|
+
try {
|
|
86
|
+
this.onChange?.();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Status reporting is not allowed to fail a queued completion.
|
|
90
|
+
}
|
|
91
|
+
}, _Scheduler_take = function _Scheduler_take(pred) {
|
|
82
92
|
const kept = [];
|
|
83
93
|
const taken = [];
|
|
84
94
|
for (const job of this.queue)
|
|
85
95
|
(pred(job) ? taken : kept).push(job);
|
|
86
96
|
this.queue = kept;
|
|
97
|
+
if (taken.length > 0)
|
|
98
|
+
__classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_announce).call(this);
|
|
87
99
|
return taken;
|
|
88
100
|
}, _Scheduler_serveTurn =
|
|
89
101
|
/** Serve one model's snapshot with bounded concurrency. */
|
package/dist/service/serve.d.ts
CHANGED
|
@@ -37,7 +37,8 @@ export interface ServiceHandle {
|
|
|
37
37
|
supervisor: Supervisor;
|
|
38
38
|
host: string;
|
|
39
39
|
port: number;
|
|
40
|
-
model
|
|
40
|
+
/** The model loaded during startup, if one was available. */
|
|
41
|
+
model: Model | null;
|
|
41
42
|
/** Whether the listener terminates TLS (config.tls.mode !== "off"). */
|
|
42
43
|
secure: boolean;
|
|
43
44
|
/** The address to show a user: the MagicDNS/cert hostname when TLS is on, else the bind host. */
|