@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/runtime/managed.js
CHANGED
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
* also needs `libcublas`, `libcudart` and `libnccl.so.2` - and NCCL ships in
|
|
39
39
|
* neither NVIDIA redistributable. Do not reopen this without a measurement on
|
|
40
40
|
* hardware that does *not* report NV_coopmat2, which is the one case where
|
|
41
|
-
* the gap could still be real.
|
|
42
|
-
*
|
|
41
|
+
* the gap could still be real. Measured 2026-08-04; the full method and
|
|
42
|
+
* numbers are the "cuda vs vulkan" finding in Otto Knowledge.
|
|
43
43
|
* - **No Linux asset ships `libgomp.so.1`**, which `llama-server` hard-links,
|
|
44
44
|
* so a host without `libgomp1` installs a runtime that then exits 127 on
|
|
45
45
|
* spawn. Windows bundles its OpenMP runtime (`libomp140.x86_64.dll`); Linux
|
|
@@ -64,6 +64,14 @@ export interface InferenceActivitySnapshot {
|
|
|
64
64
|
processing: number;
|
|
65
65
|
thinking: number;
|
|
66
66
|
generating: number;
|
|
67
|
+
/**
|
|
68
|
+
* Per-slot stage join, present only when at least one tracked request was
|
|
69
|
+
* pinned to a llama-server slot by the router (host API v3). Keys are the
|
|
70
|
+
* engine's slot ids as reported by `/slots`; values are that request's
|
|
71
|
+
* proxy-side stage. Absent (not just empty) on brains that predate the join,
|
|
72
|
+
* which is how an old client tells "no join data" from "no pinned requests".
|
|
73
|
+
*/
|
|
74
|
+
slotStages?: Record<string, InferenceStage>;
|
|
67
75
|
}
|
|
68
76
|
/**
|
|
69
77
|
* Which in-flight completions are currently mid-thought.
|
|
@@ -91,6 +99,17 @@ export declare class ReasoningTracker {
|
|
|
91
99
|
onChange(listener: () => void): () => void;
|
|
92
100
|
/** A completion was dispatched to llama-server and awaits its first output delta. */
|
|
93
101
|
begin(requestId: string): void;
|
|
102
|
+
/**
|
|
103
|
+
* Record the engine slot this request was pinned to. Called exactly once per
|
|
104
|
+
* request, at dispatch - the pin is injected into the outbound body before
|
|
105
|
+
* the request goes out, so the association exists before the first chunk and
|
|
106
|
+
* `observe` never has to learn about it.
|
|
107
|
+
*
|
|
108
|
+
* Idempotent and self-cleaning: a repeat for the same slot is a no-op, and a
|
|
109
|
+
* different slot replaces it, so a request that somehow moves slots (a
|
|
110
|
+
* restarted engine hands a task out again) reports where it is now.
|
|
111
|
+
*/
|
|
112
|
+
setSlot(requestId: string, slotId: number): void;
|
|
94
113
|
/** Note a chunk of `requestId`'s stream. Cheap enough to call per chunk. */
|
|
95
114
|
observe(requestId: string, text: string): void;
|
|
96
115
|
/** Forget the request. Must be called on end *and* on error, or the flag sticks. */
|
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 { ModelScheduler } 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,12 +75,16 @@ 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. */
|
|
78
83
|
jobs: boolean;
|
|
79
84
|
/** POST /__host/restart delegates a restart to the service owner. */
|
|
80
85
|
restart: boolean;
|
|
86
|
+
/** Multiple independently supervised model processes are supported. */
|
|
87
|
+
processPool: boolean;
|
|
81
88
|
}
|
|
82
89
|
/** A long-running operation owned by this brain host, not its caller. */
|
|
83
90
|
export interface HostJob {
|
|
@@ -86,6 +93,8 @@ export interface HostJob {
|
|
|
86
93
|
label: string;
|
|
87
94
|
target: string | null;
|
|
88
95
|
status: "running" | "succeeded" | "failed" | "canceled";
|
|
96
|
+
/** Positive while the shared scheduler has not admitted this operation yet. */
|
|
97
|
+
queuePosition?: number | null;
|
|
89
98
|
percent: number | null;
|
|
90
99
|
message: string | null;
|
|
91
100
|
error: string | null;
|
|
@@ -93,10 +102,15 @@ export interface HostJob {
|
|
|
93
102
|
finishedAt: string | null;
|
|
94
103
|
}
|
|
95
104
|
export interface HostJobRunner {
|
|
96
|
-
start: (kind: HostJob["kind"], target: string | null, args: string[]
|
|
105
|
+
start: (kind: HostJob["kind"], target: string | null, args: string[],
|
|
106
|
+
/** A bundle entry owns its companion-artifact queue, not the whole host. */
|
|
107
|
+
pull?: {
|
|
108
|
+
entryKey: string;
|
|
109
|
+
components: string[];
|
|
110
|
+
}) => HostJob;
|
|
97
111
|
list: () => HostJob[];
|
|
98
112
|
cancel: (jobId: string) => Promise<HostJob[]>;
|
|
99
|
-
query: (args: string[]) => Promise<unknown>;
|
|
113
|
+
query: (args: string[], area?: BrainLogArea) => Promise<unknown>;
|
|
100
114
|
}
|
|
101
115
|
export interface HostApiDeps {
|
|
102
116
|
supervisor: Supervisor;
|
|
@@ -110,6 +124,9 @@ export interface HostApiDeps {
|
|
|
110
124
|
queryGpuInfo: () => Promise<GpuInfo | null>;
|
|
111
125
|
getRanking: () => RankedModel[];
|
|
112
126
|
loadModel: (model: Model) => Promise<void>;
|
|
127
|
+
unloadModels?: () => Promise<void>;
|
|
128
|
+
/** The process-pool scheduler shared by completions and resident operations. */
|
|
129
|
+
scheduler?: ModelScheduler<Supervisor> | null;
|
|
113
130
|
/** Mirrors POST /__host/config's gate: may a network caller change things? */
|
|
114
131
|
getAllowWrite: () => boolean;
|
|
115
132
|
/** The managed models directory, for disk accounting. Null when unresolvable. */
|
|
@@ -121,10 +138,16 @@ export interface HostApiDeps {
|
|
|
121
138
|
* its daemon keeps polling status.
|
|
122
139
|
*/
|
|
123
140
|
statusEvents?: BrainStatusPublisher | null;
|
|
141
|
+
/** The append-only line stream behind `GET /__host/events`. */
|
|
142
|
+
logEvents?: BrainLogPublisher | null;
|
|
124
143
|
/** Long operations that must execute on this brain's machine. */
|
|
125
144
|
jobs?: HostJobRunner;
|
|
145
|
+
/** The append-only log owned by this Brain service run. */
|
|
146
|
+
runLog?: BrainRunLog;
|
|
126
147
|
/** Gracefully restart the serving process after its HTTP acknowledgement. */
|
|
127
148
|
restart?: () => void;
|
|
149
|
+
/** Durable service-session operation log. */
|
|
150
|
+
log?: (area: BrainLogArea, message: string) => void;
|
|
128
151
|
}
|
|
129
152
|
/** One row of the model inventory: the scan, metadata, profile and score joined. */
|
|
130
153
|
export interface InventoryRow {
|
|
@@ -153,7 +176,7 @@ export interface InventoryRow {
|
|
|
153
176
|
budget: vram.Budget | null;
|
|
154
177
|
maxContextThatFits: number | null;
|
|
155
178
|
score: RankedModel | null;
|
|
156
|
-
state: "loaded" | "loading" | "not-loaded";
|
|
179
|
+
state: "loaded" | "loading" | "unloading" | "active" | "queued" | "not-loaded";
|
|
157
180
|
warnings: ReturnType<typeof profileWarnings>;
|
|
158
181
|
components: NonNullable<Model["components"]> | null;
|
|
159
182
|
}
|
|
@@ -184,6 +207,7 @@ export declare function buildInventoryRow(params: {
|
|
|
184
207
|
gpu: GpuInfo | null;
|
|
185
208
|
ranking: RankedModel[];
|
|
186
209
|
supervisor: Supervisor;
|
|
210
|
+
scheduler?: ModelScheduler<Supervisor> | null;
|
|
187
211
|
runtimeBuild?: number | null;
|
|
188
212
|
}): InventoryRow;
|
|
189
213
|
export interface HostApi {
|
package/dist/service/host-api.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { calibrationInfo, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, } from "../config/profile-edit.js";
|
|
3
3
|
import { familyHostingProfileId, hostingFamily, removeHostingProfileMaterialization, } from "../config/hosting-profiles.js";
|
|
4
|
-
import { forModel,
|
|
4
|
+
import { forModel, getCalibrationForBudget, put } from "../config/profiles.js";
|
|
5
5
|
import { HostingProfileSchema, } from "../config/schema.js";
|
|
6
6
|
import { deleteComponentFile, deleteModelFiles, diskUsage, planDelete, totalModelBytes, } from "../models/manage.js";
|
|
7
7
|
import { deleteDisplayName, updateDisplayName } from "../models/rename-map.js";
|
|
@@ -170,13 +170,23 @@ 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 residentSupervisor = (typeof scheduler?.supervisorFor === "function" ? scheduler.supervisorFor(model.id) : null) ??
|
|
175
|
+
(supervisor.model?.id === model.id ? supervisor : null);
|
|
176
|
+
const resident = residentSupervisor !== null;
|
|
177
|
+
if (resident) {
|
|
178
|
+
if (residentSupervisor.state === "starting")
|
|
179
|
+
return "loading";
|
|
180
|
+
if (residentSupervisor.state === "stopping")
|
|
181
|
+
return "unloading";
|
|
182
|
+
}
|
|
183
|
+
const stats = scheduler?.stats();
|
|
184
|
+
if (stats?.active?.modelId === model.id)
|
|
185
|
+
return "active";
|
|
186
|
+
if ((stats?.waitingModelIds[model.id] ?? 0) > 0)
|
|
187
|
+
return "queued";
|
|
188
|
+
if (resident && residentSupervisor.state === "ready")
|
|
177
189
|
return "loaded";
|
|
178
|
-
if (supervisor.state === "starting")
|
|
179
|
-
return "loading";
|
|
180
190
|
return "not-loaded";
|
|
181
191
|
}
|
|
182
192
|
/**
|
|
@@ -187,9 +197,9 @@ function stateOf(supervisor, model) {
|
|
|
187
197
|
* client would otherwise have to correlate three unrelated lists by display name.
|
|
188
198
|
*/
|
|
189
199
|
export function buildInventoryRow(params) {
|
|
190
|
-
const { model, store, defaults, gpu, ranking, supervisor, runtimeBuild: activeRuntimeBuild = null, } = params;
|
|
200
|
+
const { model, store, defaults, gpu, ranking, supervisor, scheduler = null, runtimeBuild: activeRuntimeBuild = null, } = params;
|
|
191
201
|
const profile = forModel(store, model, defaults);
|
|
192
|
-
const calibration =
|
|
202
|
+
const calibration = getCalibrationForBudget(store, model, profile);
|
|
193
203
|
const budgetOptions = gpu
|
|
194
204
|
? { model, profile, calibration, totalVramBytes: gpu.totalBytes }
|
|
195
205
|
: null;
|
|
@@ -222,7 +232,7 @@ export function buildInventoryRow(params) {
|
|
|
222
232
|
budget: budgetOptions ? vram.budget(budgetOptions) : null,
|
|
223
233
|
maxContextThatFits: budgetOptions ? vram.maxContextThatFits(budgetOptions) : null,
|
|
224
234
|
score: ranked,
|
|
225
|
-
state: stateOf(supervisor, model),
|
|
235
|
+
state: stateOf(supervisor, scheduler, model),
|
|
226
236
|
warnings: profileWarnings(profile, model, store),
|
|
227
237
|
components: model.components?.map((component) => {
|
|
228
238
|
if (component.minRuntimeBuild === undefined ||
|
|
@@ -267,7 +277,23 @@ function resolveModel(catalog, needle) {
|
|
|
267
277
|
*/
|
|
268
278
|
function profileFromQuery(base, params, model) {
|
|
269
279
|
const patch = {};
|
|
270
|
-
|
|
280
|
+
// The samplers cost no VRAM and so change nothing in the budget this powers,
|
|
281
|
+
// but they ride in the same draft the editor sends. Parse them anyway: an
|
|
282
|
+
// unparsed key reaches sanitizeProfilePatch as the string "0.8" and throws,
|
|
283
|
+
// which would fail the whole preview over a field it does not even price.
|
|
284
|
+
const numeric = [
|
|
285
|
+
"contextSize",
|
|
286
|
+
"gpuLayers",
|
|
287
|
+
"parallelSlots",
|
|
288
|
+
"cachedChats",
|
|
289
|
+
"reasoningBudget",
|
|
290
|
+
"temperature",
|
|
291
|
+
"topP",
|
|
292
|
+
"topK",
|
|
293
|
+
"minP",
|
|
294
|
+
"presencePenalty",
|
|
295
|
+
"repeatPenalty",
|
|
296
|
+
];
|
|
271
297
|
for (const key of numeric) {
|
|
272
298
|
const raw = params.get(key);
|
|
273
299
|
if (raw !== null && raw !== "")
|
|
@@ -283,6 +309,16 @@ function profileFromQuery(base, params, model) {
|
|
|
283
309
|
if (raw !== null && raw !== "")
|
|
284
310
|
patch[key] = raw === "true" || raw === "1";
|
|
285
311
|
}
|
|
312
|
+
// Tri-state, and every spelling a client might use for it. Unknown text is
|
|
313
|
+
// dropped rather than thrown on: this field prices nothing, so a value this
|
|
314
|
+
// route cannot read must not take the whole budget preview down with it.
|
|
315
|
+
const preserve = params.get("preserveReasoning");
|
|
316
|
+
if (preserve === "true" || preserve === "on")
|
|
317
|
+
patch.preserveReasoning = true;
|
|
318
|
+
else if (preserve === "false" || preserve === "off")
|
|
319
|
+
patch.preserveReasoning = false;
|
|
320
|
+
else if (preserve === "default" || preserve === "null")
|
|
321
|
+
patch.preserveReasoning = null;
|
|
286
322
|
if (Object.keys(patch).length === 0)
|
|
287
323
|
return base;
|
|
288
324
|
return sanitizeProfilePatch(base, patch, model, runtimeBuild(null)).profile;
|
|
@@ -306,9 +342,11 @@ export function createHostApi(deps) {
|
|
|
306
342
|
// would make a daemon stop polling and see nothing.
|
|
307
343
|
events: Boolean(deps.statusEvents?.ready),
|
|
308
344
|
liveInference: Boolean(deps.statusEvents?.ready),
|
|
345
|
+
logEvents: Boolean(deps.statusEvents?.ready && deps.logEvents),
|
|
309
346
|
writable: deps.getAllowWrite(),
|
|
310
347
|
jobs: Boolean(deps.jobs),
|
|
311
348
|
restart: Boolean(deps.restart),
|
|
349
|
+
processPool: true,
|
|
312
350
|
});
|
|
313
351
|
/** Refuse a write unless the owner opted into remote configuration. */
|
|
314
352
|
const guardWrite = (res) => {
|
|
@@ -328,6 +366,7 @@ export function createHostApi(deps) {
|
|
|
328
366
|
gpu,
|
|
329
367
|
ranking,
|
|
330
368
|
supervisor: deps.supervisor,
|
|
369
|
+
scheduler: deps.scheduler,
|
|
331
370
|
runtimeBuild: runtimeBuild(deps.supervisor.runtime),
|
|
332
371
|
}));
|
|
333
372
|
};
|
|
@@ -377,16 +416,17 @@ export function createHostApi(deps) {
|
|
|
377
416
|
// A setting is only unapplied when it was changed on the currently
|
|
378
417
|
// resident model. Edits to an unloaded model take effect naturally
|
|
379
418
|
// when it is next loaded and do not earn a misleading reload badge.
|
|
380
|
-
const requiresRestart = deps.
|
|
419
|
+
const requiresRestart = Boolean((typeof deps.scheduler?.supervisorFor === "function"
|
|
420
|
+
? deps.scheduler.supervisorFor(model.id)
|
|
421
|
+
: null) ?? (deps.supervisor.model?.id === model.id ? deps.supervisor : null));
|
|
381
422
|
if (requiresRestart)
|
|
382
423
|
store.pendingReloadModelIds[model.id] = true;
|
|
383
424
|
deps.saveProfiles(store);
|
|
425
|
+
deps.log?.("model", `updated profile for ${model.displayName}${requiresRestart ? "; reload required" : ""}`);
|
|
384
426
|
// Return the recomputed budget so an edit costs one round trip rather
|
|
385
427
|
// than a write followed by a read the UI has to sequence.
|
|
386
428
|
const gpu = await deps.queryGpuInfo();
|
|
387
|
-
const calibration = profile
|
|
388
|
-
? null
|
|
389
|
-
: getCalibration(store, model, profile);
|
|
429
|
+
const calibration = getCalibrationForBudget(store, model, profile);
|
|
390
430
|
const options = gpu
|
|
391
431
|
? { model, profile, calibration, totalVramBytes: gpu.totalBytes }
|
|
392
432
|
: null;
|
|
@@ -448,6 +488,7 @@ export function createHostApi(deps) {
|
|
|
448
488
|
// the brain is restarted. Reset already follows this pattern below.
|
|
449
489
|
const catalog = deps.rescan();
|
|
450
490
|
const updated = resolveModel(catalog, model.id);
|
|
491
|
+
deps.log?.("library", `renamed ${model.displayName} to ${displayName}`);
|
|
451
492
|
sendJson(res, { displayName: updated ? updated.displayName : displayName });
|
|
452
493
|
});
|
|
453
494
|
};
|
|
@@ -460,6 +501,7 @@ export function createHostApi(deps) {
|
|
|
460
501
|
deleteDisplayName(model.id);
|
|
461
502
|
const catalog = deps.rescan();
|
|
462
503
|
const updated = resolveModel(catalog, model.id);
|
|
504
|
+
deps.log?.("library", `reset display name for ${model.displayName}`);
|
|
463
505
|
sendJson(res, { displayName: updated ? updated.displayName : model.displayName });
|
|
464
506
|
});
|
|
465
507
|
};
|
|
@@ -483,7 +525,7 @@ export function createHostApi(deps) {
|
|
|
483
525
|
const options = {
|
|
484
526
|
model,
|
|
485
527
|
profile,
|
|
486
|
-
calibration:
|
|
528
|
+
calibration: getCalibrationForBudget(store, model, profile),
|
|
487
529
|
totalVramBytes: gpu.totalBytes,
|
|
488
530
|
};
|
|
489
531
|
sendJson(res, {
|
|
@@ -511,15 +553,21 @@ export function createHostApi(deps) {
|
|
|
511
553
|
return;
|
|
512
554
|
}
|
|
513
555
|
try {
|
|
556
|
+
deps.log?.("model", `loading ${model.displayName}`);
|
|
514
557
|
await deps.loadModel(model);
|
|
558
|
+
deps.log?.("model", `loaded ${model.displayName}`);
|
|
559
|
+
const resident = (typeof deps.scheduler?.supervisorFor === "function"
|
|
560
|
+
? deps.scheduler.supervisorFor(model.id)
|
|
561
|
+
: null) ?? deps.supervisor;
|
|
515
562
|
sendJson(res, {
|
|
516
|
-
status:
|
|
563
|
+
status: resident.status(),
|
|
517
564
|
// What actually got used: loadModel fits the profile to VRAM, so the
|
|
518
565
|
// context here may be lower than the one saved.
|
|
519
|
-
profile:
|
|
566
|
+
profile: resident.profile,
|
|
520
567
|
});
|
|
521
568
|
}
|
|
522
569
|
catch (error) {
|
|
570
|
+
deps.log?.("model", `failed to load ${model.displayName}: ${errorMessage(error)}`);
|
|
523
571
|
sendError(res, 409, `could not load ${model.displayName}: ${errorMessage(error)}`);
|
|
524
572
|
}
|
|
525
573
|
})();
|
|
@@ -527,7 +575,12 @@ export function createHostApi(deps) {
|
|
|
527
575
|
const handleUnload = (res) => {
|
|
528
576
|
void (async () => {
|
|
529
577
|
try {
|
|
530
|
-
|
|
578
|
+
deps.log?.("model", "unloading resident model");
|
|
579
|
+
if (deps.unloadModels)
|
|
580
|
+
await deps.unloadModels();
|
|
581
|
+
else
|
|
582
|
+
await deps.supervisor.stop();
|
|
583
|
+
deps.log?.("model", "resident model unloaded");
|
|
531
584
|
sendJson(res, { status: deps.supervisor.status() });
|
|
532
585
|
}
|
|
533
586
|
catch (error) {
|
|
@@ -536,13 +589,17 @@ export function createHostApi(deps) {
|
|
|
536
589
|
})();
|
|
537
590
|
};
|
|
538
591
|
const handleDelete = (res, model) => {
|
|
539
|
-
|
|
592
|
+
const resident = typeof deps.scheduler?.supervisorFor === "function"
|
|
593
|
+
? deps.scheduler.supervisorFor(model.id)
|
|
594
|
+
: null;
|
|
595
|
+
if (resident && resident.state !== "stopped") {
|
|
540
596
|
sendError(res, 409, "stop the model before deleting it");
|
|
541
597
|
return;
|
|
542
598
|
}
|
|
543
599
|
try {
|
|
544
600
|
const plan = deleteModelFiles(model);
|
|
545
601
|
const catalog = deps.rescan();
|
|
602
|
+
deps.log?.("library", `deleted ${model.displayName}; freed ${plan.bytes} bytes`);
|
|
546
603
|
sendJson(res, {
|
|
547
604
|
deleted: plan.files,
|
|
548
605
|
freedBytes: plan.bytes,
|
|
@@ -555,13 +612,17 @@ export function createHostApi(deps) {
|
|
|
555
612
|
}
|
|
556
613
|
};
|
|
557
614
|
const handleComponentDelete = (res, model, componentId) => {
|
|
558
|
-
|
|
615
|
+
const resident = typeof deps.scheduler?.supervisorFor === "function"
|
|
616
|
+
? deps.scheduler.supervisorFor(model.id)
|
|
617
|
+
: null;
|
|
618
|
+
if (resident && resident.state !== "stopped") {
|
|
559
619
|
sendError(res, 409, "stop the model before removing a bundle component");
|
|
560
620
|
return;
|
|
561
621
|
}
|
|
562
622
|
try {
|
|
563
623
|
const plan = deleteComponentFile(model, componentId);
|
|
564
624
|
deps.rescan();
|
|
625
|
+
deps.log?.("library", `deleted ${componentId} from ${model.displayName}; freed ${plan.bytes} bytes`);
|
|
565
626
|
sendJson(res, {
|
|
566
627
|
deleted: plan.files,
|
|
567
628
|
freedBytes: plan.bytes,
|
|
@@ -575,10 +636,11 @@ export function createHostApi(deps) {
|
|
|
575
636
|
const handleLogs = (res, params) => {
|
|
576
637
|
const raw = Number(params.get("limit"));
|
|
577
638
|
const limit = Number.isFinite(raw) && raw > 0 ? Math.min(Math.round(raw), 1000) : DEFAULT_LOG_LINES;
|
|
639
|
+
const session = deps.runLog?.tail(limit);
|
|
578
640
|
const all = deps.supervisor.logLines;
|
|
579
641
|
sendJson(res, {
|
|
580
|
-
lines: all.slice(-limit),
|
|
581
|
-
total: all.length,
|
|
642
|
+
lines: session?.lines ?? all.slice(-limit),
|
|
643
|
+
total: session?.total ?? all.length,
|
|
582
644
|
state: deps.supervisor.state,
|
|
583
645
|
command: deps.supervisor.command,
|
|
584
646
|
});
|
|
@@ -610,7 +672,13 @@ export function createHostApi(deps) {
|
|
|
610
672
|
return;
|
|
611
673
|
res.write(`event: status\ndata: ${JSON.stringify(snapshot)}\n\n`);
|
|
612
674
|
};
|
|
675
|
+
const writeLog = (line) => {
|
|
676
|
+
if (res.writableEnded || res.destroyed)
|
|
677
|
+
return;
|
|
678
|
+
res.write(`event: log\ndata: ${JSON.stringify({ line })}\n\n`);
|
|
679
|
+
};
|
|
613
680
|
let unsubscribe = () => { };
|
|
681
|
+
let unsubscribeLogs = () => { };
|
|
614
682
|
const keepalive = setInterval(() => {
|
|
615
683
|
if (res.writableEnded || res.destroyed)
|
|
616
684
|
return;
|
|
@@ -620,6 +688,7 @@ export function createHostApi(deps) {
|
|
|
620
688
|
const teardown = () => {
|
|
621
689
|
clearInterval(keepalive);
|
|
622
690
|
unsubscribe();
|
|
691
|
+
unsubscribeLogs();
|
|
623
692
|
};
|
|
624
693
|
// The publisher ends the response on host shutdown: an open SSE response is
|
|
625
694
|
// an open connection, and `server.close()` waits for those.
|
|
@@ -628,6 +697,7 @@ export function createHostApi(deps) {
|
|
|
628
697
|
if (!res.writableEnded && !res.destroyed)
|
|
629
698
|
res.end();
|
|
630
699
|
});
|
|
700
|
+
unsubscribeLogs = deps.logEvents?.subscribe(writeLog) ?? (() => { });
|
|
631
701
|
// Both ends matter: `close` on the request covers a client that walked away,
|
|
632
702
|
// and `close` on the response covers the service shutting the socket down.
|
|
633
703
|
req.on("close", teardown);
|
|
@@ -664,6 +734,7 @@ export function createHostApi(deps) {
|
|
|
664
734
|
}
|
|
665
735
|
if (!guardWrite(res))
|
|
666
736
|
return true;
|
|
737
|
+
deps.log?.("server", "restart requested through the management API");
|
|
667
738
|
sendJson(res, { accepted: true });
|
|
668
739
|
queueMicrotask(() => deps.restart?.());
|
|
669
740
|
return true;
|
|
@@ -737,6 +808,7 @@ export function createHostApi(deps) {
|
|
|
737
808
|
"--",
|
|
738
809
|
model,
|
|
739
810
|
],
|
|
811
|
+
pull: { entryKey: model, components: components ?? [] },
|
|
740
812
|
};
|
|
741
813
|
},
|
|
742
814
|
},
|
|
@@ -763,6 +835,7 @@ export function createHostApi(deps) {
|
|
|
763
835
|
"--",
|
|
764
836
|
repo,
|
|
765
837
|
],
|
|
838
|
+
pull: { entryKey: `${repo}#${quant}`, components: components ?? [] },
|
|
766
839
|
};
|
|
767
840
|
},
|
|
768
841
|
},
|
|
@@ -838,7 +911,9 @@ export function createHostApi(deps) {
|
|
|
838
911
|
}
|
|
839
912
|
try {
|
|
840
913
|
const spec = start.makeArgs(result.body);
|
|
841
|
-
sendJson(res, {
|
|
914
|
+
sendJson(res, {
|
|
915
|
+
job: deps.jobs?.start(start.kind, spec.target, spec.args, spec.pull) ?? null,
|
|
916
|
+
});
|
|
842
917
|
}
|
|
843
918
|
catch (error) {
|
|
844
919
|
sendError(res, 400, errorMessage(error));
|
|
@@ -847,27 +922,32 @@ export function createHostApi(deps) {
|
|
|
847
922
|
return true;
|
|
848
923
|
}
|
|
849
924
|
if (route === "/__host/catalog" && method === "GET") {
|
|
850
|
-
|
|
925
|
+
deps.log?.("library", "refreshing the model catalog");
|
|
926
|
+
void deps.jobs
|
|
927
|
+
?.query(["catalog", "--json"], "library")
|
|
928
|
+
.then((models) => sendJson(res, { models }));
|
|
851
929
|
return true;
|
|
852
930
|
}
|
|
853
931
|
if (route === "/__host/runtimes" && method === "GET") {
|
|
854
932
|
void deps.jobs
|
|
855
|
-
?.query(["runtime", "list", "--json"])
|
|
933
|
+
?.query(["runtime", "list", "--json"], "library")
|
|
856
934
|
.then((runtimes) => sendJson(res, { runtimes }));
|
|
857
935
|
return true;
|
|
858
936
|
}
|
|
859
937
|
if (route === "/__host/hf/search" && method === "GET") {
|
|
860
938
|
const query = params.get("query") ?? "";
|
|
861
939
|
const limit = Math.max(1, Math.min(100, Number(params.get("limit")) || 25));
|
|
940
|
+
deps.log?.("library", `searching Hugging Face for ${JSON.stringify(query)} (limit ${limit})`);
|
|
862
941
|
void deps.jobs
|
|
863
|
-
?.query(["search", "--json", "--limit", String(limit), "--", query])
|
|
942
|
+
?.query(["search", "--json", "--limit", String(limit), "--", query], "library")
|
|
864
943
|
.then((results) => sendJson(res, { results }));
|
|
865
944
|
return true;
|
|
866
945
|
}
|
|
867
946
|
if (route === "/__host/hf/quants" && method === "GET") {
|
|
868
947
|
const repo = params.get("repo") ?? "";
|
|
948
|
+
deps.log?.("library", `listing Hugging Face quants for ${repo}`);
|
|
869
949
|
void deps.jobs
|
|
870
|
-
?.query(["add", "--list-quants", "--json", "--", repo])
|
|
950
|
+
?.query(["add", "--list-quants", "--json", "--", repo], "library")
|
|
871
951
|
.then((quants) => sendJson(res, { quants }));
|
|
872
952
|
return true;
|
|
873
953
|
}
|
|
@@ -913,6 +993,7 @@ export function createHostApi(deps) {
|
|
|
913
993
|
// without restarting the host or unloading its resident model.
|
|
914
994
|
if (route === "/__host/models/rescan" && method === "POST") {
|
|
915
995
|
const models = deps.rescan();
|
|
996
|
+
deps.log?.("library", `rescanned model library: ${models.length} models`);
|
|
916
997
|
sendJson(res, { models: models.length });
|
|
917
998
|
return true;
|
|
918
999
|
}
|
|
@@ -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
|