@otto-code/brain 0.8.6 → 0.8.8
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/cli.js +2 -1
- package/dist/commands/catalog.d.ts +2 -0
- package/dist/commands/catalog.js +1 -0
- package/dist/commands/pull.d.ts +1 -0
- package/dist/commands/pull.js +55 -3
- package/dist/commands/runtime.d.ts +3 -0
- package/dist/commands/runtime.js +65 -18
- package/dist/commands/search.d.ts +3 -0
- package/dist/commands/search.js +36 -7
- package/dist/config/profile-edit.d.ts +1 -1
- package/dist/config/profile-edit.js +40 -10
- package/dist/config/profiles.js +69 -13
- package/dist/config/schema.d.ts +528 -0
- package/dist/config/schema.js +24 -0
- package/dist/config/store.js +16 -14
- package/dist/models/download.d.ts +7 -0
- package/dist/models/download.js +160 -17
- package/dist/models/enrich.d.ts +0 -19
- package/dist/models/enrich.js +95 -4
- package/dist/models/hf.d.ts +14 -1
- package/dist/models/hf.js +239 -6
- package/dist/models/index.d.ts +2 -2
- package/dist/models/index.js +8 -5
- package/dist/models/manage.d.ts +4 -0
- package/dist/models/manage.js +34 -4
- package/dist/models/scan.js +8 -42
- package/dist/runtime/args.js +5 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/managed.d.ts +40 -0
- package/dist/runtime/managed.js +146 -7
- package/dist/service/host-api.d.ts +3 -2
- package/dist/service/host-api.js +118 -8
- package/dist/service/serve.js +39 -12
- package/dist/types.d.ts +20 -0
- package/dist/vram.d.ts +3 -0
- package/dist/vram.js +21 -3
- package/package.json +1 -1
package/dist/runtime/managed.js
CHANGED
|
@@ -63,8 +63,75 @@ import { buildEnv } from "./args.js";
|
|
|
63
63
|
*/
|
|
64
64
|
export const DEFAULT_LLAMA_BUILD = "b10265";
|
|
65
65
|
const LLAMA_RELEASE_BASE = "https://github.com/ggml-org/llama.cpp/releases/download";
|
|
66
|
+
const LLAMA_RELEASE_API = "https://api.github.com/repos/ggml-org/llama.cpp/releases";
|
|
67
|
+
/**
|
|
68
|
+
* How many releases to read when resolving "latest".
|
|
69
|
+
*
|
|
70
|
+
* Not 1: the newest release is not necessarily a numbered build (upstream also
|
|
71
|
+
* publishes differently tagged and pre-release entries), and a single
|
|
72
|
+
* non-matching entry would otherwise read as "llama.cpp published no release
|
|
73
|
+
* builds" while the very next one qualified.
|
|
74
|
+
*/
|
|
75
|
+
const LATEST_BUILD_SCAN_PAGE = 30;
|
|
76
|
+
/** Official llama.cpp builds, newest first. Only numbered build releases are actionable. */
|
|
77
|
+
export async function listRuntimeReleases(limit = 100) {
|
|
78
|
+
const response = await fetch(`${LLAMA_RELEASE_API}?per_page=${Math.min(Math.max(limit, 1), 100)}`, {
|
|
79
|
+
headers: { Accept: "application/vnd.github+json" },
|
|
80
|
+
});
|
|
81
|
+
if (!response.ok)
|
|
82
|
+
throw new Error(`could not fetch llama.cpp releases (${response.status})`);
|
|
83
|
+
const releases = await response.json();
|
|
84
|
+
if (!Array.isArray(releases))
|
|
85
|
+
throw new Error("llama.cpp releases response was invalid");
|
|
86
|
+
return releases.flatMap((release) => {
|
|
87
|
+
if (!release || typeof release !== "object")
|
|
88
|
+
return [];
|
|
89
|
+
const value = release;
|
|
90
|
+
const build = typeof value.tag_name === "string" ? value.tag_name : "";
|
|
91
|
+
return /^b\d+$/.test(build)
|
|
92
|
+
? [{ build, publishedAt: typeof value.published_at === "string" ? value.published_at : null }]
|
|
93
|
+
: [];
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** Resolve the latest official build at the last responsible moment. */
|
|
97
|
+
export async function latestRuntimeBuild() {
|
|
98
|
+
const [latest] = await listRuntimeReleases(LATEST_BUILD_SCAN_PAGE);
|
|
99
|
+
if (!latest)
|
|
100
|
+
throw new Error("llama.cpp published no release builds");
|
|
101
|
+
return latest.build;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The build to install for a "latest" request, with the pin as the safety net.
|
|
105
|
+
*
|
|
106
|
+
* Asking upstream is a best effort, never a precondition. `listRuntimeReleases`
|
|
107
|
+
* hits api.github.com unauthenticated, which is rate limited to 60 requests per
|
|
108
|
+
* hour per IP: behind NAT, on a corporate egress or on a CI runner that is a 403
|
|
109
|
+
* on an address that has spent its budget on something else entirely. An install
|
|
110
|
+
* the pinned build can serve must not fail because a version lookup did.
|
|
111
|
+
*
|
|
112
|
+
* The warning is deliberately one line. The daemon's BrainOpsManager keeps the
|
|
113
|
+
* *last* stderr line as the job's message, so a wrapped warning would surface in
|
|
114
|
+
* the GUI as a dangling fragment.
|
|
115
|
+
*/
|
|
116
|
+
export async function resolveLatestBuildOrPin() {
|
|
117
|
+
try {
|
|
118
|
+
return { build: await latestRuntimeBuild(), warning: null };
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
return {
|
|
122
|
+
build: DEFAULT_LLAMA_BUILD,
|
|
123
|
+
warning: `could not look up the latest llama.cpp build (${oneLine(error)}), so Otto installed` +
|
|
124
|
+
` the pinned build ${DEFAULT_LLAMA_BUILD} instead.`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Flatten a message to one line, so a warning survives the last-line rule. */
|
|
129
|
+
function oneLine(error) {
|
|
130
|
+
return (error instanceof Error ? error.message : String(error)).replace(/\s+/gu, " ").trim();
|
|
131
|
+
}
|
|
66
132
|
/** The CUDA toolkit version whose Windows assets we pin. */
|
|
67
133
|
const WINDOWS_CUDA = "12.4";
|
|
134
|
+
const MANAGED_RUNTIME_METADATA_FILE = ".otto-runtime.json";
|
|
68
135
|
/** The binary name llama.cpp ships for a platform. */
|
|
69
136
|
export function serverExeName(platform = process.platform) {
|
|
70
137
|
return platform === "win32" ? "llama-server.exe" : "llama-server";
|
|
@@ -181,6 +248,34 @@ function slug(spec) {
|
|
|
181
248
|
.replace(/[^a-z0-9]+/g, "-")
|
|
182
249
|
.replace(/(^-|-$)/g, "");
|
|
183
250
|
}
|
|
251
|
+
function displayNameForManagedRuntime(label, version) {
|
|
252
|
+
return `${label.replace(/\s*\(managed\)$/iu, "")} · ${version} (Otto managed)`;
|
|
253
|
+
}
|
|
254
|
+
function legacyManagedDisplayName(dirName, version) {
|
|
255
|
+
const cuda = /^cuda-(\d+)-(\d+)-managed(?:-|$)/iu.exec(dirName);
|
|
256
|
+
if (cuda)
|
|
257
|
+
return `CUDA ${cuda[1]}.${cuda[2]} · ${version} (Otto managed)`;
|
|
258
|
+
if (/^vulkan-managed(?:-|$)/iu.test(dirName))
|
|
259
|
+
return `Vulkan · ${version} (Otto managed)`;
|
|
260
|
+
if (/^metal-managed(?:-|$)/iu.test(dirName))
|
|
261
|
+
return `Metal · ${version} (Otto managed)`;
|
|
262
|
+
if (/^cpu-managed(?:-|$)/iu.test(dirName))
|
|
263
|
+
return `CPU · ${version} (Otto managed)`;
|
|
264
|
+
return `${version} (Otto managed)`;
|
|
265
|
+
}
|
|
266
|
+
function readManagedRuntimeDisplayName(root, dirName, version) {
|
|
267
|
+
try {
|
|
268
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(root, MANAGED_RUNTIME_METADATA_FILE), "utf8"));
|
|
269
|
+
if (typeof parsed.displayName === "string" && parsed.displayName.trim()) {
|
|
270
|
+
return parsed.displayName;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
// Existing installations predate metadata; their directory name remains a
|
|
275
|
+
// backend-only migration source, never a UI presentation contract.
|
|
276
|
+
}
|
|
277
|
+
return legacyManagedDisplayName(dirName, version);
|
|
278
|
+
}
|
|
184
279
|
/** Recursively find the first file named `name` under `dir`. */
|
|
185
280
|
function findFile(dir, name) {
|
|
186
281
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -230,9 +325,11 @@ export function listManagedRuntimes(runtimesDir, platform = process.platform) {
|
|
|
230
325
|
const exe = findFile(root, exeName);
|
|
231
326
|
if (!exe)
|
|
232
327
|
continue;
|
|
328
|
+
const version = entry.name.replace(/^.*-/, "");
|
|
233
329
|
found.push({
|
|
234
330
|
label: entry.name,
|
|
235
|
-
|
|
331
|
+
displayName: readManagedRuntimeDisplayName(root, entry.name, version),
|
|
332
|
+
version,
|
|
236
333
|
dir: path.dirname(exe),
|
|
237
334
|
exe,
|
|
238
335
|
vendorDir: null,
|
|
@@ -242,8 +339,24 @@ export function listManagedRuntimes(runtimesDir, platform = process.platform) {
|
|
|
242
339
|
return found.sort((a, b) => managedVariantRank(a.label) - managedVariantRank(b.label) ||
|
|
243
340
|
buildNumber(b.version) - buildNumber(a.version));
|
|
244
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* An asset upstream does not serve at all, which is what a renamed asset or a
|
|
344
|
+
* tag without the expected build looks like from here. Distinguished from every
|
|
345
|
+
* other download failure because it is the one a caller can answer by retrying
|
|
346
|
+
* a build whose asset names are pinned and tested, rather than by retrying the
|
|
347
|
+
* same URL later.
|
|
348
|
+
*/
|
|
349
|
+
export class MissingAssetError extends Error {
|
|
350
|
+
constructor(url) {
|
|
351
|
+
super(`download failed (404) for ${url}`);
|
|
352
|
+
this.url = url;
|
|
353
|
+
this.name = "MissingAssetError";
|
|
354
|
+
}
|
|
355
|
+
}
|
|
245
356
|
async function downloadFile(url, dest, onProgress) {
|
|
246
357
|
const response = await fetch(url);
|
|
358
|
+
if (response.status === 404)
|
|
359
|
+
throw new MissingAssetError(url);
|
|
247
360
|
if (!response.ok || !response.body) {
|
|
248
361
|
throw new Error(`download failed (${response.status}) for ${url}`);
|
|
249
362
|
}
|
|
@@ -480,13 +593,26 @@ export async function extractArchive(archivePath, destDir, platform = process.pl
|
|
|
480
593
|
export async function installManagedRuntime(spec, runtimesDir, onProgress) {
|
|
481
594
|
const platform = spec.platform ?? process.platform;
|
|
482
595
|
const targetDir = path.join(runtimesDir, slug(spec));
|
|
596
|
+
const preexisting = fs.existsSync(targetDir);
|
|
483
597
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
484
|
-
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
598
|
+
try {
|
|
599
|
+
for (const url of spec.assets) {
|
|
600
|
+
const archivePath = path.join(targetDir, path.basename(new URL(url).pathname));
|
|
601
|
+
await downloadFile(url, archivePath, onProgress);
|
|
602
|
+
onProgress?.({ phase: "extracting", asset: url });
|
|
603
|
+
await extractArchive(archivePath, targetDir, platform);
|
|
604
|
+
fs.rmSync(archivePath, { force: true });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
// A half-installed directory is worse than no directory: Windows CUDA needs
|
|
609
|
+
// two archives, so a 404 on the companion leaves a llama-server with no CUDA
|
|
610
|
+
// runtime beside it, and `listManagedRuntimes` ranks by build number - that
|
|
611
|
+
// newer, broken build would then outrank the working runtime it replaced.
|
|
612
|
+
// Only a directory this call created is removed; an existing install stands.
|
|
613
|
+
if (!preexisting)
|
|
614
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
615
|
+
throw error;
|
|
490
616
|
}
|
|
491
617
|
const exeName = serverExeName(platform);
|
|
492
618
|
const exe = findFile(targetDir, exeName);
|
|
@@ -499,6 +625,7 @@ export async function installManagedRuntime(spec, runtimesDir, onProgress) {
|
|
|
499
625
|
}
|
|
500
626
|
const runtime = {
|
|
501
627
|
label: spec.label,
|
|
628
|
+
displayName: displayNameForManagedRuntime(spec.label, spec.version),
|
|
502
629
|
version: spec.version,
|
|
503
630
|
dir: path.dirname(exe),
|
|
504
631
|
exe,
|
|
@@ -508,7 +635,19 @@ export async function installManagedRuntime(spec, runtimesDir, onProgress) {
|
|
|
508
635
|
// Before reporting success. An install that cannot exec is not an install, and
|
|
509
636
|
// the loader error names the cause far better than the later spawn failure does.
|
|
510
637
|
await verifyRuntimeExecutable(runtime, platform);
|
|
638
|
+
fs.writeFileSync(path.join(targetDir, MANAGED_RUNTIME_METADATA_FILE), `${JSON.stringify({ displayName: runtime.displayName })}\n`);
|
|
511
639
|
onProgress?.({ phase: "done" });
|
|
512
640
|
return runtime;
|
|
513
641
|
}
|
|
642
|
+
/** Remove one Otto-managed runtime. LM Studio files are outside this root. */
|
|
643
|
+
export function removeManagedRuntime(runtimesDir, name) {
|
|
644
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(name))
|
|
645
|
+
throw new Error("invalid runtime name");
|
|
646
|
+
const root = path.resolve(runtimesDir);
|
|
647
|
+
const target = path.resolve(root, name);
|
|
648
|
+
if (path.dirname(target) !== root || !fs.existsSync(target)) {
|
|
649
|
+
throw new Error(`managed runtime not found: ${name}`);
|
|
650
|
+
}
|
|
651
|
+
fs.rmSync(target, { recursive: true, force: false });
|
|
652
|
+
}
|
|
514
653
|
//# sourceMappingURL=managed.js.map
|
|
@@ -82,11 +82,11 @@ export interface HostCapabilities {
|
|
|
82
82
|
/** A long-running operation owned by this brain host, not its caller. */
|
|
83
83
|
export interface HostJob {
|
|
84
84
|
id: string;
|
|
85
|
-
kind: "pull" | "runtime-install" | "calibrate" | "sweep" | "bench";
|
|
85
|
+
kind: "pull" | "runtime-install" | "runtime-remove" | "calibrate" | "sweep" | "bench";
|
|
86
86
|
label: string;
|
|
87
87
|
target: string | null;
|
|
88
88
|
status: "running" | "succeeded" | "failed" | "canceled";
|
|
89
|
-
percent: null;
|
|
89
|
+
percent: number | null;
|
|
90
90
|
message: string | null;
|
|
91
91
|
error: string | null;
|
|
92
92
|
startedAt: string;
|
|
@@ -153,6 +153,7 @@ export interface InventoryRow {
|
|
|
153
153
|
score: RankedModel | null;
|
|
154
154
|
state: "loaded" | "loading" | "not-loaded";
|
|
155
155
|
warnings: ReturnType<typeof profileWarnings>;
|
|
156
|
+
components: NonNullable<Model["components"]> | null;
|
|
156
157
|
}
|
|
157
158
|
/**
|
|
158
159
|
* Join one model's scan row, GGUF metadata, saved profile, calibration, VRAM
|
package/dist/service/host-api.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { calibrationInfo, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, } from "../config/profile-edit.js";
|
|
2
2
|
import { forModel, getCalibration, put } from "../config/profiles.js";
|
|
3
|
-
import { deleteModelFiles, diskUsage, planDelete, totalModelBytes } from "../models/manage.js";
|
|
3
|
+
import { deleteComponentFile, deleteModelFiles, diskUsage, planDelete, totalModelBytes, } from "../models/manage.js";
|
|
4
4
|
import { deleteDisplayName, updateDisplayName } from "../models/rename-map.js";
|
|
5
5
|
import * as vram from "../vram.js";
|
|
6
6
|
import { errorMessage, readJsonBody, sendError, sendJson } from "./http-util.js";
|
|
@@ -60,7 +60,11 @@ export function buildInventoryRow(params) {
|
|
|
60
60
|
contextLength: model.metadata?.contextLength ?? null,
|
|
61
61
|
blockCount: model.metadata?.blockCount ?? null,
|
|
62
62
|
headCountKv: model.metadata?.headCountKv ?? null,
|
|
63
|
-
|
|
63
|
+
// A bundle declares vision capability even before its projector is
|
|
64
|
+
// downloaded. The badge describes what the model supports, while the
|
|
65
|
+
// profile's component row describes whether that artifact is ready.
|
|
66
|
+
hasProjector: Boolean(model.mmprojPath) ||
|
|
67
|
+
Boolean(model.components?.some((component) => component.role === "vision_projector")),
|
|
64
68
|
reasoning: Boolean(model.metadata?.reasoning ?? model.thinking),
|
|
65
69
|
mtp: Boolean(model.features?.mtp),
|
|
66
70
|
distilled: Boolean(model.features?.distilled),
|
|
@@ -73,8 +77,24 @@ export function buildInventoryRow(params) {
|
|
|
73
77
|
score: ranked,
|
|
74
78
|
state: stateOf(supervisor, model),
|
|
75
79
|
warnings: profileWarnings(profile, model, store),
|
|
80
|
+
components: model.components ?? null,
|
|
76
81
|
};
|
|
77
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Reject a request value that the CLI would read as a flag rather than as a
|
|
85
|
+
* value.
|
|
86
|
+
*
|
|
87
|
+
* An option value has to sit before the `--` separator by construction - the
|
|
88
|
+
* separator only ends *positional* parsing - so unlike `model` and `repo` these
|
|
89
|
+
* cannot be moved out of harm's way, and the argv is unambiguous only if the
|
|
90
|
+
* value itself is. The throw surfaces as the route's 400, next to the type
|
|
91
|
+
* checks in each `makeArgs`.
|
|
92
|
+
*/
|
|
93
|
+
function optionValue(label, value) {
|
|
94
|
+
if (value.startsWith("-"))
|
|
95
|
+
throw new Error(`${label} must not start with "-"`);
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
78
98
|
/** Resolve a model by id or display name, the same way the completion path does. */
|
|
79
99
|
function resolveModel(catalog, needle) {
|
|
80
100
|
if (!needle)
|
|
@@ -352,6 +372,24 @@ export function createHostApi(deps) {
|
|
|
352
372
|
sendError(res, 500, `could not delete ${model.displayName}: ${errorMessage(error)}`);
|
|
353
373
|
}
|
|
354
374
|
};
|
|
375
|
+
const handleComponentDelete = (res, model, componentId) => {
|
|
376
|
+
if (deps.supervisor.model?.id === model.id && deps.supervisor.state !== "stopped") {
|
|
377
|
+
sendError(res, 409, "stop the model before removing a bundle component");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
const plan = deleteComponentFile(model, componentId);
|
|
382
|
+
deps.rescan();
|
|
383
|
+
sendJson(res, {
|
|
384
|
+
deleted: plan.files,
|
|
385
|
+
freedBytes: plan.bytes,
|
|
386
|
+
componentIds: plan.componentIds,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
sendError(res, 409, errorMessage(error));
|
|
391
|
+
}
|
|
392
|
+
};
|
|
355
393
|
const handleLogs = (res, params) => {
|
|
356
394
|
const raw = Number(params.get("limit"));
|
|
357
395
|
const limit = Number.isFinite(raw) && raw > 0 ? Math.min(Math.round(raw), 1000) : DEFAULT_LOG_LINES;
|
|
@@ -482,7 +520,7 @@ export function createHostApi(deps) {
|
|
|
482
520
|
sendJson(res, {
|
|
483
521
|
job: deps.jobs?.start("bench", model ?? null, [
|
|
484
522
|
"bench",
|
|
485
|
-
...(model ? ["--model", model] : []),
|
|
523
|
+
...(model ? ["--model", optionValue("model", model)] : []),
|
|
486
524
|
]) ?? null,
|
|
487
525
|
});
|
|
488
526
|
}
|
|
@@ -499,7 +537,25 @@ export function createHostApi(deps) {
|
|
|
499
537
|
const model = body.model;
|
|
500
538
|
if (typeof model !== "string" || !model)
|
|
501
539
|
throw new Error("model is required");
|
|
502
|
-
|
|
540
|
+
const components = body.components;
|
|
541
|
+
const quant = body.quant;
|
|
542
|
+
if (components !== undefined &&
|
|
543
|
+
(!Array.isArray(components) || !components.every((id) => typeof id === "string"))) {
|
|
544
|
+
throw new Error("components must be component ids");
|
|
545
|
+
}
|
|
546
|
+
if (quant !== undefined && typeof quant !== "string")
|
|
547
|
+
throw new Error("quant must be a string");
|
|
548
|
+
return {
|
|
549
|
+
target: model,
|
|
550
|
+
args: [
|
|
551
|
+
"pull",
|
|
552
|
+
...(typeof quant === "string" ? ["--quant", optionValue("quant", quant)] : []),
|
|
553
|
+
...(components ?? []).flatMap((id) => ["--component", optionValue("component", id)]),
|
|
554
|
+
"--json",
|
|
555
|
+
"--",
|
|
556
|
+
model,
|
|
557
|
+
],
|
|
558
|
+
};
|
|
503
559
|
},
|
|
504
560
|
},
|
|
505
561
|
"/__host/jobs/add": {
|
|
@@ -507,11 +563,24 @@ export function createHostApi(deps) {
|
|
|
507
563
|
makeArgs: (body) => {
|
|
508
564
|
const repo = body.repo;
|
|
509
565
|
const quant = body.quant;
|
|
566
|
+
const components = body.components;
|
|
510
567
|
if (typeof repo !== "string" || !repo || typeof quant !== "string" || !quant)
|
|
511
568
|
throw new Error("repo and quant are required");
|
|
569
|
+
if (components !== undefined &&
|
|
570
|
+
(!Array.isArray(components) || !components.every((id) => typeof id === "string")))
|
|
571
|
+
throw new Error("components must be component ids");
|
|
512
572
|
return {
|
|
513
573
|
target: `${repo}#${quant}`,
|
|
514
|
-
args: [
|
|
574
|
+
args: [
|
|
575
|
+
"add",
|
|
576
|
+
"--quant",
|
|
577
|
+
optionValue("quant", quant),
|
|
578
|
+
...(components === undefined ? [] : ["--primary-only"]),
|
|
579
|
+
...(components ?? []).flatMap((id) => ["--component", optionValue("component", id)]),
|
|
580
|
+
"--json",
|
|
581
|
+
"--",
|
|
582
|
+
repo,
|
|
583
|
+
],
|
|
515
584
|
};
|
|
516
585
|
},
|
|
517
586
|
},
|
|
@@ -527,18 +596,36 @@ export function createHostApi(deps) {
|
|
|
527
596
|
"runtime",
|
|
528
597
|
"install",
|
|
529
598
|
"--json",
|
|
530
|
-
...(typeof build === "string" ? ["--build", build] : []),
|
|
599
|
+
...(typeof build === "string" ? ["--build", optionValue("build", build)] : []),
|
|
531
600
|
],
|
|
532
601
|
};
|
|
533
602
|
},
|
|
534
603
|
},
|
|
604
|
+
// Removal is host-owned for the same reason installation is: the runtimes
|
|
605
|
+
// directory belongs to this machine. A daemon in brain.mode=remote that ran
|
|
606
|
+
// this locally would delete a same-named runtime out of its own OTTO_HOME.
|
|
607
|
+
"/__host/jobs/runtime-remove": {
|
|
608
|
+
kind: "runtime-remove",
|
|
609
|
+
makeArgs: (body) => {
|
|
610
|
+
const name = body.name;
|
|
611
|
+
if (typeof name !== "string" || !name)
|
|
612
|
+
throw new Error("name is required");
|
|
613
|
+
// Pass the name as an operand, not as a flag candidate. The CLI still
|
|
614
|
+
// owns the real safety check (the name regex plus the parent-dir
|
|
615
|
+
// assertion in removeManagedRuntime).
|
|
616
|
+
return { target: name, args: ["runtime", "remove", "--json", "--", name] };
|
|
617
|
+
},
|
|
618
|
+
},
|
|
535
619
|
"/__host/jobs/calibrate": {
|
|
536
620
|
kind: "calibrate",
|
|
537
621
|
makeArgs: (body) => {
|
|
538
622
|
const model = body.model;
|
|
539
623
|
if (typeof model !== "string" || !model)
|
|
540
624
|
throw new Error("model is required");
|
|
541
|
-
return {
|
|
625
|
+
return {
|
|
626
|
+
target: model,
|
|
627
|
+
args: ["calibrate", "--model", optionValue("model", model), "--json"],
|
|
628
|
+
};
|
|
542
629
|
},
|
|
543
630
|
},
|
|
544
631
|
"/__host/jobs/sweep": {
|
|
@@ -547,7 +634,10 @@ export function createHostApi(deps) {
|
|
|
547
634
|
const model = body.model;
|
|
548
635
|
if (typeof model !== "string" || !model)
|
|
549
636
|
throw new Error("model is required");
|
|
550
|
-
return {
|
|
637
|
+
return {
|
|
638
|
+
target: model,
|
|
639
|
+
args: ["sweep", "--model", optionValue("model", model), "--json"],
|
|
640
|
+
};
|
|
551
641
|
},
|
|
552
642
|
},
|
|
553
643
|
};
|
|
@@ -636,6 +726,14 @@ export function createHostApi(deps) {
|
|
|
636
726
|
handleModelsList(res);
|
|
637
727
|
return true;
|
|
638
728
|
}
|
|
729
|
+
// A local daemon may run downloads in its own tracked job process while
|
|
730
|
+
// this service owns the in-memory inventory. Reconcile that disk mutation
|
|
731
|
+
// without restarting the host or unloading its resident model.
|
|
732
|
+
if (route === "/__host/models/rescan" && method === "POST") {
|
|
733
|
+
const models = deps.rescan();
|
|
734
|
+
sendJson(res, { models: models.length });
|
|
735
|
+
return true;
|
|
736
|
+
}
|
|
639
737
|
if (route === "/__host/model/unload" && method === "POST") {
|
|
640
738
|
if (!guardWrite(res))
|
|
641
739
|
return true;
|
|
@@ -651,6 +749,7 @@ export function createHostApi(deps) {
|
|
|
651
749
|
"/__host/model/fields",
|
|
652
750
|
"/__host/model/rename",
|
|
653
751
|
"/__host/model/rename/reset",
|
|
752
|
+
"/__host/model/component",
|
|
654
753
|
]);
|
|
655
754
|
if (!modelRoutes.has(route))
|
|
656
755
|
return false;
|
|
@@ -702,6 +801,17 @@ export function createHostApi(deps) {
|
|
|
702
801
|
handleDelete(res, model);
|
|
703
802
|
return true;
|
|
704
803
|
}
|
|
804
|
+
if (route === "/__host/model/component" && method === "DELETE") {
|
|
805
|
+
if (!guardWrite(res))
|
|
806
|
+
return true;
|
|
807
|
+
const componentId = params.get("component");
|
|
808
|
+
if (!componentId) {
|
|
809
|
+
sendError(res, 400, "a component query parameter is required");
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
handleComponentDelete(res, model, componentId);
|
|
813
|
+
return true;
|
|
814
|
+
}
|
|
705
815
|
if (route === "/__host/model" && method === "GET") {
|
|
706
816
|
// A single inventory row, for a detail pane that does not want the whole list.
|
|
707
817
|
void (async () => {
|
package/dist/service/serve.js
CHANGED
|
@@ -24,6 +24,7 @@ import { resolveVersion } from "../version.js";
|
|
|
24
24
|
import * as results from "../ops/results.js";
|
|
25
25
|
import { createCpuSampler, sample as sampleSystem } from "../sysmon.js";
|
|
26
26
|
import { createHostApi } from "./host-api.js";
|
|
27
|
+
import { errorMessage } from "./http-util.js";
|
|
27
28
|
import { createRouter, Telemetry } from "./router.js";
|
|
28
29
|
import { BrainStatusPublisher } from "./status-events.js";
|
|
29
30
|
import { Supervisor } from "./supervisor.js";
|
|
@@ -63,7 +64,8 @@ const REMOTE_JOB_RETENTION_MS = 5 * 60000;
|
|
|
63
64
|
* directory and GPU all belong to the host that is being benchmarked.
|
|
64
65
|
*/
|
|
65
66
|
class ServiceJobRunner {
|
|
66
|
-
constructor() {
|
|
67
|
+
constructor(onPullCompleted) {
|
|
68
|
+
this.onPullCompleted = onPullCompleted;
|
|
67
69
|
this.jobs = new Map();
|
|
68
70
|
}
|
|
69
71
|
start(kind, target, args) {
|
|
@@ -101,16 +103,24 @@ class ServiceJobRunner {
|
|
|
101
103
|
job.child = child;
|
|
102
104
|
this.jobs.set(job.id, job);
|
|
103
105
|
child.stderr?.setEncoding("utf8");
|
|
104
|
-
child.stderr?.on("data", (chunk) =>
|
|
105
|
-
const last = chunk.trim().split(/\r?\n/).at(-1)?.trim();
|
|
106
|
-
if (last)
|
|
107
|
-
job.message = last.slice(-1000);
|
|
108
|
-
});
|
|
106
|
+
child.stderr?.on("data", (chunk) => this.ingestOutput(job, chunk));
|
|
109
107
|
child.once("error", (error) => this.finish(job, "failed", error.message));
|
|
110
108
|
child.once("close", (code) => {
|
|
111
109
|
if (job.status !== "running")
|
|
112
110
|
return;
|
|
113
|
-
|
|
111
|
+
if (code === 0 && kind === "pull") {
|
|
112
|
+
try {
|
|
113
|
+
// Downloads happen in a child process, but inventory is served from
|
|
114
|
+
// this process's in-memory scan. Reconcile before reporting success
|
|
115
|
+
// so a newly downloaded bundle component is immediately available.
|
|
116
|
+
this.onPullCompleted();
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
this.finish(job, "failed", `Downloaded files, but could not refresh inventory: ${errorMessage(error)}`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
this.finish(job, code === 0 ? "succeeded" : "failed", code === 0 ? null : (job.message ?? `Exited with code ${code}.`));
|
|
114
124
|
});
|
|
115
125
|
return this.publicJob(job);
|
|
116
126
|
}
|
|
@@ -168,6 +178,20 @@ class ServiceJobRunner {
|
|
|
168
178
|
this.finish(job, "canceled", "Canceled.");
|
|
169
179
|
return this.list();
|
|
170
180
|
}
|
|
181
|
+
ingestOutput(job, chunk) {
|
|
182
|
+
for (const line of chunk
|
|
183
|
+
.split(/[\r\n]+/u)
|
|
184
|
+
.map((value) => value.trim())
|
|
185
|
+
.filter(Boolean)) {
|
|
186
|
+
const progress = /(\d{1,3})\s*%/u.exec(line);
|
|
187
|
+
if (progress)
|
|
188
|
+
job.percent = Math.max(0, Math.min(100, Number(progress[1])));
|
|
189
|
+
// The final JSON result is not a useful status label. Keep progress and
|
|
190
|
+
// actionable text instead, so a failed bundle pull tells the user why.
|
|
191
|
+
if (line !== "[" && !/^[\]{}",]+$/u.test(line))
|
|
192
|
+
job.message = line.slice(-1000);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
171
195
|
finish(job, status, error) {
|
|
172
196
|
if (job.status !== "running")
|
|
173
197
|
return;
|
|
@@ -175,6 +199,8 @@ class ServiceJobRunner {
|
|
|
175
199
|
job.status = status;
|
|
176
200
|
job.error = error;
|
|
177
201
|
job.finishedAt = new Date().toISOString();
|
|
202
|
+
if (status === "succeeded")
|
|
203
|
+
job.percent = 100;
|
|
178
204
|
}
|
|
179
205
|
publicJob({ child: _child, ...job }) {
|
|
180
206
|
return job;
|
|
@@ -266,6 +292,10 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
266
292
|
// Not const: deleting a model through the management API re-scans and replaces
|
|
267
293
|
// this, and every reader goes through a getter so nobody holds a stale array.
|
|
268
294
|
let catalog = scanModels(config, env);
|
|
295
|
+
const rescanCatalog = () => {
|
|
296
|
+
catalog = scanModels(config, env);
|
|
297
|
+
return catalog;
|
|
298
|
+
};
|
|
269
299
|
const needle = modelNeedle ?? config.defaultModel ?? store.lastModelId ?? undefined;
|
|
270
300
|
let model = null;
|
|
271
301
|
if (catalog.length > 0) {
|
|
@@ -389,17 +419,14 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
389
419
|
// /__host/events. One instance, so `capabilities.events` and the stream can
|
|
390
420
|
// never disagree about whether this brain publishes.
|
|
391
421
|
const statusEvents = new BrainStatusPublisher();
|
|
392
|
-
const jobs = new ServiceJobRunner();
|
|
422
|
+
const jobs = new ServiceJobRunner(rescanCatalog);
|
|
393
423
|
// Assigned once `stop` exists below. This indirection lets the management API
|
|
394
424
|
// answer a remote restart request before closing its own socket.
|
|
395
425
|
let requestRestart = () => { };
|
|
396
426
|
const hostApi = createHostApi({
|
|
397
427
|
supervisor,
|
|
398
428
|
getCatalog: () => catalog,
|
|
399
|
-
rescan:
|
|
400
|
-
catalog = scanModels(config, env);
|
|
401
|
-
return catalog;
|
|
402
|
-
},
|
|
429
|
+
rescan: rescanCatalog,
|
|
403
430
|
getProfilesStore: () => store,
|
|
404
431
|
saveProfiles: (next) => saveProfilesStore(next, paths),
|
|
405
432
|
getProfileDefaults: () => config.defaults,
|
package/dist/types.d.ts
CHANGED
|
@@ -26,6 +26,22 @@ export interface ModelFeatures {
|
|
|
26
26
|
imatrix: boolean;
|
|
27
27
|
distilled: boolean;
|
|
28
28
|
}
|
|
29
|
+
export type ModelComponentRole = "vision_projector" | "speculative_drafter";
|
|
30
|
+
/** A catalog-declared companion artifact resolved against local disk. */
|
|
31
|
+
export interface ModelComponent {
|
|
32
|
+
id: string;
|
|
33
|
+
label: string;
|
|
34
|
+
description: string;
|
|
35
|
+
role: ModelComponentRole;
|
|
36
|
+
path: string | null;
|
|
37
|
+
bytes: number;
|
|
38
|
+
required: boolean;
|
|
39
|
+
defaultDownload: boolean;
|
|
40
|
+
defaultLoad: boolean;
|
|
41
|
+
available: boolean;
|
|
42
|
+
unavailableReason?: string;
|
|
43
|
+
minRuntimeBuild?: number;
|
|
44
|
+
}
|
|
29
45
|
/** A GGUF model discovered on disk (or resolvable from the download catalog). */
|
|
30
46
|
export interface Model {
|
|
31
47
|
id: string;
|
|
@@ -57,10 +73,14 @@ export interface Model {
|
|
|
57
73
|
catalogId?: string;
|
|
58
74
|
/** Back-reference: the hfRepo of the reconciled catalog entry, if matched. */
|
|
59
75
|
catalogHfRepo?: string;
|
|
76
|
+
/** Present only when this catalog entry declares a component manifest. */
|
|
77
|
+
components?: ModelComponent[];
|
|
60
78
|
}
|
|
61
79
|
/** A resolved llama.cpp runtime: an executable paired with its vendor DLL dir. */
|
|
62
80
|
export interface Runtime {
|
|
63
81
|
label: string;
|
|
82
|
+
/** Human-readable runtime identity, separate from the filesystem-safe label. */
|
|
83
|
+
displayName?: string;
|
|
64
84
|
version: string;
|
|
65
85
|
dir: string;
|
|
66
86
|
exe: string;
|
package/dist/vram.d.ts
CHANGED
|
@@ -21,6 +21,9 @@ export type BudgetSource = "measured" | "theoretical" | "unknown";
|
|
|
21
21
|
export interface Budget {
|
|
22
22
|
weightsBytes: number;
|
|
23
23
|
mmprojBytes: number;
|
|
24
|
+
componentBytes: number;
|
|
25
|
+
drafterKvBytes: number;
|
|
26
|
+
imageProcessingBytes: number;
|
|
24
27
|
kvBytes: number;
|
|
25
28
|
overheadBytes: number;
|
|
26
29
|
totalBytes: number;
|
package/dist/vram.js
CHANGED
|
@@ -35,7 +35,17 @@ export function theoreticalKvBytesPerToken(metadata, cacheTypeK, cacheTypeV) {
|
|
|
35
35
|
/** Compute a VRAM budget for a profile. */
|
|
36
36
|
export function budget({ model, profile, calibration = null, totalVramBytes, reserveBytes = 1.5 * GIB, }) {
|
|
37
37
|
const weights = model.sizeBytes;
|
|
38
|
-
const
|
|
38
|
+
const enabled = new Set(profile.enabledComponents ?? []);
|
|
39
|
+
const components = model.components ?? [];
|
|
40
|
+
const projector = components.filter((component) => component.role === "vision_projector" && enabled.has(component.id) && component.available);
|
|
41
|
+
const drafters = components.filter((component) => component.role === "speculative_drafter" && enabled.has(component.id) && component.available);
|
|
42
|
+
const componentBytes = components.length
|
|
43
|
+
? [...projector, ...drafters].reduce((total, component) => total + component.bytes, 0)
|
|
44
|
+
: profile.vision && model.mmprojPath
|
|
45
|
+
? model.mmprojBytes
|
|
46
|
+
: 0;
|
|
47
|
+
const mmproj = projector.reduce((total, component) => total + component.bytes, 0) ||
|
|
48
|
+
(components.length ? 0 : componentBytes);
|
|
39
49
|
const theoretical = theoreticalKvBytesPerToken(model.metadata, profile.cacheTypeK, profile.cacheTypeV);
|
|
40
50
|
let kvBytesPerToken;
|
|
41
51
|
let source;
|
|
@@ -52,14 +62,22 @@ export function budget({ model, profile, calibration = null, totalVramBytes, res
|
|
|
52
62
|
source = "unknown";
|
|
53
63
|
}
|
|
54
64
|
const kv = kvBytesPerToken * profile.contextSize;
|
|
65
|
+
// The speculative decoder keeps its own KV cache. Until component-specific
|
|
66
|
+
// calibration exists, reserve the same conservative per-token cost.
|
|
67
|
+
const drafterKv = drafters.length * kv;
|
|
68
|
+
// Vision preprocessing needs transient GPU buffers in addition to weights.
|
|
69
|
+
const imageProcessing = projector.length * 256 * 1024 * 1024;
|
|
55
70
|
// Compute buffers and the CUDA context; measured at ~0.6 GB for a 27B and
|
|
56
71
|
// superseded by the calibrated value when available.
|
|
57
72
|
const overhead = calibration && calibration.baseOverheadBytes > 0 ? calibration.baseOverheadBytes : 0.6 * GIB;
|
|
58
|
-
const total = weights +
|
|
73
|
+
const total = weights + componentBytes + kv + drafterKv + imageProcessing + overhead;
|
|
59
74
|
const usable = totalVramBytes - reserveBytes;
|
|
60
75
|
return {
|
|
61
76
|
weightsBytes: weights,
|
|
62
77
|
mmprojBytes: mmproj,
|
|
78
|
+
componentBytes,
|
|
79
|
+
drafterKvBytes: drafterKv,
|
|
80
|
+
imageProcessingBytes: imageProcessing,
|
|
63
81
|
kvBytes: kv,
|
|
64
82
|
overheadBytes: overhead,
|
|
65
83
|
totalBytes: total,
|
|
@@ -85,7 +103,7 @@ export function maxContextThatFits({ model, profile, calibration, totalVramBytes
|
|
|
85
103
|
});
|
|
86
104
|
if (probe.kvBytesPerToken <= 0)
|
|
87
105
|
return null;
|
|
88
|
-
const fixed = probe.weightsBytes + probe.
|
|
106
|
+
const fixed = probe.weightsBytes + probe.componentBytes + probe.imageProcessingBytes + probe.overheadBytes;
|
|
89
107
|
const room = probe.usableBytes - fixed;
|
|
90
108
|
if (room <= 0)
|
|
91
109
|
return null;
|
package/package.json
CHANGED