@otto-code/brain 0.8.10 → 0.8.13
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/bench.js +2 -2
- package/dist/commands/calibrate.js +11 -2
- 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 +2 -2
- package/dist/config/index.js +2 -2
- package/dist/config/profile-edit.d.ts +88 -1
- package/dist/config/profile-edit.js +294 -43
- package/dist/config/profiles.d.ts +19 -3
- package/dist/config/profiles.js +52 -4
- package/dist/config/schema.d.ts +616 -0
- package/dist/config/schema.js +65 -3
- 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/archive.d.ts +14 -1
- package/dist/ops/archive.js +9 -5
- 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 +77 -11
- package/dist/ops/results.js +84 -14
- 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 +28 -4
- package/dist/service/host-api.js +109 -28
- package/dist/service/log-format.d.ts +18 -0
- package/dist/service/log-format.js +32 -0
- package/dist/service/process-pool.d.ts +45 -0
- package/dist/service/process-pool.js +271 -0
- package/dist/service/router.d.ts +74 -3
- package/dist/service/router.js +277 -51
- package/dist/service/run-log.d.ts +6 -1
- package/dist/service/run-log.js +46 -4
- package/dist/service/scheduler.d.ts +250 -31
- package/dist/service/scheduler.js +408 -63
- package/dist/service/serve.d.ts +4 -0
- package/dist/service/serve.js +376 -142
- package/dist/service/status-events.d.ts +14 -1
- package/dist/service/status-events.js +112 -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 +83 -26
- 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.
|
|
@@ -201,30 +205,39 @@ function resolveCatalog(getCatalog) {
|
|
|
201
205
|
* the supervisor is running marked 'loaded'. Falls back to just the running
|
|
202
206
|
* model when no catalog provider is wired in.
|
|
203
207
|
*/
|
|
204
|
-
export function buildModelList(supervisor, getCatalog) {
|
|
205
|
-
const
|
|
208
|
+
export function buildModelList(supervisor, getCatalog, scheduler = null) {
|
|
209
|
+
const supervisors = scheduler && typeof scheduler.supervisors === "function"
|
|
210
|
+
? scheduler.supervisors()
|
|
211
|
+
: [supervisor];
|
|
212
|
+
const residentFor = (modelId) => supervisors.find((candidate) => candidate.model?.id === modelId && candidate.state !== "stopped") ?? null;
|
|
206
213
|
const stateOf = (model) => {
|
|
207
|
-
|
|
214
|
+
const resident = residentFor(model.id);
|
|
215
|
+
if (!resident)
|
|
208
216
|
return "not-loaded";
|
|
209
|
-
if (
|
|
217
|
+
if (resident.state === "ready")
|
|
210
218
|
return "loaded";
|
|
211
|
-
if (
|
|
219
|
+
if (resident.state === "starting")
|
|
212
220
|
return "loading";
|
|
213
221
|
return "not-loaded";
|
|
214
222
|
};
|
|
215
223
|
let catalog = resolveCatalog(getCatalog);
|
|
216
224
|
// Guarantee the running model appears even if the snapshot predates it.
|
|
217
|
-
|
|
218
|
-
catalog
|
|
225
|
+
for (const resident of supervisors) {
|
|
226
|
+
if (resident.model && !catalog.some((model) => model.id === resident.model?.id)) {
|
|
227
|
+
catalog.unshift(resident.model);
|
|
228
|
+
}
|
|
219
229
|
}
|
|
220
|
-
return catalog.map((model) =>
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
230
|
+
return catalog.map((model) => {
|
|
231
|
+
const resident = residentFor(model.id);
|
|
232
|
+
return describeModel(model, {
|
|
233
|
+
state: stateOf(model),
|
|
234
|
+
profile: resident?.profile ?? null,
|
|
235
|
+
createdAt: resident?.startedAt ?? null,
|
|
236
|
+
});
|
|
237
|
+
});
|
|
225
238
|
}
|
|
226
239
|
/** Handle the model-discovery endpoints ourselves; returns true if it did. */
|
|
227
|
-
function handleModelsRoute(req, res, supervisor, getCatalog) {
|
|
240
|
+
function handleModelsRoute(req, res, supervisor, getCatalog, scheduler = null) {
|
|
228
241
|
if (req.method !== "GET")
|
|
229
242
|
return false;
|
|
230
243
|
const url = (req.url || "").split("?")[0];
|
|
@@ -234,7 +247,7 @@ function handleModelsRoute(req, res, supervisor, getCatalog) {
|
|
|
234
247
|
: null;
|
|
235
248
|
if (!isList && single === null)
|
|
236
249
|
return false;
|
|
237
|
-
const list = buildModelList(supervisor, getCatalog);
|
|
250
|
+
const list = buildModelList(supervisor, getCatalog, scheduler);
|
|
238
251
|
let payload;
|
|
239
252
|
if (single !== null) {
|
|
240
253
|
const entry = list.find((e) => e.id === single);
|
|
@@ -277,13 +290,152 @@ function nextStreamId() {
|
|
|
277
290
|
* bench command builds its own throwaway router and simply never reads it.
|
|
278
291
|
*/
|
|
279
292
|
const reasoningTracker = new ReasoningTracker();
|
|
293
|
+
/**
|
|
294
|
+
* Map an OpenAI-compatible effort request onto a model's own chat-template
|
|
295
|
+
* arguments. llama.cpp does not know every model's dialect: Qwen3.8 calls the
|
|
296
|
+
* controls `enable_thinking` and `reasoning_effort`, for example. Only catalog
|
|
297
|
+
* entries that declare these names are rewritten, so generic models and GPT-OSS
|
|
298
|
+
* keep their existing server-native request handling.
|
|
299
|
+
*/
|
|
300
|
+
export function applyModelReasoningTemplate(body, model) {
|
|
301
|
+
const template = model.reasoningTemplate;
|
|
302
|
+
const advertised = model.reasoningEfforts;
|
|
303
|
+
if (!template || !Array.isArray(advertised))
|
|
304
|
+
return body;
|
|
305
|
+
let parsed;
|
|
306
|
+
try {
|
|
307
|
+
parsed = JSON.parse(body.toString("utf8"));
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return body;
|
|
311
|
+
}
|
|
312
|
+
if (!isRecord(parsed) || typeof parsed.reasoning_effort !== "string")
|
|
313
|
+
return body;
|
|
314
|
+
const requested = parsed.reasoning_effort.toLowerCase();
|
|
315
|
+
const knownEfforts = new Set(advertised.map((value) => value.toLowerCase()));
|
|
316
|
+
const isDisabled = requested === "off" || requested === "none";
|
|
317
|
+
const isEnabled = requested === "on" || knownEfforts.has(requested);
|
|
318
|
+
if (!isDisabled && !isEnabled)
|
|
319
|
+
return body;
|
|
320
|
+
const suppliedKwargs = parsed.chat_template_kwargs;
|
|
321
|
+
if (suppliedKwargs !== undefined && !isRecord(suppliedKwargs))
|
|
322
|
+
return body;
|
|
323
|
+
const templateKwargs = { ...suppliedKwargs };
|
|
324
|
+
templateKwargs[template.enableThinkingArgument] = !isDisabled;
|
|
325
|
+
if (knownEfforts.has(requested)) {
|
|
326
|
+
templateKwargs[template.effortArgument] = requested;
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
// The generic On selection means the model's native default, not a stale
|
|
330
|
+
// explicit effort that happened to be supplied by a previous client.
|
|
331
|
+
delete templateKwargs[template.effortArgument];
|
|
332
|
+
}
|
|
333
|
+
const { reasoning_effort: _reasoningEffort, ...withoutReasoningEffort } = parsed;
|
|
334
|
+
return Buffer.from(JSON.stringify({ ...withoutReasoningEffort, chat_template_kwargs: templateKwargs }), "utf8");
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Pin the request to one llama-server slot by adding the engine's own
|
|
338
|
+
* `id_slot` field to the request body (host API v3).
|
|
339
|
+
*
|
|
340
|
+
* Why pin instead of guess: the OpenAI-compatible stream chunks never carry the
|
|
341
|
+
* slot id, so without a pin the router can only correlate a request to a slot
|
|
342
|
+
* by elimination, and with several concurrent requests that guess is exactly
|
|
343
|
+
* the lie the Overview panel used to tell. llama-server honors the pin on every
|
|
344
|
+
* completion endpoint: if the named slot is free the task lands there, and if
|
|
345
|
+
* it is busy the engine DEFERS the task internally - it never reassigns the
|
|
346
|
+
* task elsewhere and never fails the request - so the slot this router names is
|
|
347
|
+
* always the slot the request ends up on (possibly after waiting on it).
|
|
348
|
+
*
|
|
349
|
+
* `null` returns the body untouched: no pin, and therefore no join data for
|
|
350
|
+
* this request, which is the same degraded state as an older brain. A body this
|
|
351
|
+
* cannot parse is forwarded exactly as-is - an unfamiliar request must reach
|
|
352
|
+
* llama-server and get llama-server's own answer, not a 400 invented here.
|
|
353
|
+
*/
|
|
354
|
+
export function pinSlot(body, slotId) {
|
|
355
|
+
if (slotId === null)
|
|
356
|
+
return body;
|
|
357
|
+
let parsed;
|
|
358
|
+
try {
|
|
359
|
+
parsed = JSON.parse(body.toString("utf8"));
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return body;
|
|
363
|
+
}
|
|
364
|
+
if (!isRecord(parsed))
|
|
365
|
+
return body;
|
|
366
|
+
parsed.id_slot = slotId;
|
|
367
|
+
return Buffer.from(JSON.stringify(parsed), "utf8");
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Wipe one llama-server slot's retained KV state, and RESOLVE only once the
|
|
371
|
+
* engine has acknowledged the wipe.
|
|
372
|
+
*
|
|
373
|
+
* This is the engine-side half of the scheduler's OWNERSHIP fix. The engine
|
|
374
|
+
* never clears a released slot's prompt, so a slot handed to a different chat
|
|
375
|
+
* would keep the previous chat's KV and bleed its topics into the new chat's
|
|
376
|
+
* thinking. The router erases the slot the moment the scheduler hands it off;
|
|
377
|
+
* the engine's task queue runs in arrival order, so resolving on the
|
|
378
|
+
* acknowledgment is what guarantees the clean state sits in the queue ahead of
|
|
379
|
+
* the completion the scheduler posts right after.
|
|
380
|
+
*
|
|
381
|
+
* The route is `POST /slots?action=erase&id_slot=N` - llama.cpp's own slot
|
|
382
|
+
* action. It answers 200 `{id, id_slot, n_erased}` on success and a
|
|
383
|
+
* `NOT_SUPPORTED` error when the server was not launched with a slot-save path;
|
|
384
|
+
* either way this resolves (never rejects), because an erase that cannot be
|
|
385
|
+
* performed degrades to the old behavior rather than failing the completion.
|
|
386
|
+
*
|
|
387
|
+
* NOTE: `action` and `id_slot` MUST travel in the query string, not the JSON
|
|
388
|
+
* body. llama-server's `POST /slots` handler reads both via `req.get_param()`,
|
|
389
|
+
* which is built only from query + path params (b10441 tools/server/server-http.cpp,
|
|
390
|
+
* `server_http_req::params` = "path_params + query_params"; the body is a
|
|
391
|
+
* separate field the handler never parses for this route). A body-only request
|
|
392
|
+
* reaches `std::stoi("")` and answers 400 "Invalid slot ID" - the erase then
|
|
393
|
+
* silently no-ops and the bleed survives. The body must stay empty for the
|
|
394
|
+
* same reason `handle_slots_erase` ignores it entirely.
|
|
395
|
+
*/
|
|
396
|
+
export function eraseSlot(host, port, slotId) {
|
|
397
|
+
return new Promise((resolve) => {
|
|
398
|
+
const req = http.request({
|
|
399
|
+
host,
|
|
400
|
+
port,
|
|
401
|
+
path: `/slots?action=erase&id_slot=${slotId}`,
|
|
402
|
+
method: "POST",
|
|
403
|
+
timeout: 3000,
|
|
404
|
+
}, (res) => {
|
|
405
|
+
res.resume();
|
|
406
|
+
res.on("end", () => resolve());
|
|
407
|
+
});
|
|
408
|
+
req.on("timeout", () => req.destroy());
|
|
409
|
+
req.on("error", () => resolve());
|
|
410
|
+
req.end();
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* The eraser the scheduler needs, bound to one engine endpoint. Extracted so
|
|
415
|
+
* the router (which builds its own scheduler) and the service (which builds a
|
|
416
|
+
* shared one and passes it in) hand the scheduler the SAME transport rather
|
|
417
|
+
* than each spelling the request.
|
|
418
|
+
*/
|
|
419
|
+
export function createSlotEraser(host, port) {
|
|
420
|
+
return (slotId) => eraseSlot(host, port, slotId);
|
|
421
|
+
}
|
|
280
422
|
/**
|
|
281
423
|
* Forward a buffered completion body to the resident llama-server and stream the
|
|
282
424
|
* reply back, teeing non-streaming bodies for classification. Resolves once the
|
|
283
425
|
* client response is fully concluded (it owns the response in every outcome,
|
|
284
426
|
* including upstream errors), so the scheduler can move to the next turn.
|
|
427
|
+
*
|
|
428
|
+
* The slot this request is pinned to arrives as `options.slot`, named by the
|
|
429
|
+
* scheduler at the moment of admission (see `Scheduler.onSlotFree`). That is
|
|
430
|
+
* the only race-free place to choose it: by then the model is resident (slot
|
|
431
|
+
* ids do not survive a model switch), the admission sample has just counted
|
|
432
|
+
* the slot free, and every sibling admitted in the same pass has been named a
|
|
433
|
+
* DISTINCT id. `null` means the engine could not be sampled or reported no
|
|
434
|
+
* per-slot rows - the request then runs unpinned with no join data, exactly
|
|
435
|
+
* the degraded state of an older brain.
|
|
285
436
|
*/
|
|
286
|
-
function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, reasoning = null, }) {
|
|
437
|
+
function proxyBuffered({ agent, model, supervisor, telemetry, logger, req, res, body, reasoning = null, slot, }) {
|
|
438
|
+
const slotId = slot ?? null;
|
|
287
439
|
return new Promise((resolve) => {
|
|
288
440
|
let settled = false;
|
|
289
441
|
const streamId = nextStreamId();
|
|
@@ -300,7 +452,8 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
300
452
|
// Injected here, not at queue time: the scheduler may switch models between
|
|
301
453
|
// buffering and dispatch, and the addendum belongs to whichever model ends
|
|
302
454
|
// up resident, which is the one `supervisor.profile` now describes.
|
|
303
|
-
const
|
|
455
|
+
const withSystemAddendum = injectSystemAddendum(body, supervisor.profile?.chatSystemAddendum ?? null, completionShape(req.url));
|
|
456
|
+
const outbound = pinSlot(applyModelReasoningTemplate(withSystemAddendum, model), slotId);
|
|
304
457
|
const headers = {};
|
|
305
458
|
for (const [name, value] of Object.entries(req.headers)) {
|
|
306
459
|
if (!HOP_BY_HOP.has(name.toLowerCase()))
|
|
@@ -309,6 +462,7 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
309
462
|
headers.host = `${supervisor.host}:${supervisor.internalPort}`;
|
|
310
463
|
headers["content-length"] = Buffer.byteLength(outbound);
|
|
311
464
|
const started = Date.now();
|
|
465
|
+
logger?.info?.(`dispatching ${req.method ?? "POST"} ${req.url ?? "/v1/chat/completions"} to ${model.displayName}`);
|
|
312
466
|
const upstream = http.request({
|
|
313
467
|
host: supervisor.host,
|
|
314
468
|
port: supervisor.internalPort,
|
|
@@ -362,6 +516,7 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
362
516
|
streamed: true,
|
|
363
517
|
verdict: !sawContent && sawReasoning ? "reasoning-only" : "ok",
|
|
364
518
|
});
|
|
519
|
+
logger?.info?.(`completed streamed ${req.method ?? "POST"} ${req.url ?? "/v1/chat/completions"} for ${model.displayName}`);
|
|
365
520
|
done();
|
|
366
521
|
});
|
|
367
522
|
upstreamRes.pipe(res);
|
|
@@ -389,6 +544,7 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
389
544
|
...(analysis ?? { verdict: "ok" }),
|
|
390
545
|
};
|
|
391
546
|
telemetry.record(entry);
|
|
547
|
+
logger?.info?.(`completed ${req.method ?? "POST"} ${req.url ?? "/v1/chat/completions"} for ${model.displayName} in ${entry.ms}ms (${entry.verdict})`);
|
|
392
548
|
if (entry.verdict === "reasoning-only") {
|
|
393
549
|
logger?.warn?.(`reasoning-only response: ${entry.outputTokens} tokens, ${entry.reasoningChars} reasoning chars, 0 content`);
|
|
394
550
|
}
|
|
@@ -398,6 +554,12 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
398
554
|
res.on("close", () => {
|
|
399
555
|
if (!res.writableFinished && !upstream.destroyed)
|
|
400
556
|
upstream.destroy();
|
|
557
|
+
// The client is gone - interrupt, chat switch, socket death. Destroying
|
|
558
|
+
// the upstream request does not reliably fire "aborted"/"error" on the
|
|
559
|
+
// already-open response stream, so this is the authoritative release:
|
|
560
|
+
// without it a departed client's job never resolves and pins its model's
|
|
561
|
+
// slot forever, wedging the whole queue behind it (needs a reboot).
|
|
562
|
+
done();
|
|
401
563
|
});
|
|
402
564
|
res.on("error", () => {
|
|
403
565
|
if (!upstream.destroyed)
|
|
@@ -410,13 +572,21 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
410
572
|
verdict: "failed",
|
|
411
573
|
error: error.message,
|
|
412
574
|
});
|
|
413
|
-
|
|
575
|
+
logger?.warn(`request to ${model.displayName} failed: ${error.message}`);
|
|
576
|
+
// The client may already be gone; writing a 502 into a dead socket
|
|
577
|
+
// throws and would kill this handler before done() releases the slot.
|
|
578
|
+
if (!res.writableEnded && !res.destroyed) {
|
|
579
|
+
sendError(res, 502, `Upstream llama-server error: ${error.message}`);
|
|
580
|
+
}
|
|
414
581
|
done();
|
|
415
582
|
});
|
|
416
583
|
// This is the first authoritative inference-stage signal: the request is
|
|
417
584
|
// being dispatched to llama-server and is waiting for prompt processing or
|
|
418
|
-
// its first output delta.
|
|
585
|
+
// its first output delta. The slot association is set once here, at the
|
|
586
|
+
// same moment, so `observe` stays free of any per-chunk slot work.
|
|
419
587
|
reasoning?.begin(streamId);
|
|
588
|
+
if (slotId !== null)
|
|
589
|
+
reasoning?.setSlot(streamId, slotId);
|
|
420
590
|
upstream.end(outbound);
|
|
421
591
|
});
|
|
422
592
|
}
|
|
@@ -497,19 +667,24 @@ export function injectSystemAddendum(body, addendum, shape) {
|
|
|
497
667
|
* a switch; an unnamed request rides the pin.
|
|
498
668
|
*/
|
|
499
669
|
export function decideModelGate(params) {
|
|
500
|
-
const { lockModel, requestedName, pinned, resolved } = params;
|
|
670
|
+
const { lockModel, requestedName, pinned, pinnedModels = pinned ? [pinned] : [], resolved, } = params;
|
|
501
671
|
if (lockModel) {
|
|
502
|
-
if (
|
|
503
|
-
return { ok: false, status: 503, message: "no
|
|
672
|
+
if (pinnedModels.length === 0) {
|
|
673
|
+
return { ok: false, status: 503, message: "no models are selected on this locked host" };
|
|
504
674
|
}
|
|
505
|
-
|
|
675
|
+
const selected = requestedName
|
|
676
|
+
? pinnedModels.find((model) => model.id === requestedName || model.displayName === requestedName)
|
|
677
|
+
: pinnedModels[0];
|
|
678
|
+
if (!selected) {
|
|
506
679
|
return {
|
|
507
680
|
ok: false,
|
|
508
681
|
status: 409,
|
|
509
|
-
message: `model switching is disabled on this host;
|
|
682
|
+
message: `model switching is disabled on this host; served models: ${pinnedModels
|
|
683
|
+
.map((model) => `"${model.displayName}"`)
|
|
684
|
+
.join(", ")}`,
|
|
510
685
|
};
|
|
511
686
|
}
|
|
512
|
-
return { ok: true, model:
|
|
687
|
+
return { ok: true, model: selected };
|
|
513
688
|
}
|
|
514
689
|
if (!resolved) {
|
|
515
690
|
return {
|
|
@@ -523,7 +698,7 @@ export function decideModelGate(params) {
|
|
|
523
698
|
return { ok: true, model: resolved };
|
|
524
699
|
}
|
|
525
700
|
/** Buffer a completion request, resolve its target model, and queue it. */
|
|
526
|
-
function scheduleCompletion({ req, res, agent,
|
|
701
|
+
function scheduleCompletion({ req, res, agent, telemetry, logger, scheduler, modelGate, }) {
|
|
527
702
|
const chunks = [];
|
|
528
703
|
let size = 0;
|
|
529
704
|
let tooBig = false;
|
|
@@ -544,31 +719,52 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
|
|
|
544
719
|
}
|
|
545
720
|
const body = Buffer.concat(chunks);
|
|
546
721
|
let modelName = null;
|
|
722
|
+
let session = null;
|
|
547
723
|
try {
|
|
548
724
|
const parsed = JSON.parse(body.toString("utf8"));
|
|
549
|
-
|
|
725
|
+
if (isRecord(parsed)) {
|
|
726
|
+
modelName = typeof parsed.model === "string" ? parsed.model : null;
|
|
727
|
+
// The standard prompt_cache_key is the chat's stable identity: Otto's
|
|
728
|
+
// provider sends its session id there, and a third-party client that
|
|
729
|
+
// uses it for prompt caching sends one too. Brain reads it for
|
|
730
|
+
// session-affine scheduling; the OpenAI-compatible contract is
|
|
731
|
+
// untouched either way.
|
|
732
|
+
session =
|
|
733
|
+
typeof parsed.prompt_cache_key === "string" && parsed.prompt_cache_key.length > 0
|
|
734
|
+
? parsed.prompt_cache_key
|
|
735
|
+
: null;
|
|
736
|
+
}
|
|
550
737
|
}
|
|
551
738
|
catch {
|
|
552
739
|
/* leave null */
|
|
553
740
|
}
|
|
554
741
|
const gate = modelGate(modelName);
|
|
555
742
|
if (!gate.ok) {
|
|
743
|
+
logger?.warn(`refused ${req.method ?? "POST"} ${req.url ?? "completion"}: ${gate.message}`);
|
|
556
744
|
sendError(res, gate.status, gate.message);
|
|
557
745
|
return;
|
|
558
746
|
}
|
|
559
747
|
const model = gate.model;
|
|
560
|
-
scheduler
|
|
561
|
-
|
|
748
|
+
// The slot is named by the scheduler at admission time (one distinct id per
|
|
749
|
+
// job, drawn from the pass's own free-slot sample). It is not sampled here
|
|
750
|
+
// at queue time - that could name a slot of an engine about to be
|
|
751
|
+
// relaunched - and not at dispatch time either, where a sibling admitted in
|
|
752
|
+
// the same pass could see the same slot free and pin it too.
|
|
753
|
+
let slot = null;
|
|
754
|
+
const queued = scheduler.submit(model, (resident) => proxyBuffered({
|
|
562
755
|
agent,
|
|
563
|
-
|
|
756
|
+
model,
|
|
757
|
+
supervisor: resident,
|
|
564
758
|
telemetry,
|
|
565
759
|
logger,
|
|
566
760
|
req,
|
|
567
761
|
res,
|
|
568
762
|
body,
|
|
569
763
|
reasoning: reasoningTracker,
|
|
570
|
-
|
|
571
|
-
|
|
764
|
+
slot,
|
|
765
|
+
}), { session, onSlotFree: (id) => (slot = id) });
|
|
766
|
+
logger?.info?.(`queued ${req.method ?? "POST"} ${req.url ?? "completion"} for ${model.displayName}; queue depth ${scheduler.stats().queued}`);
|
|
767
|
+
queued.catch((error) => sendError(res, 502, `could not serve ${model.displayName}: ${errorMessage(error)}`));
|
|
572
768
|
});
|
|
573
769
|
}
|
|
574
770
|
// The bench ranking is read from disk (one JSON per run). A completion request
|
|
@@ -576,29 +772,41 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
|
|
|
576
772
|
// (rare), so the router caches the ranking and re-reads it at most once per
|
|
577
773
|
// window - the cheap time-based trigger.
|
|
578
774
|
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, }) {
|
|
775
|
+
export function createRouter({ supervisor, telemetry, logger, getCatalog = null, loadModel = null, loadRanking = () => rankModels(), queryGpuInfo = queryGpu, version = null, getConfig = null, getEvals = null, getLockModel = () => false, getDefaultModel = () => null, getLockedModels = () => [], applyConfigPatch = null, getAllowConfigWrite = () => false, hostApi = null, getResources = null, statusEvents = null, scheduler: suppliedScheduler = null, }) {
|
|
580
776
|
// llama-server may close an idle response socket while this scheduler holds
|
|
581
777
|
// the next request in queue. A reused keep-alive socket then fails as
|
|
582
778
|
// ECONNRESET ("socket hang up") before the queued request reaches inference.
|
|
583
779
|
// Inference time dwarfs localhost connection setup, so isolate each request
|
|
584
780
|
// instead of letting a second client inherit a stale upstream connection.
|
|
585
781
|
const agent = new http.Agent({ keepAlive: false, maxSockets: 32 });
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
782
|
+
// Erase an engine slot's KV when it is handed to a different chat. The
|
|
783
|
+
// endpoint is the engine's own, on the private port - never on the public
|
|
784
|
+
// one - so a remote client cannot wipe a chat's cache out from under it.
|
|
785
|
+
const eraseSlot = createSlotEraser(supervisor.host, supervisor.internalPort);
|
|
786
|
+
const scheduler = suppliedScheduler ??
|
|
787
|
+
(loadModel
|
|
788
|
+
? new Scheduler({
|
|
789
|
+
supervisor,
|
|
790
|
+
loadModel,
|
|
791
|
+
logger: (m) => logger?.warn?.(m),
|
|
792
|
+
onChange: statusEvents ? () => statusEvents.notify() : null,
|
|
793
|
+
eraseSlot,
|
|
794
|
+
})
|
|
795
|
+
: null);
|
|
594
796
|
// A (re)start means whatever produced the current warning no longer applies -
|
|
595
797
|
// either a different model is now resident, or the same one just picked up an
|
|
596
798
|
// edited profile (e.g. a lowered reasoning budget). Either way the recent
|
|
597
799
|
// window is stale, so start it clean rather than let old records blame a
|
|
598
|
-
// config that is no longer running.
|
|
800
|
+
// config that is no longer running. The scheduler's slot owners go the same
|
|
801
|
+
// way: the engine's slots do not survive the relaunch, so a stale owner entry
|
|
802
|
+
// would make the next admission erase a FRESH slot or mistake a keyless job
|
|
803
|
+
// for one it owns. The model-switch case is cleared by the scheduler itself;
|
|
804
|
+
// this catches the relaunch that keeps the same model resident.
|
|
599
805
|
supervisor.on("state", ({ state }) => {
|
|
600
|
-
if (state === "starting")
|
|
806
|
+
if (state === "starting") {
|
|
601
807
|
telemetry.reset();
|
|
808
|
+
scheduler?.forgetSlots();
|
|
809
|
+
}
|
|
602
810
|
});
|
|
603
811
|
// GPU total VRAM is static hardware, so it is queried once at startup and
|
|
604
812
|
// cached. Absent (no nvidia-smi) or not-yet-resolved leaves the fit predicate
|
|
@@ -657,11 +865,20 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
657
865
|
// The single model this host will serve when switching is locked: the
|
|
658
866
|
// resident model if one is up, else the configured default resolved through
|
|
659
867
|
// the catalog. Null means nothing is loadable yet.
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
const
|
|
664
|
-
|
|
868
|
+
const pinnedModels = () => {
|
|
869
|
+
const configured = getLockedModels();
|
|
870
|
+
const names = configured.length > 0 ? configured : [getDefaultModel()].filter(Boolean);
|
|
871
|
+
const selected = names
|
|
872
|
+
.map((name) => resolveModel(name ?? null))
|
|
873
|
+
.filter((model) => model !== null);
|
|
874
|
+
if (selected.length > 0)
|
|
875
|
+
return selected;
|
|
876
|
+
const residents = scheduler && typeof scheduler.supervisors === "function"
|
|
877
|
+
? scheduler.supervisors()
|
|
878
|
+
: [supervisor];
|
|
879
|
+
return residents
|
|
880
|
+
.map((candidate) => candidate.model)
|
|
881
|
+
.filter((model) => model !== null);
|
|
665
882
|
};
|
|
666
883
|
// Decide whether a completion for `name` may run. The lock/default are read
|
|
667
884
|
// live so a POST /__host/config change takes effect without a restart. Only
|
|
@@ -671,7 +888,8 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
671
888
|
return decideModelGate({
|
|
672
889
|
lockModel: lock,
|
|
673
890
|
requestedName: name,
|
|
674
|
-
pinned:
|
|
891
|
+
pinned: null,
|
|
892
|
+
pinnedModels: lock ? pinnedModels() : [],
|
|
675
893
|
resolved: lock ? null : resolveModel(name),
|
|
676
894
|
});
|
|
677
895
|
};
|
|
@@ -686,6 +904,10 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
686
904
|
*/
|
|
687
905
|
const buildCheapStatus = async () => {
|
|
688
906
|
const schedulerStats = scheduler ? scheduler.stats() : null;
|
|
907
|
+
const residentSupervisors = scheduler && typeof scheduler.supervisors === "function"
|
|
908
|
+
? scheduler.supervisors()
|
|
909
|
+
: [supervisor];
|
|
910
|
+
const residents = residentSupervisors.filter((candidate) => candidate.model !== null && candidate.state !== "stopped");
|
|
689
911
|
// Slots come from a loopback GET on the resident llama-server. That is
|
|
690
912
|
// cheap enough to pay on every sample - unlike GPU sampling, which spawns
|
|
691
913
|
// `nvidia-smi` and stays opt-in. Skipped entirely unless a model is
|
|
@@ -701,6 +923,7 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
701
923
|
// package version.
|
|
702
924
|
apiVersion: HOST_API_VERSION,
|
|
703
925
|
...supervisor.status(),
|
|
926
|
+
residents: residents.map((resident) => resident.status()),
|
|
704
927
|
telemetry: { ...telemetry.totals, warning: telemetry.warning },
|
|
705
928
|
scheduler: schedulerStats,
|
|
706
929
|
recent: telemetry.records.slice(-10),
|
|
@@ -763,8 +986,12 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
763
986
|
sendError(res, 400, result.error);
|
|
764
987
|
return;
|
|
765
988
|
}
|
|
989
|
+
logger?.info?.("received host configuration update");
|
|
766
990
|
applyConfigPatch(result.body)
|
|
767
|
-
.then((cfg) =>
|
|
991
|
+
.then((cfg) => {
|
|
992
|
+
logger?.info?.("applied host configuration update");
|
|
993
|
+
return sendJson(res, cfg);
|
|
994
|
+
})
|
|
768
995
|
.catch((err) => sendError(res, 500, `could not apply config: ${errorMessage(err)}`));
|
|
769
996
|
});
|
|
770
997
|
return;
|
|
@@ -785,7 +1012,7 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
785
1012
|
return;
|
|
786
1013
|
// Answer model discovery ourselves so ids are real names (not paths), the
|
|
787
1014
|
// whole catalog is listed, and each carries LM Studio's context fields.
|
|
788
|
-
if (handleModelsRoute(req, res, supervisor, getCatalog))
|
|
1015
|
+
if (handleModelsRoute(req, res, supervisor, getCatalog, scheduler))
|
|
789
1016
|
return;
|
|
790
1017
|
// With a scheduler wired in, completion requests are queued and served in
|
|
791
1018
|
// turns - including loading/switching to the model they ask for - instead
|
|
@@ -795,7 +1022,6 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
795
1022
|
req,
|
|
796
1023
|
res,
|
|
797
1024
|
agent,
|
|
798
|
-
supervisor,
|
|
799
1025
|
telemetry,
|
|
800
1026
|
logger,
|
|
801
1027
|
scheduler,
|
|
@@ -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 })) {
|