@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/activity.js
CHANGED
|
@@ -9,7 +9,7 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (
|
|
|
9
9
|
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
10
10
|
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
11
11
|
};
|
|
12
|
-
var _ReasoningTracker_instances, _ReasoningTracker_requests, _ReasoningTracker_tails, _ReasoningTracker_inlineReasoning, _ReasoningTracker_listeners, _ReasoningTracker_lastSnapshot, _ReasoningTracker_announce;
|
|
12
|
+
var _ReasoningTracker_instances, _ReasoningTracker_requests, _ReasoningTracker_slots, _ReasoningTracker_tails, _ReasoningTracker_inlineReasoning, _ReasoningTracker_listeners, _ReasoningTracker_lastSnapshot, _ReasoningTracker_announce;
|
|
13
13
|
/**
|
|
14
14
|
* What long-running work currently owns the brain, and which stage each live
|
|
15
15
|
* inference request has reached.
|
|
@@ -197,6 +197,12 @@ export class ReasoningTracker {
|
|
|
197
197
|
constructor() {
|
|
198
198
|
_ReasoningTracker_instances.add(this);
|
|
199
199
|
_ReasoningTracker_requests.set(this, new Map());
|
|
200
|
+
/**
|
|
201
|
+
* The llama-server slot a request was pinned to at dispatch, so its proxy-side
|
|
202
|
+
* stage can be attributed to the engine row the panel actually shows. Set once
|
|
203
|
+
* per request (see `setSlot`), never on the per-chunk path.
|
|
204
|
+
*/
|
|
205
|
+
_ReasoningTracker_slots.set(this, new Map());
|
|
200
206
|
/** Tail of the last transport chunk, so a field name split by TCP is still detected. */
|
|
201
207
|
_ReasoningTracker_tails.set(this, new Map());
|
|
202
208
|
/** Models/runtimes that leave reasoning inline as `<think>…</think>`. */
|
|
@@ -222,6 +228,24 @@ export class ReasoningTracker {
|
|
|
222
228
|
__classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "processing");
|
|
223
229
|
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
|
|
224
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* Record the engine slot this request was pinned to. Called exactly once per
|
|
233
|
+
* request, at dispatch - the pin is injected into the outbound body before
|
|
234
|
+
* the request goes out, so the association exists before the first chunk and
|
|
235
|
+
* `observe` never has to learn about it.
|
|
236
|
+
*
|
|
237
|
+
* Idempotent and self-cleaning: a repeat for the same slot is a no-op, and a
|
|
238
|
+
* different slot replaces it, so a request that somehow moves slots (a
|
|
239
|
+
* restarted engine hands a task out again) reports where it is now.
|
|
240
|
+
*/
|
|
241
|
+
setSlot(requestId, slotId) {
|
|
242
|
+
if (!Number.isInteger(slotId) || slotId < 0)
|
|
243
|
+
return;
|
|
244
|
+
if (__classPrivateFieldGet(this, _ReasoningTracker_slots, "f").get(requestId) === slotId)
|
|
245
|
+
return;
|
|
246
|
+
__classPrivateFieldGet(this, _ReasoningTracker_slots, "f").set(requestId, slotId);
|
|
247
|
+
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
|
|
248
|
+
}
|
|
225
249
|
/** Note a chunk of `requestId`'s stream. Cheap enough to call per chunk. */
|
|
226
250
|
observe(requestId, text) {
|
|
227
251
|
const current = __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").get(requestId);
|
|
@@ -261,6 +285,7 @@ export class ReasoningTracker {
|
|
|
261
285
|
/** Forget the request. Must be called on end *and* on error, or the flag sticks. */
|
|
262
286
|
end(requestId) {
|
|
263
287
|
__classPrivateFieldGet(this, _ReasoningTracker_requests, "f").delete(requestId);
|
|
288
|
+
__classPrivateFieldGet(this, _ReasoningTracker_slots, "f").delete(requestId);
|
|
264
289
|
__classPrivateFieldGet(this, _ReasoningTracker_tails, "f").delete(requestId);
|
|
265
290
|
__classPrivateFieldGet(this, _ReasoningTracker_inlineReasoning, "f").delete(requestId);
|
|
266
291
|
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
|
|
@@ -279,14 +304,32 @@ export class ReasoningTracker {
|
|
|
279
304
|
thinking: 0,
|
|
280
305
|
generating: 0,
|
|
281
306
|
};
|
|
282
|
-
|
|
307
|
+
let slotStages;
|
|
308
|
+
for (const [requestId, stage] of __classPrivateFieldGet(this, _ReasoningTracker_requests, "f")) {
|
|
283
309
|
result[stage] += 1;
|
|
310
|
+
const slot = __classPrivateFieldGet(this, _ReasoningTracker_slots, "f").get(requestId);
|
|
311
|
+
if (slot === undefined)
|
|
312
|
+
continue;
|
|
313
|
+
(slotStages ?? (slotStages = {}))[String(slot)] = stage;
|
|
314
|
+
}
|
|
315
|
+
if (slotStages)
|
|
316
|
+
result.slotStages = slotStages;
|
|
284
317
|
return result;
|
|
285
318
|
}
|
|
286
319
|
}
|
|
287
|
-
_ReasoningTracker_requests = new WeakMap(), _ReasoningTracker_tails = new WeakMap(), _ReasoningTracker_inlineReasoning = new WeakMap(), _ReasoningTracker_listeners = new WeakMap(), _ReasoningTracker_lastSnapshot = new WeakMap(), _ReasoningTracker_instances = new WeakSet(), _ReasoningTracker_announce = function _ReasoningTracker_announce() {
|
|
320
|
+
_ReasoningTracker_requests = new WeakMap(), _ReasoningTracker_slots = new WeakMap(), _ReasoningTracker_tails = new WeakMap(), _ReasoningTracker_inlineReasoning = new WeakMap(), _ReasoningTracker_listeners = new WeakMap(), _ReasoningTracker_lastSnapshot = new WeakMap(), _ReasoningTracker_instances = new WeakSet(), _ReasoningTracker_announce = function _ReasoningTracker_announce() {
|
|
288
321
|
const snapshot = this.snapshot;
|
|
289
|
-
|
|
322
|
+
// The slot join rides in the key too: pinning a request to a slot is a
|
|
323
|
+
// state change even when no stage count moves, and it is the field the
|
|
324
|
+
// Overview rows read. The map is bounded by concurrency, so the digest is
|
|
325
|
+
// cheap enough to build on every announce.
|
|
326
|
+
const slotKey = snapshot.slotStages
|
|
327
|
+
? Object.entries(snapshot.slotStages)
|
|
328
|
+
.map(([slot, stage]) => `${slot}:${stage}`)
|
|
329
|
+
.sort()
|
|
330
|
+
.join(",")
|
|
331
|
+
: "";
|
|
332
|
+
const key = `${snapshot.activeRequests}:${snapshot.processing}:${snapshot.thinking}:${snapshot.generating}:${slotKey}`;
|
|
290
333
|
if (key === __classPrivateFieldGet(this, _ReasoningTracker_lastSnapshot, "f"))
|
|
291
334
|
return;
|
|
292
335
|
__classPrivateFieldSet(this, _ReasoningTracker_lastSnapshot, key, "f");
|
|
@@ -26,8 +26,11 @@ import type { RankedModel } from "../ops/results.js";
|
|
|
26
26
|
import type { GpuInfo, Model } from "../types.js";
|
|
27
27
|
import * as vram from "../vram.js";
|
|
28
28
|
import type { SystemSample } from "../sysmon.js";
|
|
29
|
-
import type { BrainStatusPublisher } from "./status-events.js";
|
|
29
|
+
import type { BrainLogPublisher, BrainStatusPublisher } from "./status-events.js";
|
|
30
30
|
import type { Supervisor } from "./supervisor.js";
|
|
31
|
+
import type { Scheduler } from "./scheduler.js";
|
|
32
|
+
import type { BrainRunLog } from "./run-log.js";
|
|
33
|
+
import type { BrainLogArea } from "./log-format.js";
|
|
31
34
|
/**
|
|
32
35
|
* The management API's own version, additive to the capability flags.
|
|
33
36
|
*
|
|
@@ -72,6 +75,8 @@ export interface HostCapabilities {
|
|
|
72
75
|
events: boolean;
|
|
73
76
|
/** Bounded live inference stages, token counts and throughput on status events. */
|
|
74
77
|
liveInference: boolean;
|
|
78
|
+
/** Every completed Brain log line arrives immediately on the SSE stream. */
|
|
79
|
+
logEvents: boolean;
|
|
75
80
|
/** Whether writes are currently permitted (allowRemoteConfig). */
|
|
76
81
|
writable: boolean;
|
|
77
82
|
/** POST/GET /__host/jobs and POST /__host/jobs/cancel. */
|
|
@@ -86,6 +91,8 @@ export interface HostJob {
|
|
|
86
91
|
label: string;
|
|
87
92
|
target: string | null;
|
|
88
93
|
status: "running" | "succeeded" | "failed" | "canceled";
|
|
94
|
+
/** Positive while the shared scheduler has not admitted this operation yet. */
|
|
95
|
+
queuePosition?: number | null;
|
|
89
96
|
percent: number | null;
|
|
90
97
|
message: string | null;
|
|
91
98
|
error: string | null;
|
|
@@ -93,10 +100,15 @@ export interface HostJob {
|
|
|
93
100
|
finishedAt: string | null;
|
|
94
101
|
}
|
|
95
102
|
export interface HostJobRunner {
|
|
96
|
-
start: (kind: HostJob["kind"], target: string | null, args: string[]
|
|
103
|
+
start: (kind: HostJob["kind"], target: string | null, args: string[],
|
|
104
|
+
/** A bundle entry owns its companion-artifact queue, not the whole host. */
|
|
105
|
+
pull?: {
|
|
106
|
+
entryKey: string;
|
|
107
|
+
components: string[];
|
|
108
|
+
}) => HostJob;
|
|
97
109
|
list: () => HostJob[];
|
|
98
110
|
cancel: (jobId: string) => Promise<HostJob[]>;
|
|
99
|
-
query: (args: string[]) => Promise<unknown>;
|
|
111
|
+
query: (args: string[], area?: BrainLogArea) => Promise<unknown>;
|
|
100
112
|
}
|
|
101
113
|
export interface HostApiDeps {
|
|
102
114
|
supervisor: Supervisor;
|
|
@@ -110,6 +122,8 @@ export interface HostApiDeps {
|
|
|
110
122
|
queryGpuInfo: () => Promise<GpuInfo | null>;
|
|
111
123
|
getRanking: () => RankedModel[];
|
|
112
124
|
loadModel: (model: Model) => Promise<void>;
|
|
125
|
+
/** The single model queue shared by completions and resident operations. */
|
|
126
|
+
scheduler?: Scheduler | null;
|
|
113
127
|
/** Mirrors POST /__host/config's gate: may a network caller change things? */
|
|
114
128
|
getAllowWrite: () => boolean;
|
|
115
129
|
/** The managed models directory, for disk accounting. Null when unresolvable. */
|
|
@@ -121,10 +135,16 @@ export interface HostApiDeps {
|
|
|
121
135
|
* its daemon keeps polling status.
|
|
122
136
|
*/
|
|
123
137
|
statusEvents?: BrainStatusPublisher | null;
|
|
138
|
+
/** The append-only line stream behind `GET /__host/events`. */
|
|
139
|
+
logEvents?: BrainLogPublisher | null;
|
|
124
140
|
/** Long operations that must execute on this brain's machine. */
|
|
125
141
|
jobs?: HostJobRunner;
|
|
142
|
+
/** The append-only log owned by this Brain service run. */
|
|
143
|
+
runLog?: BrainRunLog;
|
|
126
144
|
/** Gracefully restart the serving process after its HTTP acknowledgement. */
|
|
127
145
|
restart?: () => void;
|
|
146
|
+
/** Durable service-session operation log. */
|
|
147
|
+
log?: (area: BrainLogArea, message: string) => void;
|
|
128
148
|
}
|
|
129
149
|
/** One row of the model inventory: the scan, metadata, profile and score joined. */
|
|
130
150
|
export interface InventoryRow {
|
|
@@ -153,7 +173,7 @@ export interface InventoryRow {
|
|
|
153
173
|
budget: vram.Budget | null;
|
|
154
174
|
maxContextThatFits: number | null;
|
|
155
175
|
score: RankedModel | null;
|
|
156
|
-
state: "loaded" | "loading" | "not-loaded";
|
|
176
|
+
state: "loaded" | "loading" | "unloading" | "active" | "queued" | "not-loaded";
|
|
157
177
|
warnings: ReturnType<typeof profileWarnings>;
|
|
158
178
|
components: NonNullable<Model["components"]> | null;
|
|
159
179
|
}
|
|
@@ -184,6 +204,7 @@ export declare function buildInventoryRow(params: {
|
|
|
184
204
|
gpu: GpuInfo | null;
|
|
185
205
|
ranking: RankedModel[];
|
|
186
206
|
supervisor: Supervisor;
|
|
207
|
+
scheduler?: Scheduler | null;
|
|
187
208
|
runtimeBuild?: number | null;
|
|
188
209
|
}): InventoryRow;
|
|
189
210
|
export interface HostApi {
|
package/dist/service/host-api.js
CHANGED
|
@@ -170,13 +170,21 @@ function hostingProfilesFor(store, model) {
|
|
|
170
170
|
const family = hostingFamily(model.family);
|
|
171
171
|
return Object.values(store.hostingProfiles).filter((candidate) => candidate.family === family);
|
|
172
172
|
}
|
|
173
|
-
function stateOf(supervisor, model) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
173
|
+
function stateOf(supervisor, scheduler, model) {
|
|
174
|
+
const resident = supervisor.model?.id === model.id;
|
|
175
|
+
if (resident) {
|
|
176
|
+
if (supervisor.state === "starting")
|
|
177
|
+
return "loading";
|
|
178
|
+
if (supervisor.state === "stopping")
|
|
179
|
+
return "unloading";
|
|
180
|
+
}
|
|
181
|
+
const stats = scheduler?.stats();
|
|
182
|
+
if (stats?.active?.modelId === model.id)
|
|
183
|
+
return "active";
|
|
184
|
+
if ((stats?.waitingModelIds[model.id] ?? 0) > 0)
|
|
185
|
+
return "queued";
|
|
186
|
+
if (resident && supervisor.state === "ready")
|
|
177
187
|
return "loaded";
|
|
178
|
-
if (supervisor.state === "starting")
|
|
179
|
-
return "loading";
|
|
180
188
|
return "not-loaded";
|
|
181
189
|
}
|
|
182
190
|
/**
|
|
@@ -187,7 +195,7 @@ function stateOf(supervisor, model) {
|
|
|
187
195
|
* client would otherwise have to correlate three unrelated lists by display name.
|
|
188
196
|
*/
|
|
189
197
|
export function buildInventoryRow(params) {
|
|
190
|
-
const { model, store, defaults, gpu, ranking, supervisor, runtimeBuild: activeRuntimeBuild = null, } = params;
|
|
198
|
+
const { model, store, defaults, gpu, ranking, supervisor, scheduler = null, runtimeBuild: activeRuntimeBuild = null, } = params;
|
|
191
199
|
const profile = forModel(store, model, defaults);
|
|
192
200
|
const calibration = profile.calibrationRequired ? null : getCalibration(store, model, profile);
|
|
193
201
|
const budgetOptions = gpu
|
|
@@ -222,7 +230,7 @@ export function buildInventoryRow(params) {
|
|
|
222
230
|
budget: budgetOptions ? vram.budget(budgetOptions) : null,
|
|
223
231
|
maxContextThatFits: budgetOptions ? vram.maxContextThatFits(budgetOptions) : null,
|
|
224
232
|
score: ranked,
|
|
225
|
-
state: stateOf(supervisor, model),
|
|
233
|
+
state: stateOf(supervisor, scheduler, model),
|
|
226
234
|
warnings: profileWarnings(profile, model, store),
|
|
227
235
|
components: model.components?.map((component) => {
|
|
228
236
|
if (component.minRuntimeBuild === undefined ||
|
|
@@ -267,7 +275,23 @@ function resolveModel(catalog, needle) {
|
|
|
267
275
|
*/
|
|
268
276
|
function profileFromQuery(base, params, model) {
|
|
269
277
|
const patch = {};
|
|
270
|
-
|
|
278
|
+
// The samplers cost no VRAM and so change nothing in the budget this powers,
|
|
279
|
+
// but they ride in the same draft the editor sends. Parse them anyway: an
|
|
280
|
+
// unparsed key reaches sanitizeProfilePatch as the string "0.8" and throws,
|
|
281
|
+
// which would fail the whole preview over a field it does not even price.
|
|
282
|
+
const numeric = [
|
|
283
|
+
"contextSize",
|
|
284
|
+
"gpuLayers",
|
|
285
|
+
"parallelSlots",
|
|
286
|
+
"cachedChats",
|
|
287
|
+
"reasoningBudget",
|
|
288
|
+
"temperature",
|
|
289
|
+
"topP",
|
|
290
|
+
"topK",
|
|
291
|
+
"minP",
|
|
292
|
+
"presencePenalty",
|
|
293
|
+
"repeatPenalty",
|
|
294
|
+
];
|
|
271
295
|
for (const key of numeric) {
|
|
272
296
|
const raw = params.get(key);
|
|
273
297
|
if (raw !== null && raw !== "")
|
|
@@ -283,6 +307,16 @@ function profileFromQuery(base, params, model) {
|
|
|
283
307
|
if (raw !== null && raw !== "")
|
|
284
308
|
patch[key] = raw === "true" || raw === "1";
|
|
285
309
|
}
|
|
310
|
+
// Tri-state, and every spelling a client might use for it. Unknown text is
|
|
311
|
+
// dropped rather than thrown on: this field prices nothing, so a value this
|
|
312
|
+
// route cannot read must not take the whole budget preview down with it.
|
|
313
|
+
const preserve = params.get("preserveReasoning");
|
|
314
|
+
if (preserve === "true" || preserve === "on")
|
|
315
|
+
patch.preserveReasoning = true;
|
|
316
|
+
else if (preserve === "false" || preserve === "off")
|
|
317
|
+
patch.preserveReasoning = false;
|
|
318
|
+
else if (preserve === "default" || preserve === "null")
|
|
319
|
+
patch.preserveReasoning = null;
|
|
286
320
|
if (Object.keys(patch).length === 0)
|
|
287
321
|
return base;
|
|
288
322
|
return sanitizeProfilePatch(base, patch, model, runtimeBuild(null)).profile;
|
|
@@ -306,6 +340,7 @@ export function createHostApi(deps) {
|
|
|
306
340
|
// would make a daemon stop polling and see nothing.
|
|
307
341
|
events: Boolean(deps.statusEvents?.ready),
|
|
308
342
|
liveInference: Boolean(deps.statusEvents?.ready),
|
|
343
|
+
logEvents: Boolean(deps.statusEvents?.ready && deps.logEvents),
|
|
309
344
|
writable: deps.getAllowWrite(),
|
|
310
345
|
jobs: Boolean(deps.jobs),
|
|
311
346
|
restart: Boolean(deps.restart),
|
|
@@ -328,6 +363,7 @@ export function createHostApi(deps) {
|
|
|
328
363
|
gpu,
|
|
329
364
|
ranking,
|
|
330
365
|
supervisor: deps.supervisor,
|
|
366
|
+
scheduler: deps.scheduler,
|
|
331
367
|
runtimeBuild: runtimeBuild(deps.supervisor.runtime),
|
|
332
368
|
}));
|
|
333
369
|
};
|
|
@@ -381,6 +417,7 @@ export function createHostApi(deps) {
|
|
|
381
417
|
if (requiresRestart)
|
|
382
418
|
store.pendingReloadModelIds[model.id] = true;
|
|
383
419
|
deps.saveProfiles(store);
|
|
420
|
+
deps.log?.("model", `updated profile for ${model.displayName}${requiresRestart ? "; reload required" : ""}`);
|
|
384
421
|
// Return the recomputed budget so an edit costs one round trip rather
|
|
385
422
|
// than a write followed by a read the UI has to sequence.
|
|
386
423
|
const gpu = await deps.queryGpuInfo();
|
|
@@ -448,6 +485,7 @@ export function createHostApi(deps) {
|
|
|
448
485
|
// the brain is restarted. Reset already follows this pattern below.
|
|
449
486
|
const catalog = deps.rescan();
|
|
450
487
|
const updated = resolveModel(catalog, model.id);
|
|
488
|
+
deps.log?.("library", `renamed ${model.displayName} to ${displayName}`);
|
|
451
489
|
sendJson(res, { displayName: updated ? updated.displayName : displayName });
|
|
452
490
|
});
|
|
453
491
|
};
|
|
@@ -460,6 +498,7 @@ export function createHostApi(deps) {
|
|
|
460
498
|
deleteDisplayName(model.id);
|
|
461
499
|
const catalog = deps.rescan();
|
|
462
500
|
const updated = resolveModel(catalog, model.id);
|
|
501
|
+
deps.log?.("library", `reset display name for ${model.displayName}`);
|
|
463
502
|
sendJson(res, { displayName: updated ? updated.displayName : model.displayName });
|
|
464
503
|
});
|
|
465
504
|
};
|
|
@@ -511,7 +550,9 @@ export function createHostApi(deps) {
|
|
|
511
550
|
return;
|
|
512
551
|
}
|
|
513
552
|
try {
|
|
553
|
+
deps.log?.("model", `loading ${model.displayName}`);
|
|
514
554
|
await deps.loadModel(model);
|
|
555
|
+
deps.log?.("model", `loaded ${model.displayName}`);
|
|
515
556
|
sendJson(res, {
|
|
516
557
|
status: deps.supervisor.status(),
|
|
517
558
|
// What actually got used: loadModel fits the profile to VRAM, so the
|
|
@@ -520,6 +561,7 @@ export function createHostApi(deps) {
|
|
|
520
561
|
});
|
|
521
562
|
}
|
|
522
563
|
catch (error) {
|
|
564
|
+
deps.log?.("model", `failed to load ${model.displayName}: ${errorMessage(error)}`);
|
|
523
565
|
sendError(res, 409, `could not load ${model.displayName}: ${errorMessage(error)}`);
|
|
524
566
|
}
|
|
525
567
|
})();
|
|
@@ -527,7 +569,9 @@ export function createHostApi(deps) {
|
|
|
527
569
|
const handleUnload = (res) => {
|
|
528
570
|
void (async () => {
|
|
529
571
|
try {
|
|
572
|
+
deps.log?.("model", "unloading resident model");
|
|
530
573
|
await deps.supervisor.stop();
|
|
574
|
+
deps.log?.("model", "resident model unloaded");
|
|
531
575
|
sendJson(res, { status: deps.supervisor.status() });
|
|
532
576
|
}
|
|
533
577
|
catch (error) {
|
|
@@ -543,6 +587,7 @@ export function createHostApi(deps) {
|
|
|
543
587
|
try {
|
|
544
588
|
const plan = deleteModelFiles(model);
|
|
545
589
|
const catalog = deps.rescan();
|
|
590
|
+
deps.log?.("library", `deleted ${model.displayName}; freed ${plan.bytes} bytes`);
|
|
546
591
|
sendJson(res, {
|
|
547
592
|
deleted: plan.files,
|
|
548
593
|
freedBytes: plan.bytes,
|
|
@@ -562,6 +607,7 @@ export function createHostApi(deps) {
|
|
|
562
607
|
try {
|
|
563
608
|
const plan = deleteComponentFile(model, componentId);
|
|
564
609
|
deps.rescan();
|
|
610
|
+
deps.log?.("library", `deleted ${componentId} from ${model.displayName}; freed ${plan.bytes} bytes`);
|
|
565
611
|
sendJson(res, {
|
|
566
612
|
deleted: plan.files,
|
|
567
613
|
freedBytes: plan.bytes,
|
|
@@ -575,10 +621,11 @@ export function createHostApi(deps) {
|
|
|
575
621
|
const handleLogs = (res, params) => {
|
|
576
622
|
const raw = Number(params.get("limit"));
|
|
577
623
|
const limit = Number.isFinite(raw) && raw > 0 ? Math.min(Math.round(raw), 1000) : DEFAULT_LOG_LINES;
|
|
624
|
+
const session = deps.runLog?.tail(limit);
|
|
578
625
|
const all = deps.supervisor.logLines;
|
|
579
626
|
sendJson(res, {
|
|
580
|
-
lines: all.slice(-limit),
|
|
581
|
-
total: all.length,
|
|
627
|
+
lines: session?.lines ?? all.slice(-limit),
|
|
628
|
+
total: session?.total ?? all.length,
|
|
582
629
|
state: deps.supervisor.state,
|
|
583
630
|
command: deps.supervisor.command,
|
|
584
631
|
});
|
|
@@ -610,7 +657,13 @@ export function createHostApi(deps) {
|
|
|
610
657
|
return;
|
|
611
658
|
res.write(`event: status\ndata: ${JSON.stringify(snapshot)}\n\n`);
|
|
612
659
|
};
|
|
660
|
+
const writeLog = (line) => {
|
|
661
|
+
if (res.writableEnded || res.destroyed)
|
|
662
|
+
return;
|
|
663
|
+
res.write(`event: log\ndata: ${JSON.stringify({ line })}\n\n`);
|
|
664
|
+
};
|
|
613
665
|
let unsubscribe = () => { };
|
|
666
|
+
let unsubscribeLogs = () => { };
|
|
614
667
|
const keepalive = setInterval(() => {
|
|
615
668
|
if (res.writableEnded || res.destroyed)
|
|
616
669
|
return;
|
|
@@ -620,6 +673,7 @@ export function createHostApi(deps) {
|
|
|
620
673
|
const teardown = () => {
|
|
621
674
|
clearInterval(keepalive);
|
|
622
675
|
unsubscribe();
|
|
676
|
+
unsubscribeLogs();
|
|
623
677
|
};
|
|
624
678
|
// The publisher ends the response on host shutdown: an open SSE response is
|
|
625
679
|
// an open connection, and `server.close()` waits for those.
|
|
@@ -628,6 +682,7 @@ export function createHostApi(deps) {
|
|
|
628
682
|
if (!res.writableEnded && !res.destroyed)
|
|
629
683
|
res.end();
|
|
630
684
|
});
|
|
685
|
+
unsubscribeLogs = deps.logEvents?.subscribe(writeLog) ?? (() => { });
|
|
631
686
|
// Both ends matter: `close` on the request covers a client that walked away,
|
|
632
687
|
// and `close` on the response covers the service shutting the socket down.
|
|
633
688
|
req.on("close", teardown);
|
|
@@ -664,6 +719,7 @@ export function createHostApi(deps) {
|
|
|
664
719
|
}
|
|
665
720
|
if (!guardWrite(res))
|
|
666
721
|
return true;
|
|
722
|
+
deps.log?.("server", "restart requested through the management API");
|
|
667
723
|
sendJson(res, { accepted: true });
|
|
668
724
|
queueMicrotask(() => deps.restart?.());
|
|
669
725
|
return true;
|
|
@@ -737,6 +793,7 @@ export function createHostApi(deps) {
|
|
|
737
793
|
"--",
|
|
738
794
|
model,
|
|
739
795
|
],
|
|
796
|
+
pull: { entryKey: model, components: components ?? [] },
|
|
740
797
|
};
|
|
741
798
|
},
|
|
742
799
|
},
|
|
@@ -763,6 +820,7 @@ export function createHostApi(deps) {
|
|
|
763
820
|
"--",
|
|
764
821
|
repo,
|
|
765
822
|
],
|
|
823
|
+
pull: { entryKey: `${repo}#${quant}`, components: components ?? [] },
|
|
766
824
|
};
|
|
767
825
|
},
|
|
768
826
|
},
|
|
@@ -838,7 +896,9 @@ export function createHostApi(deps) {
|
|
|
838
896
|
}
|
|
839
897
|
try {
|
|
840
898
|
const spec = start.makeArgs(result.body);
|
|
841
|
-
sendJson(res, {
|
|
899
|
+
sendJson(res, {
|
|
900
|
+
job: deps.jobs?.start(start.kind, spec.target, spec.args, spec.pull) ?? null,
|
|
901
|
+
});
|
|
842
902
|
}
|
|
843
903
|
catch (error) {
|
|
844
904
|
sendError(res, 400, errorMessage(error));
|
|
@@ -847,27 +907,32 @@ export function createHostApi(deps) {
|
|
|
847
907
|
return true;
|
|
848
908
|
}
|
|
849
909
|
if (route === "/__host/catalog" && method === "GET") {
|
|
850
|
-
|
|
910
|
+
deps.log?.("library", "refreshing the model catalog");
|
|
911
|
+
void deps.jobs
|
|
912
|
+
?.query(["catalog", "--json"], "library")
|
|
913
|
+
.then((models) => sendJson(res, { models }));
|
|
851
914
|
return true;
|
|
852
915
|
}
|
|
853
916
|
if (route === "/__host/runtimes" && method === "GET") {
|
|
854
917
|
void deps.jobs
|
|
855
|
-
?.query(["runtime", "list", "--json"])
|
|
918
|
+
?.query(["runtime", "list", "--json"], "library")
|
|
856
919
|
.then((runtimes) => sendJson(res, { runtimes }));
|
|
857
920
|
return true;
|
|
858
921
|
}
|
|
859
922
|
if (route === "/__host/hf/search" && method === "GET") {
|
|
860
923
|
const query = params.get("query") ?? "";
|
|
861
924
|
const limit = Math.max(1, Math.min(100, Number(params.get("limit")) || 25));
|
|
925
|
+
deps.log?.("library", `searching Hugging Face for ${JSON.stringify(query)} (limit ${limit})`);
|
|
862
926
|
void deps.jobs
|
|
863
|
-
?.query(["search", "--json", "--limit", String(limit), "--", query])
|
|
927
|
+
?.query(["search", "--json", "--limit", String(limit), "--", query], "library")
|
|
864
928
|
.then((results) => sendJson(res, { results }));
|
|
865
929
|
return true;
|
|
866
930
|
}
|
|
867
931
|
if (route === "/__host/hf/quants" && method === "GET") {
|
|
868
932
|
const repo = params.get("repo") ?? "";
|
|
933
|
+
deps.log?.("library", `listing Hugging Face quants for ${repo}`);
|
|
869
934
|
void deps.jobs
|
|
870
|
-
?.query(["add", "--list-quants", "--json", "--", repo])
|
|
935
|
+
?.query(["add", "--list-quants", "--json", "--", repo], "library")
|
|
871
936
|
.then((quants) => sendJson(res, { quants }));
|
|
872
937
|
return true;
|
|
873
938
|
}
|
|
@@ -913,6 +978,7 @@ export function createHostApi(deps) {
|
|
|
913
978
|
// without restarting the host or unloading its resident model.
|
|
914
979
|
if (route === "/__host/models/rescan" && method === "POST") {
|
|
915
980
|
const models = deps.rescan();
|
|
981
|
+
deps.log?.("library", `rescanned model library: ${models.length} models`);
|
|
916
982
|
sendJson(res, { models: models.length });
|
|
917
983
|
return true;
|
|
918
984
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Stable source and subsystem markers for the one Brain service-session log. */
|
|
2
|
+
export type BrainLogArea = "library" | "model" | "api" | "server";
|
|
3
|
+
/**
|
|
4
|
+
* Every service-owned event carries both its process source and operation area.
|
|
5
|
+
* llama-server output is separately marked by `formatLlamaServerLog`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function formatBrainLog(area: BrainLogArea, message: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Remove llama.cpp's elapsed-time, level and component columns. Otto owns the
|
|
10
|
+
* timestamp and source marker, and the remaining message is what an operator
|
|
11
|
+
* needs to diagnose the runtime.
|
|
12
|
+
*/
|
|
13
|
+
export declare function stripLlamaServerPrefix(message: string): string;
|
|
14
|
+
/** Preserve the useful llama.cpp message while making its process boundary explicit. */
|
|
15
|
+
export declare function formatLlamaServerLog(message: string): string;
|
|
16
|
+
/** Place source tags ahead of the timestamp so they are scannable in a dense log. */
|
|
17
|
+
export declare function timestampBrainLogLine(timestamp: string, line: string): string;
|
|
18
|
+
//# sourceMappingURL=log-format.d.ts.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const TAGGED_LINE = /^\[(?:brain|llama-server)\]/u;
|
|
2
|
+
const SOURCE_AND_AREA = /^(\[(?:brain|llama-server)\])(?:\s+(\[(?:library|model|api|server)\]))?\s*(.*)$/u;
|
|
3
|
+
const LLAMA_SERVER_PREFIX = /^\d+(?:\.\d+){3}\s+[A-Z]\s+\S+\s+(?:\S+:\s+)?(.+)$/u;
|
|
4
|
+
/**
|
|
5
|
+
* Every service-owned event carries both its process source and operation area.
|
|
6
|
+
* llama-server output is separately marked by `formatLlamaServerLog`.
|
|
7
|
+
*/
|
|
8
|
+
export function formatBrainLog(area, message) {
|
|
9
|
+
return TAGGED_LINE.test(message) ? message : `[brain] [${area}] ${message}`;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Remove llama.cpp's elapsed-time, level and component columns. Otto owns the
|
|
13
|
+
* timestamp and source marker, and the remaining message is what an operator
|
|
14
|
+
* needs to diagnose the runtime.
|
|
15
|
+
*/
|
|
16
|
+
export function stripLlamaServerPrefix(message) {
|
|
17
|
+
return LLAMA_SERVER_PREFIX.exec(message)?.[1] ?? message;
|
|
18
|
+
}
|
|
19
|
+
/** Preserve the useful llama.cpp message while making its process boundary explicit. */
|
|
20
|
+
export function formatLlamaServerLog(message) {
|
|
21
|
+
return TAGGED_LINE.test(message) ? message : `[llama-server] ${stripLlamaServerPrefix(message)}`;
|
|
22
|
+
}
|
|
23
|
+
/** Place source tags ahead of the timestamp so they are scannable in a dense log. */
|
|
24
|
+
export function timestampBrainLogLine(timestamp, line) {
|
|
25
|
+
const tagged = formatBrainLog("server", line);
|
|
26
|
+
const match = SOURCE_AND_AREA.exec(tagged);
|
|
27
|
+
if (!match)
|
|
28
|
+
return `${timestamp} ${tagged}`;
|
|
29
|
+
const [, source, area, message] = match;
|
|
30
|
+
return `${source}${area ? ` ${area}` : ""} ${timestamp}${message ? ` ${message}` : ""}`;
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=log-format.js.map
|
package/dist/service/router.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
|
+
import { Scheduler } from "./scheduler.js";
|
|
2
3
|
import type { Supervisor } from "./supervisor.js";
|
|
3
4
|
import { type RankedModel } from "../ops/results.js";
|
|
4
5
|
import type { GpuInfo, Model } from "../types.js";
|
|
@@ -6,8 +7,9 @@ import type { Profile } from "../config/schema.js";
|
|
|
6
7
|
import { type HostApi } from "./host-api.js";
|
|
7
8
|
import type { BrainStatusPublisher } from "./status-events.js";
|
|
8
9
|
type Verdict = "ok" | "reasoning-only" | "truncated" | "failed";
|
|
9
|
-
/**
|
|
10
|
+
/** Optional durable operational-log sink for completion lifecycle events. */
|
|
10
11
|
export interface Logger {
|
|
12
|
+
info?(message: string): void;
|
|
11
13
|
warn(message: string): void;
|
|
12
14
|
}
|
|
13
15
|
/** A source of the catalog: a getter, a snapshot array, or nothing. */
|
|
@@ -88,6 +90,8 @@ export interface ModelEntry {
|
|
|
88
90
|
reasoning: boolean;
|
|
89
91
|
/** Optional per-model values accepted by the OpenAI-compatible endpoint. */
|
|
90
92
|
reasoning_efforts?: string[];
|
|
93
|
+
/** Optional model-native default among `reasoning_efforts`. */
|
|
94
|
+
reasoning_effort_default?: string;
|
|
91
95
|
loaded_context_length?: number;
|
|
92
96
|
}
|
|
93
97
|
/**
|
|
@@ -105,6 +109,68 @@ export declare function describeModel(model: Model | null, options?: DescribeOpt
|
|
|
105
109
|
* model when no catalog provider is wired in.
|
|
106
110
|
*/
|
|
107
111
|
export declare function buildModelList(supervisor: Supervisor, getCatalog: GetCatalog): ModelEntry[];
|
|
112
|
+
/**
|
|
113
|
+
* Map an OpenAI-compatible effort request onto a model's own chat-template
|
|
114
|
+
* arguments. llama.cpp does not know every model's dialect: Qwen3.8 calls the
|
|
115
|
+
* controls `enable_thinking` and `reasoning_effort`, for example. Only catalog
|
|
116
|
+
* entries that declare these names are rewritten, so generic models and GPT-OSS
|
|
117
|
+
* keep their existing server-native request handling.
|
|
118
|
+
*/
|
|
119
|
+
export declare function applyModelReasoningTemplate(body: Buffer, model: Model): Buffer;
|
|
120
|
+
/**
|
|
121
|
+
* Pin the request to one llama-server slot by adding the engine's own
|
|
122
|
+
* `id_slot` field to the request body (host API v3).
|
|
123
|
+
*
|
|
124
|
+
* Why pin instead of guess: the OpenAI-compatible stream chunks never carry the
|
|
125
|
+
* slot id, so without a pin the router can only correlate a request to a slot
|
|
126
|
+
* by elimination, and with several concurrent requests that guess is exactly
|
|
127
|
+
* the lie the Overview panel used to tell. llama-server honors the pin on every
|
|
128
|
+
* completion endpoint: if the named slot is free the task lands there, and if
|
|
129
|
+
* it is busy the engine DEFERS the task internally - it never reassigns the
|
|
130
|
+
* task elsewhere and never fails the request - so the slot this router names is
|
|
131
|
+
* always the slot the request ends up on (possibly after waiting on it).
|
|
132
|
+
*
|
|
133
|
+
* `null` returns the body untouched: no pin, and therefore no join data for
|
|
134
|
+
* this request, which is the same degraded state as an older brain. A body this
|
|
135
|
+
* cannot parse is forwarded exactly as-is - an unfamiliar request must reach
|
|
136
|
+
* llama-server and get llama-server's own answer, not a 400 invented here.
|
|
137
|
+
*/
|
|
138
|
+
export declare function pinSlot(body: Buffer, slotId: number | null): Buffer;
|
|
139
|
+
/**
|
|
140
|
+
* Wipe one llama-server slot's retained KV state, and RESOLVE only once the
|
|
141
|
+
* engine has acknowledged the wipe.
|
|
142
|
+
*
|
|
143
|
+
* This is the engine-side half of the scheduler's OWNERSHIP fix. The engine
|
|
144
|
+
* never clears a released slot's prompt, so a slot handed to a different chat
|
|
145
|
+
* would keep the previous chat's KV and bleed its topics into the new chat's
|
|
146
|
+
* thinking. The router erases the slot the moment the scheduler hands it off;
|
|
147
|
+
* the engine's task queue runs in arrival order, so resolving on the
|
|
148
|
+
* acknowledgment is what guarantees the clean state sits in the queue ahead of
|
|
149
|
+
* the completion the scheduler posts right after.
|
|
150
|
+
*
|
|
151
|
+
* The route is `POST /slots?action=erase&id_slot=N` - llama.cpp's own slot
|
|
152
|
+
* action. It answers 200 `{id, id_slot, n_erased}` on success and a
|
|
153
|
+
* `NOT_SUPPORTED` error when the server was not launched with a slot-save path;
|
|
154
|
+
* either way this resolves (never rejects), because an erase that cannot be
|
|
155
|
+
* performed degrades to the old behavior rather than failing the completion.
|
|
156
|
+
*
|
|
157
|
+
* NOTE: `action` and `id_slot` MUST travel in the query string, not the JSON
|
|
158
|
+
* body. llama-server's `POST /slots` handler reads both via `req.get_param()`,
|
|
159
|
+
* which is built only from query + path params (b10441 tools/server/server-http.cpp,
|
|
160
|
+
* `server_http_req::params` = "path_params + query_params"; the body is a
|
|
161
|
+
* separate field the handler never parses for this route). A body-only request
|
|
162
|
+
* reaches `std::stoi("")` and answers 400 "Invalid slot ID" - the erase then
|
|
163
|
+
* silently no-ops and the bleed survives. The body must stay empty for the
|
|
164
|
+
* same reason `handle_slots_erase` ignores it entirely.
|
|
165
|
+
*/
|
|
166
|
+
export declare function eraseSlot(host: string, port: number, slotId: number): Promise<void>;
|
|
167
|
+
/**
|
|
168
|
+
* The eraser the scheduler needs, bound to one engine endpoint. Extracted so
|
|
169
|
+
* the router (which builds its own scheduler) and the service (which builds a
|
|
170
|
+
* shared one and passes it in) hand the scheduler the SAME transport rather
|
|
171
|
+
* than each spelling the request.
|
|
172
|
+
*/
|
|
173
|
+
export declare function createSlotEraser(host: string, port: number): (slotId: number) => Promise<void>;
|
|
108
174
|
/** Which shape a completion path uses to carry its system turn. */
|
|
109
175
|
export type CompletionShape = "anthropic" | "openai";
|
|
110
176
|
export declare function completionShape(url: string | null | undefined): CompletionShape;
|
|
@@ -195,7 +261,9 @@ export interface RouterOptions {
|
|
|
195
261
|
* can never disagree. Absent means this brain does not advertise events.
|
|
196
262
|
*/
|
|
197
263
|
statusEvents?: BrainStatusPublisher | null;
|
|
264
|
+
/** A service shares this scheduler with host-owned model operations. */
|
|
265
|
+
scheduler?: Scheduler | null;
|
|
198
266
|
}
|
|
199
|
-
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;
|
|
267
|
+
export declare function createRouter({ supervisor, telemetry, logger, getCatalog, loadModel, loadRanking, queryGpuInfo, version, getConfig, getEvals, getLockModel, getDefaultModel, applyConfigPatch, getAllowConfigWrite, hostApi, getResources, statusEvents, scheduler: suppliedScheduler, }: RouterOptions): (req: http.IncomingMessage, res: http.ServerResponse) => void;
|
|
200
268
|
export {};
|
|
201
269
|
//# sourceMappingURL=router.d.ts.map
|