@otto-code/brain 0.8.9 → 0.8.12
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/calibrate.js +9 -0
- package/dist/commands/catalog.d.ts +1 -0
- package/dist/commands/catalog.js +1 -0
- package/dist/commands/pull.d.ts +1 -0
- package/dist/commands/pull.js +12 -3
- package/dist/commands/search.d.ts +1 -0
- package/dist/commands/search.js +12 -2
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +1 -1
- package/dist/config/profile-edit.d.ts +88 -1
- package/dist/config/profile-edit.js +280 -29
- package/dist/config/profiles.js +16 -0
- package/dist/config/schema.d.ts +608 -0
- package/dist/config/schema.js +58 -0
- package/dist/config/store.js +7 -4
- package/dist/gguf.d.ts +7 -0
- package/dist/gguf.js +15 -2
- package/dist/models/download.d.ts +1 -1
- package/dist/models/download.js +2 -2
- package/dist/models/enrich.d.ts +6 -0
- package/dist/models/enrich.js +27 -1
- package/dist/models/index.d.ts +1 -1
- package/dist/models/index.js +4 -3
- package/dist/ops/calibrate.d.ts +38 -3
- package/dist/ops/calibrate.js +68 -19
- package/dist/ops/report.js +51 -1
- package/dist/ops/results.d.ts +57 -11
- package/dist/ops/results.js +75 -10
- package/dist/ops/sweep.d.ts +38 -1
- package/dist/ops/sweep.js +61 -10
- package/dist/runtime/args.d.ts +15 -2
- package/dist/runtime/args.js +60 -5
- package/dist/runtime/managed.js +2 -2
- package/dist/service/activity.d.ts +19 -0
- package/dist/service/activity.js +47 -4
- package/dist/service/host-api.d.ts +25 -4
- package/dist/service/host-api.js +82 -16
- package/dist/service/log-format.d.ts +18 -0
- package/dist/service/log-format.js +32 -0
- package/dist/service/router.d.ts +70 -2
- package/dist/service/router.js +219 -21
- package/dist/service/run-log.d.ts +6 -1
- package/dist/service/run-log.js +46 -4
- package/dist/service/scheduler.d.ts +227 -24
- package/dist/service/scheduler.js +395 -63
- package/dist/service/serve.d.ts +4 -0
- package/dist/service/serve.js +302 -117
- package/dist/service/status-events.d.ts +14 -1
- package/dist/service/status-events.js +111 -12
- package/dist/service/supervisor.d.ts +9 -7
- package/dist/service/supervisor.js +37 -12
- package/dist/sysmon.d.ts +15 -0
- package/dist/sysmon.js +56 -9
- package/dist/tui/app.d.ts +8 -2
- package/dist/tui/app.js +65 -17
- package/dist/types.d.ts +18 -0
- package/dist/vram.d.ts +37 -0
- package/dist/vram.js +57 -18
- package/package.json +1 -1
package/dist/service/router.js
CHANGED
|
@@ -180,6 +180,10 @@ export function describeModel(model, options = {}) {
|
|
|
180
180
|
reasoningEfforts.every((value) => typeof value === "string")) {
|
|
181
181
|
entry.reasoning_efforts = reasoningEfforts;
|
|
182
182
|
}
|
|
183
|
+
const reasoningEffortDefault = md["reasoning_effort_default"] ?? model.reasoningEffortDefault;
|
|
184
|
+
if (typeof reasoningEffortDefault === "string") {
|
|
185
|
+
entry.reasoning_effort_default = reasoningEffortDefault;
|
|
186
|
+
}
|
|
183
187
|
if (state === "loaded" && profile && profile.contextSize) {
|
|
184
188
|
// llama-server splits -c across --parallel slots, so the window a single
|
|
185
189
|
// request actually gets is the total divided by the concurrency.
|
|
@@ -277,13 +281,152 @@ function nextStreamId() {
|
|
|
277
281
|
* bench command builds its own throwaway router and simply never reads it.
|
|
278
282
|
*/
|
|
279
283
|
const reasoningTracker = new ReasoningTracker();
|
|
284
|
+
/**
|
|
285
|
+
* Map an OpenAI-compatible effort request onto a model's own chat-template
|
|
286
|
+
* arguments. llama.cpp does not know every model's dialect: Qwen3.8 calls the
|
|
287
|
+
* controls `enable_thinking` and `reasoning_effort`, for example. Only catalog
|
|
288
|
+
* entries that declare these names are rewritten, so generic models and GPT-OSS
|
|
289
|
+
* keep their existing server-native request handling.
|
|
290
|
+
*/
|
|
291
|
+
export function applyModelReasoningTemplate(body, model) {
|
|
292
|
+
const template = model.reasoningTemplate;
|
|
293
|
+
const advertised = model.reasoningEfforts;
|
|
294
|
+
if (!template || !Array.isArray(advertised))
|
|
295
|
+
return body;
|
|
296
|
+
let parsed;
|
|
297
|
+
try {
|
|
298
|
+
parsed = JSON.parse(body.toString("utf8"));
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return body;
|
|
302
|
+
}
|
|
303
|
+
if (!isRecord(parsed) || typeof parsed.reasoning_effort !== "string")
|
|
304
|
+
return body;
|
|
305
|
+
const requested = parsed.reasoning_effort.toLowerCase();
|
|
306
|
+
const knownEfforts = new Set(advertised.map((value) => value.toLowerCase()));
|
|
307
|
+
const isDisabled = requested === "off" || requested === "none";
|
|
308
|
+
const isEnabled = requested === "on" || knownEfforts.has(requested);
|
|
309
|
+
if (!isDisabled && !isEnabled)
|
|
310
|
+
return body;
|
|
311
|
+
const suppliedKwargs = parsed.chat_template_kwargs;
|
|
312
|
+
if (suppliedKwargs !== undefined && !isRecord(suppliedKwargs))
|
|
313
|
+
return body;
|
|
314
|
+
const templateKwargs = { ...suppliedKwargs };
|
|
315
|
+
templateKwargs[template.enableThinkingArgument] = !isDisabled;
|
|
316
|
+
if (knownEfforts.has(requested)) {
|
|
317
|
+
templateKwargs[template.effortArgument] = requested;
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
// The generic On selection means the model's native default, not a stale
|
|
321
|
+
// explicit effort that happened to be supplied by a previous client.
|
|
322
|
+
delete templateKwargs[template.effortArgument];
|
|
323
|
+
}
|
|
324
|
+
const { reasoning_effort: _reasoningEffort, ...withoutReasoningEffort } = parsed;
|
|
325
|
+
return Buffer.from(JSON.stringify({ ...withoutReasoningEffort, chat_template_kwargs: templateKwargs }), "utf8");
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Pin the request to one llama-server slot by adding the engine's own
|
|
329
|
+
* `id_slot` field to the request body (host API v3).
|
|
330
|
+
*
|
|
331
|
+
* Why pin instead of guess: the OpenAI-compatible stream chunks never carry the
|
|
332
|
+
* slot id, so without a pin the router can only correlate a request to a slot
|
|
333
|
+
* by elimination, and with several concurrent requests that guess is exactly
|
|
334
|
+
* the lie the Overview panel used to tell. llama-server honors the pin on every
|
|
335
|
+
* completion endpoint: if the named slot is free the task lands there, and if
|
|
336
|
+
* it is busy the engine DEFERS the task internally - it never reassigns the
|
|
337
|
+
* task elsewhere and never fails the request - so the slot this router names is
|
|
338
|
+
* always the slot the request ends up on (possibly after waiting on it).
|
|
339
|
+
*
|
|
340
|
+
* `null` returns the body untouched: no pin, and therefore no join data for
|
|
341
|
+
* this request, which is the same degraded state as an older brain. A body this
|
|
342
|
+
* cannot parse is forwarded exactly as-is - an unfamiliar request must reach
|
|
343
|
+
* llama-server and get llama-server's own answer, not a 400 invented here.
|
|
344
|
+
*/
|
|
345
|
+
export function pinSlot(body, slotId) {
|
|
346
|
+
if (slotId === null)
|
|
347
|
+
return body;
|
|
348
|
+
let parsed;
|
|
349
|
+
try {
|
|
350
|
+
parsed = JSON.parse(body.toString("utf8"));
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return body;
|
|
354
|
+
}
|
|
355
|
+
if (!isRecord(parsed))
|
|
356
|
+
return body;
|
|
357
|
+
parsed.id_slot = slotId;
|
|
358
|
+
return Buffer.from(JSON.stringify(parsed), "utf8");
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Wipe one llama-server slot's retained KV state, and RESOLVE only once the
|
|
362
|
+
* engine has acknowledged the wipe.
|
|
363
|
+
*
|
|
364
|
+
* This is the engine-side half of the scheduler's OWNERSHIP fix. The engine
|
|
365
|
+
* never clears a released slot's prompt, so a slot handed to a different chat
|
|
366
|
+
* would keep the previous chat's KV and bleed its topics into the new chat's
|
|
367
|
+
* thinking. The router erases the slot the moment the scheduler hands it off;
|
|
368
|
+
* the engine's task queue runs in arrival order, so resolving on the
|
|
369
|
+
* acknowledgment is what guarantees the clean state sits in the queue ahead of
|
|
370
|
+
* the completion the scheduler posts right after.
|
|
371
|
+
*
|
|
372
|
+
* The route is `POST /slots?action=erase&id_slot=N` - llama.cpp's own slot
|
|
373
|
+
* action. It answers 200 `{id, id_slot, n_erased}` on success and a
|
|
374
|
+
* `NOT_SUPPORTED` error when the server was not launched with a slot-save path;
|
|
375
|
+
* either way this resolves (never rejects), because an erase that cannot be
|
|
376
|
+
* performed degrades to the old behavior rather than failing the completion.
|
|
377
|
+
*
|
|
378
|
+
* NOTE: `action` and `id_slot` MUST travel in the query string, not the JSON
|
|
379
|
+
* body. llama-server's `POST /slots` handler reads both via `req.get_param()`,
|
|
380
|
+
* which is built only from query + path params (b10441 tools/server/server-http.cpp,
|
|
381
|
+
* `server_http_req::params` = "path_params + query_params"; the body is a
|
|
382
|
+
* separate field the handler never parses for this route). A body-only request
|
|
383
|
+
* reaches `std::stoi("")` and answers 400 "Invalid slot ID" - the erase then
|
|
384
|
+
* silently no-ops and the bleed survives. The body must stay empty for the
|
|
385
|
+
* same reason `handle_slots_erase` ignores it entirely.
|
|
386
|
+
*/
|
|
387
|
+
export function eraseSlot(host, port, slotId) {
|
|
388
|
+
return new Promise((resolve) => {
|
|
389
|
+
const req = http.request({
|
|
390
|
+
host,
|
|
391
|
+
port,
|
|
392
|
+
path: `/slots?action=erase&id_slot=${slotId}`,
|
|
393
|
+
method: "POST",
|
|
394
|
+
timeout: 3000,
|
|
395
|
+
}, (res) => {
|
|
396
|
+
res.resume();
|
|
397
|
+
res.on("end", () => resolve());
|
|
398
|
+
});
|
|
399
|
+
req.on("timeout", () => req.destroy());
|
|
400
|
+
req.on("error", () => resolve());
|
|
401
|
+
req.end();
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* The eraser the scheduler needs, bound to one engine endpoint. Extracted so
|
|
406
|
+
* the router (which builds its own scheduler) and the service (which builds a
|
|
407
|
+
* shared one and passes it in) hand the scheduler the SAME transport rather
|
|
408
|
+
* than each spelling the request.
|
|
409
|
+
*/
|
|
410
|
+
export function createSlotEraser(host, port) {
|
|
411
|
+
return (slotId) => eraseSlot(host, port, slotId);
|
|
412
|
+
}
|
|
280
413
|
/**
|
|
281
414
|
* Forward a buffered completion body to the resident llama-server and stream the
|
|
282
415
|
* reply back, teeing non-streaming bodies for classification. Resolves once the
|
|
283
416
|
* client response is fully concluded (it owns the response in every outcome,
|
|
284
417
|
* including upstream errors), so the scheduler can move to the next turn.
|
|
418
|
+
*
|
|
419
|
+
* The slot this request is pinned to arrives as `options.slot`, named by the
|
|
420
|
+
* scheduler at the moment of admission (see `Scheduler.onSlotFree`). That is
|
|
421
|
+
* the only race-free place to choose it: by then the model is resident (slot
|
|
422
|
+
* ids do not survive a model switch), the admission sample has just counted
|
|
423
|
+
* the slot free, and every sibling admitted in the same pass has been named a
|
|
424
|
+
* DISTINCT id. `null` means the engine could not be sampled or reported no
|
|
425
|
+
* per-slot rows - the request then runs unpinned with no join data, exactly
|
|
426
|
+
* the degraded state of an older brain.
|
|
285
427
|
*/
|
|
286
|
-
function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, reasoning = null, }) {
|
|
428
|
+
function proxyBuffered({ agent, model, supervisor, telemetry, logger, req, res, body, reasoning = null, slot, }) {
|
|
429
|
+
const slotId = slot ?? null;
|
|
287
430
|
return new Promise((resolve) => {
|
|
288
431
|
let settled = false;
|
|
289
432
|
const streamId = nextStreamId();
|
|
@@ -300,7 +443,8 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
300
443
|
// Injected here, not at queue time: the scheduler may switch models between
|
|
301
444
|
// buffering and dispatch, and the addendum belongs to whichever model ends
|
|
302
445
|
// up resident, which is the one `supervisor.profile` now describes.
|
|
303
|
-
const
|
|
446
|
+
const withSystemAddendum = injectSystemAddendum(body, supervisor.profile?.chatSystemAddendum ?? null, completionShape(req.url));
|
|
447
|
+
const outbound = pinSlot(applyModelReasoningTemplate(withSystemAddendum, model), slotId);
|
|
304
448
|
const headers = {};
|
|
305
449
|
for (const [name, value] of Object.entries(req.headers)) {
|
|
306
450
|
if (!HOP_BY_HOP.has(name.toLowerCase()))
|
|
@@ -309,6 +453,7 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
309
453
|
headers.host = `${supervisor.host}:${supervisor.internalPort}`;
|
|
310
454
|
headers["content-length"] = Buffer.byteLength(outbound);
|
|
311
455
|
const started = Date.now();
|
|
456
|
+
logger?.info?.(`dispatching ${req.method ?? "POST"} ${req.url ?? "/v1/chat/completions"} to ${model.displayName}`);
|
|
312
457
|
const upstream = http.request({
|
|
313
458
|
host: supervisor.host,
|
|
314
459
|
port: supervisor.internalPort,
|
|
@@ -362,6 +507,7 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
362
507
|
streamed: true,
|
|
363
508
|
verdict: !sawContent && sawReasoning ? "reasoning-only" : "ok",
|
|
364
509
|
});
|
|
510
|
+
logger?.info?.(`completed streamed ${req.method ?? "POST"} ${req.url ?? "/v1/chat/completions"} for ${model.displayName}`);
|
|
365
511
|
done();
|
|
366
512
|
});
|
|
367
513
|
upstreamRes.pipe(res);
|
|
@@ -389,6 +535,7 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
389
535
|
...(analysis ?? { verdict: "ok" }),
|
|
390
536
|
};
|
|
391
537
|
telemetry.record(entry);
|
|
538
|
+
logger?.info?.(`completed ${req.method ?? "POST"} ${req.url ?? "/v1/chat/completions"} for ${model.displayName} in ${entry.ms}ms (${entry.verdict})`);
|
|
392
539
|
if (entry.verdict === "reasoning-only") {
|
|
393
540
|
logger?.warn?.(`reasoning-only response: ${entry.outputTokens} tokens, ${entry.reasoningChars} reasoning chars, 0 content`);
|
|
394
541
|
}
|
|
@@ -398,6 +545,12 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
398
545
|
res.on("close", () => {
|
|
399
546
|
if (!res.writableFinished && !upstream.destroyed)
|
|
400
547
|
upstream.destroy();
|
|
548
|
+
// The client is gone - interrupt, chat switch, socket death. Destroying
|
|
549
|
+
// the upstream request does not reliably fire "aborted"/"error" on the
|
|
550
|
+
// already-open response stream, so this is the authoritative release:
|
|
551
|
+
// without it a departed client's job never resolves and pins its model's
|
|
552
|
+
// slot forever, wedging the whole queue behind it (needs a reboot).
|
|
553
|
+
done();
|
|
401
554
|
});
|
|
402
555
|
res.on("error", () => {
|
|
403
556
|
if (!upstream.destroyed)
|
|
@@ -410,13 +563,21 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
410
563
|
verdict: "failed",
|
|
411
564
|
error: error.message,
|
|
412
565
|
});
|
|
413
|
-
|
|
566
|
+
logger?.warn(`request to ${model.displayName} failed: ${error.message}`);
|
|
567
|
+
// The client may already be gone; writing a 502 into a dead socket
|
|
568
|
+
// throws and would kill this handler before done() releases the slot.
|
|
569
|
+
if (!res.writableEnded && !res.destroyed) {
|
|
570
|
+
sendError(res, 502, `Upstream llama-server error: ${error.message}`);
|
|
571
|
+
}
|
|
414
572
|
done();
|
|
415
573
|
});
|
|
416
574
|
// This is the first authoritative inference-stage signal: the request is
|
|
417
575
|
// being dispatched to llama-server and is waiting for prompt processing or
|
|
418
|
-
// its first output delta.
|
|
576
|
+
// its first output delta. The slot association is set once here, at the
|
|
577
|
+
// same moment, so `observe` stays free of any per-chunk slot work.
|
|
419
578
|
reasoning?.begin(streamId);
|
|
579
|
+
if (slotId !== null)
|
|
580
|
+
reasoning?.setSlot(streamId, slotId);
|
|
420
581
|
upstream.end(outbound);
|
|
421
582
|
});
|
|
422
583
|
}
|
|
@@ -544,22 +705,41 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
|
|
|
544
705
|
}
|
|
545
706
|
const body = Buffer.concat(chunks);
|
|
546
707
|
let modelName = null;
|
|
708
|
+
let session = null;
|
|
547
709
|
try {
|
|
548
710
|
const parsed = JSON.parse(body.toString("utf8"));
|
|
549
|
-
|
|
711
|
+
if (isRecord(parsed)) {
|
|
712
|
+
modelName = typeof parsed.model === "string" ? parsed.model : null;
|
|
713
|
+
// The standard prompt_cache_key is the chat's stable identity: Otto's
|
|
714
|
+
// provider sends its session id there, and a third-party client that
|
|
715
|
+
// uses it for prompt caching sends one too. Brain reads it for
|
|
716
|
+
// session-affine scheduling; the OpenAI-compatible contract is
|
|
717
|
+
// untouched either way.
|
|
718
|
+
session =
|
|
719
|
+
typeof parsed.prompt_cache_key === "string" && parsed.prompt_cache_key.length > 0
|
|
720
|
+
? parsed.prompt_cache_key
|
|
721
|
+
: null;
|
|
722
|
+
}
|
|
550
723
|
}
|
|
551
724
|
catch {
|
|
552
725
|
/* leave null */
|
|
553
726
|
}
|
|
554
727
|
const gate = modelGate(modelName);
|
|
555
728
|
if (!gate.ok) {
|
|
729
|
+
logger?.warn(`refused ${req.method ?? "POST"} ${req.url ?? "completion"}: ${gate.message}`);
|
|
556
730
|
sendError(res, gate.status, gate.message);
|
|
557
731
|
return;
|
|
558
732
|
}
|
|
559
733
|
const model = gate.model;
|
|
560
|
-
scheduler
|
|
561
|
-
|
|
734
|
+
// The slot is named by the scheduler at admission time (one distinct id per
|
|
735
|
+
// job, drawn from the pass's own free-slot sample). It is not sampled here
|
|
736
|
+
// at queue time - that could name a slot of an engine about to be
|
|
737
|
+
// relaunched - and not at dispatch time either, where a sibling admitted in
|
|
738
|
+
// the same pass could see the same slot free and pin it too.
|
|
739
|
+
let slot = null;
|
|
740
|
+
const queued = scheduler.submit(model, () => proxyBuffered({
|
|
562
741
|
agent,
|
|
742
|
+
model,
|
|
563
743
|
supervisor,
|
|
564
744
|
telemetry,
|
|
565
745
|
logger,
|
|
@@ -567,8 +747,10 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
|
|
|
567
747
|
res,
|
|
568
748
|
body,
|
|
569
749
|
reasoning: reasoningTracker,
|
|
570
|
-
|
|
571
|
-
|
|
750
|
+
slot,
|
|
751
|
+
}), { session, onSlotFree: (id) => (slot = id) });
|
|
752
|
+
logger?.info?.(`queued ${req.method ?? "POST"} ${req.url ?? "completion"} for ${model.displayName}; queue depth ${scheduler.stats().queued}`);
|
|
753
|
+
queued.catch((error) => sendError(res, 502, `could not serve ${model.displayName}: ${errorMessage(error)}`));
|
|
572
754
|
});
|
|
573
755
|
}
|
|
574
756
|
// The bench ranking is read from disk (one JSON per run). A completion request
|
|
@@ -576,29 +758,41 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
|
|
|
576
758
|
// (rare), so the router caches the ranking and re-reads it at most once per
|
|
577
759
|
// window - the cheap time-based trigger.
|
|
578
760
|
const RANKING_TTL_MS = 60000;
|
|
579
|
-
export function createRouter({ supervisor, telemetry, logger, getCatalog = null, loadModel = null, loadRanking = () => rankModels(), queryGpuInfo = queryGpu, version = null, getConfig = null, getEvals = null, getLockModel = () => false, getDefaultModel = () => null, applyConfigPatch = null, getAllowConfigWrite = () => false, hostApi = null, getResources = null, statusEvents = null, }) {
|
|
761
|
+
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, scheduler: suppliedScheduler = null, }) {
|
|
580
762
|
// llama-server may close an idle response socket while this scheduler holds
|
|
581
763
|
// the next request in queue. A reused keep-alive socket then fails as
|
|
582
764
|
// ECONNRESET ("socket hang up") before the queued request reaches inference.
|
|
583
765
|
// Inference time dwarfs localhost connection setup, so isolate each request
|
|
584
766
|
// instead of letting a second client inherit a stale upstream connection.
|
|
585
767
|
const agent = new http.Agent({ keepAlive: false, maxSockets: 32 });
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
768
|
+
// Erase an engine slot's KV when it is handed to a different chat. The
|
|
769
|
+
// endpoint is the engine's own, on the private port - never on the public
|
|
770
|
+
// one - so a remote client cannot wipe a chat's cache out from under it.
|
|
771
|
+
const eraseSlot = createSlotEraser(supervisor.host, supervisor.internalPort);
|
|
772
|
+
const scheduler = suppliedScheduler ??
|
|
773
|
+
(loadModel
|
|
774
|
+
? new Scheduler({
|
|
775
|
+
supervisor,
|
|
776
|
+
loadModel,
|
|
777
|
+
logger: (m) => logger?.warn?.(m),
|
|
778
|
+
onChange: statusEvents ? () => statusEvents.notify() : null,
|
|
779
|
+
eraseSlot,
|
|
780
|
+
})
|
|
781
|
+
: null);
|
|
594
782
|
// A (re)start means whatever produced the current warning no longer applies -
|
|
595
783
|
// either a different model is now resident, or the same one just picked up an
|
|
596
784
|
// edited profile (e.g. a lowered reasoning budget). Either way the recent
|
|
597
785
|
// window is stale, so start it clean rather than let old records blame a
|
|
598
|
-
// config that is no longer running.
|
|
786
|
+
// config that is no longer running. The scheduler's slot owners go the same
|
|
787
|
+
// way: the engine's slots do not survive the relaunch, so a stale owner entry
|
|
788
|
+
// would make the next admission erase a FRESH slot or mistake a keyless job
|
|
789
|
+
// for one it owns. The model-switch case is cleared by the scheduler itself;
|
|
790
|
+
// this catches the relaunch that keeps the same model resident.
|
|
599
791
|
supervisor.on("state", ({ state }) => {
|
|
600
|
-
if (state === "starting")
|
|
792
|
+
if (state === "starting") {
|
|
601
793
|
telemetry.reset();
|
|
794
|
+
scheduler?.forgetSlots();
|
|
795
|
+
}
|
|
602
796
|
});
|
|
603
797
|
// GPU total VRAM is static hardware, so it is queried once at startup and
|
|
604
798
|
// cached. Absent (no nvidia-smi) or not-yet-resolved leaves the fit predicate
|
|
@@ -763,8 +957,12 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
763
957
|
sendError(res, 400, result.error);
|
|
764
958
|
return;
|
|
765
959
|
}
|
|
960
|
+
logger?.info?.("received host configuration update");
|
|
766
961
|
applyConfigPatch(result.body)
|
|
767
|
-
.then((cfg) =>
|
|
962
|
+
.then((cfg) => {
|
|
963
|
+
logger?.info?.("applied host configuration update");
|
|
964
|
+
return sendJson(res, cfg);
|
|
965
|
+
})
|
|
768
966
|
.catch((err) => sendError(res, 500, `could not apply config: ${errorMessage(err)}`));
|
|
769
967
|
});
|
|
770
968
|
return;
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export interface BrainRunLog {
|
|
2
2
|
path: string;
|
|
3
|
-
write(line: string):
|
|
3
|
+
write(line: string): string[];
|
|
4
|
+
/** The current Brain-service session's durable tail, including every source. */
|
|
5
|
+
tail(limit: number): {
|
|
6
|
+
lines: string[];
|
|
7
|
+
total: number;
|
|
8
|
+
};
|
|
4
9
|
}
|
|
5
10
|
/** Start a fresh Brain log and prune only expired Brain run logs. */
|
|
6
11
|
export declare function createBrainRunLog(env?: NodeJS.ProcessEnv): BrainRunLog;
|
package/dist/service/run-log.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/** Durable, per-service-run diagnostics for Otto Brain. */
|
|
2
|
-
import { appendFileSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, mkdirSync, openSync, readSync, closeSync, readdirSync, rmSync, statSync, } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { resolveBrainPaths } from "../config/paths.js";
|
|
5
|
+
import { timestampBrainLogLine } from "./log-format.js";
|
|
5
6
|
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
6
7
|
const RUN_LOG_SUFFIX = "-brain.log";
|
|
7
8
|
/** Start a fresh Brain log and prune only expired Brain run logs. */
|
|
@@ -18,14 +19,55 @@ export function createBrainRunLog(env = process.env) {
|
|
|
18
19
|
pruneBrainRunLogs(logsDir, startedAt.getTime());
|
|
19
20
|
}
|
|
20
21
|
catch { }
|
|
22
|
+
let lineCount = 0;
|
|
21
23
|
const write = (line) => {
|
|
24
|
+
const lines = line.split(/\r?\n|\r/u).filter((entry) => entry.length > 0);
|
|
25
|
+
if (lines.length === 0)
|
|
26
|
+
return [];
|
|
22
27
|
try {
|
|
23
|
-
|
|
28
|
+
const timestamp = new Date().toISOString().slice(11, 23);
|
|
29
|
+
const entries = lines.map((entry) => timestampBrainLogLine(timestamp, entry));
|
|
30
|
+
appendFileSync(filePath, `${entries.join("\n")}\n`, "utf8");
|
|
31
|
+
lineCount += lines.length;
|
|
32
|
+
return entries;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
24
36
|
}
|
|
25
|
-
catch { }
|
|
26
37
|
};
|
|
27
38
|
write(`Brain service started (pid ${process.pid})`);
|
|
28
|
-
return {
|
|
39
|
+
return {
|
|
40
|
+
path: filePath,
|
|
41
|
+
write,
|
|
42
|
+
tail(limit) {
|
|
43
|
+
const count = Math.max(1, Math.floor(limit));
|
|
44
|
+
// The Brain log is append-only for the service lifetime. Read only enough
|
|
45
|
+
// of its end for the live viewer, rather than loading an unbounded bench
|
|
46
|
+
// or download log every two-second poll.
|
|
47
|
+
try {
|
|
48
|
+
const size = statSync(filePath).size;
|
|
49
|
+
const bytes = Math.min(size, Math.max(64 * 1024, count * 1024));
|
|
50
|
+
const fd = openSync(filePath, "r");
|
|
51
|
+
try {
|
|
52
|
+
const data = Buffer.alloc(bytes);
|
|
53
|
+
readSync(fd, data, 0, bytes, size - bytes);
|
|
54
|
+
const text = data.toString("utf8");
|
|
55
|
+
const firstNewline = text.indexOf("\n");
|
|
56
|
+
const completeLines = bytes < size && firstNewline !== -1 ? text.slice(firstNewline + 1) : text;
|
|
57
|
+
return {
|
|
58
|
+
lines: completeLines.split(/\r?\n/u).filter(Boolean).slice(-count),
|
|
59
|
+
total: lineCount,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
closeSync(fd);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return { lines: [], total: lineCount };
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
29
71
|
}
|
|
30
72
|
export function pruneBrainRunLogs(logsDir, now = Date.now()) {
|
|
31
73
|
for (const entry of readdirSync(logsDir, { withFileTypes: true })) {
|