@otto-code/brain 0.8.7 → 0.8.9
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/bench.js +19 -5
- package/dist/commands/catalog.d.ts +3 -0
- package/dist/commands/catalog.js +2 -0
- package/dist/commands/pull.d.ts +1 -0
- package/dist/commands/pull.js +47 -14
- package/dist/commands/repo-download.d.ts +14 -0
- package/dist/commands/repo-download.js +29 -0
- 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 +38 -17
- package/dist/config/builtin-hosting-profiles.d.ts +8 -0
- package/dist/config/builtin-hosting-profiles.js +32 -0
- package/dist/config/hosting-profiles.d.ts +33 -0
- package/dist/config/hosting-profiles.js +71 -0
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +1 -0
- package/dist/config/paths.d.ts +2 -0
- package/dist/config/paths.js +1 -0
- package/dist/config/profile-edit.d.ts +5 -3
- package/dist/config/profile-edit.js +134 -14
- package/dist/config/profiles.js +66 -3
- package/dist/config/schema.d.ts +998 -0
- package/dist/config/schema.js +79 -0
- package/dist/config/store.js +28 -16
- package/dist/gguf.d.ts +1 -0
- package/dist/gguf.js +1 -0
- package/dist/models/download.d.ts +7 -0
- package/dist/models/download.js +165 -17
- package/dist/models/enrich.d.ts +6 -19
- package/dist/models/enrich.js +138 -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/ops/calibrate.d.ts +4 -1
- package/dist/ops/calibrate.js +10 -7
- package/dist/ops/sweep.d.ts +3 -1
- package/dist/ops/sweep.js +3 -3
- package/dist/runtime/args.d.ts +2 -2
- package/dist/runtime/args.js +18 -1
- package/dist/runtime/index.d.ts +8 -1
- package/dist/runtime/index.js +11 -1
- package/dist/runtime/managed.d.ts +40 -0
- package/dist/runtime/managed.js +146 -7
- package/dist/service/host-api.d.ts +20 -3
- package/dist/service/host-api.js +313 -20
- package/dist/service/router.d.ts +18 -0
- package/dist/service/router.js +89 -4
- package/dist/service/serve.js +221 -22
- package/dist/service/supervisor.d.ts +32 -4
- package/dist/service/supervisor.js +30 -6
- package/dist/tui/app.js +1 -1
- package/dist/types.d.ts +24 -0
- package/dist/vram.d.ts +3 -0
- package/dist/vram.js +24 -6
- package/package.json +1 -1
package/dist/service/host-api.js
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { calibrationInfo, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, } from "../config/profile-edit.js";
|
|
3
|
+
import { familyHostingProfileId, hostingFamily, removeHostingProfileMaterialization, } from "../config/hosting-profiles.js";
|
|
2
4
|
import { forModel, getCalibration, put } from "../config/profiles.js";
|
|
3
|
-
import {
|
|
5
|
+
import { HostingProfileSchema, } from "../config/schema.js";
|
|
6
|
+
import { deleteComponentFile, deleteModelFiles, diskUsage, planDelete, totalModelBytes, } from "../models/manage.js";
|
|
4
7
|
import { deleteDisplayName, updateDisplayName } from "../models/rename-map.js";
|
|
8
|
+
import { runtimeBuild } from "../runtime/index.js";
|
|
5
9
|
import * as vram from "../vram.js";
|
|
6
10
|
import { errorMessage, readJsonBody, sendError, sendJson } from "./http-util.js";
|
|
7
11
|
const MAX_PATCH_BYTES = 256 * 1024;
|
|
8
12
|
const MAX_DISPLAY_NAME = 200;
|
|
13
|
+
const MAX_HOSTING_PROFILE_NAME = 80;
|
|
14
|
+
const MAX_HOSTING_PROFILE_TEXT = 128 * 1024;
|
|
15
|
+
const MAX_HOSTING_PROFILES = 100;
|
|
16
|
+
const MAX_HOSTING_PROFILE_ID = 80;
|
|
17
|
+
const HOSTING_PROFILE_ID = /^[a-zA-Z0-9_-]+$/u;
|
|
18
|
+
// Keep product-owned records immutable through this API. Their source lives in
|
|
19
|
+
// builtin-hosting-profiles.ts, so an update would otherwise look successful but
|
|
20
|
+
// be replaced at the next Brain start with no user-facing restore action.
|
|
21
|
+
const BUILTIN_HOSTING_PROFILE_IDS = new Set(["qwen-sharp-v21.3"]);
|
|
9
22
|
const DEFAULT_LOG_LINES = 200;
|
|
10
23
|
/**
|
|
11
24
|
* The management API's own version, additive to the capability flags.
|
|
@@ -24,6 +37,139 @@ export const HOST_API_VERSION = 3;
|
|
|
24
37
|
* fine. Comments are ignored by every SSE parser, so no reader sees them.
|
|
25
38
|
*/
|
|
26
39
|
const SSE_KEEPALIVE_MS = 20000;
|
|
40
|
+
/**
|
|
41
|
+
* Apply the hosting-profile half of a profile patch, mutating both `profile`
|
|
42
|
+
* (this model's selection) and `store` (the shared library and family default).
|
|
43
|
+
*
|
|
44
|
+
* Separate from `sanitizeProfilePatch` because these keys are not profile
|
|
45
|
+
* fields: three of the five write to the store rather than the profile.
|
|
46
|
+
* Exported for testing - the ordering and the cross-profile cleanup are the
|
|
47
|
+
* parts worth pinning down, and they are unreachable through the HTTP surface
|
|
48
|
+
* without standing up a service.
|
|
49
|
+
*
|
|
50
|
+
* Throws on any invalid input; the caller turns that into a 400.
|
|
51
|
+
*/
|
|
52
|
+
export function applyHostingProfilePatch(store, model, profile, patch, onDelete = undefined) {
|
|
53
|
+
const family = hostingFamily(model.family);
|
|
54
|
+
if ("hostingProfileId" in patch) {
|
|
55
|
+
const selected = patch.hostingProfileId;
|
|
56
|
+
if (selected !== null && typeof selected !== "string") {
|
|
57
|
+
throw new Error("hostingProfileId must be a string or null");
|
|
58
|
+
}
|
|
59
|
+
if (selected && !store.hostingProfiles[selected]) {
|
|
60
|
+
throw new Error("selected hosting profile does not exist");
|
|
61
|
+
}
|
|
62
|
+
profile.hostingProfileId = selected || null;
|
|
63
|
+
profile.hostingProfileMode = selected ? "custom" : "off";
|
|
64
|
+
}
|
|
65
|
+
// After the id, so a client can send both and have the explicit mode win.
|
|
66
|
+
if ("hostingProfileMode" in patch) {
|
|
67
|
+
const mode = patch.hostingProfileMode;
|
|
68
|
+
if (mode !== "inherit" && mode !== "off" && mode !== "custom") {
|
|
69
|
+
throw new Error("hostingProfileMode must be inherit, off, or custom");
|
|
70
|
+
}
|
|
71
|
+
if (mode === "custom" && !profile.hostingProfileId) {
|
|
72
|
+
throw new Error("select a custom profile before using custom mode");
|
|
73
|
+
}
|
|
74
|
+
profile.hostingProfileMode = mode;
|
|
75
|
+
}
|
|
76
|
+
if ("familyHostingProfileId" in patch) {
|
|
77
|
+
const selected = patch.familyHostingProfileId;
|
|
78
|
+
if (selected !== null && typeof selected !== "string") {
|
|
79
|
+
throw new Error("familyHostingProfileId must be a string or null");
|
|
80
|
+
}
|
|
81
|
+
if (selected && !store.hostingProfiles[selected]) {
|
|
82
|
+
throw new Error("selected family hosting profile does not exist");
|
|
83
|
+
}
|
|
84
|
+
if (selected && store.hostingProfiles[selected].family !== family) {
|
|
85
|
+
throw new Error("selected family hosting profile must match the selected model");
|
|
86
|
+
}
|
|
87
|
+
// Null is a real instruction: it is the only way off a family default.
|
|
88
|
+
store.familyHostingProfileIds[family] = selected || null;
|
|
89
|
+
}
|
|
90
|
+
if ("hostingProfile" in patch) {
|
|
91
|
+
const candidate = HostingProfileSchema.safeParse(patch.hostingProfile);
|
|
92
|
+
if (!candidate.success)
|
|
93
|
+
throw new Error("hosting profile is invalid");
|
|
94
|
+
const item = candidate.data;
|
|
95
|
+
// Name the field that is wrong. One shared "name or text is too long" for an
|
|
96
|
+
// empty name, a missing template and an oversized addendum told a remote or
|
|
97
|
+
// CLI caller nothing about what to change.
|
|
98
|
+
if (item.name.trim().length === 0)
|
|
99
|
+
throw new Error("hosting profile needs a name");
|
|
100
|
+
if (item.name.length > MAX_HOSTING_PROFILE_NAME) {
|
|
101
|
+
throw new Error(`hosting profile name must be ${MAX_HOSTING_PROFILE_NAME} characters or fewer`);
|
|
102
|
+
}
|
|
103
|
+
if (!item.template?.trim())
|
|
104
|
+
throw new Error("hosting profile needs a Jinja chat template");
|
|
105
|
+
if (item.template.length > MAX_HOSTING_PROFILE_TEXT) {
|
|
106
|
+
throw new Error("hosting profile chat template is too long");
|
|
107
|
+
}
|
|
108
|
+
if ((item.systemPromptAddendum?.length ?? 0) > MAX_HOSTING_PROFILE_TEXT) {
|
|
109
|
+
throw new Error("hosting profile system prompt is too long");
|
|
110
|
+
}
|
|
111
|
+
if (item.family !== family) {
|
|
112
|
+
throw new Error("hosting profile family must match the selected model");
|
|
113
|
+
}
|
|
114
|
+
const isNew = item.id.length === 0;
|
|
115
|
+
const id = isNew ? `hosting_${randomUUID()}` : item.id;
|
|
116
|
+
if (!isNew && BUILTIN_HOSTING_PROFILE_IDS.has(id)) {
|
|
117
|
+
throw new Error("built-in hosting profiles cannot be edited");
|
|
118
|
+
}
|
|
119
|
+
if (!isNew && (id.length > MAX_HOSTING_PROFILE_ID || !HOSTING_PROFILE_ID.test(id))) {
|
|
120
|
+
throw new Error(`hosting profile id must use only letters, numbers, underscores, or hyphens and be ${MAX_HOSTING_PROFILE_ID} characters or fewer`);
|
|
121
|
+
}
|
|
122
|
+
if (!isNew && !store.hostingProfiles[id]) {
|
|
123
|
+
throw new Error("hosting profile does not exist; create profiles without an id");
|
|
124
|
+
}
|
|
125
|
+
if (isNew && Object.keys(store.hostingProfiles).length >= MAX_HOSTING_PROFILES) {
|
|
126
|
+
throw new Error(`hosting profile limit of ${MAX_HOSTING_PROFILES} reached`);
|
|
127
|
+
}
|
|
128
|
+
store.hostingProfiles[id] = { ...item, id, name: item.name.trim() };
|
|
129
|
+
// Only a freshly created profile is auto-selected. Editing the text of an
|
|
130
|
+
// existing one must not silently convert a model that was on System default
|
|
131
|
+
// into a custom override of it.
|
|
132
|
+
if (isNew) {
|
|
133
|
+
profile.hostingProfileId = id;
|
|
134
|
+
profile.hostingProfileMode = "custom";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (typeof patch.deleteHostingProfileId === "string") {
|
|
138
|
+
const id = patch.deleteHostingProfileId;
|
|
139
|
+
if (BUILTIN_HOSTING_PROFILE_IDS.has(id)) {
|
|
140
|
+
throw new Error("built-in hosting profiles cannot be deleted");
|
|
141
|
+
}
|
|
142
|
+
delete store.hostingProfiles[id];
|
|
143
|
+
onDelete?.(id);
|
|
144
|
+
if (profile.hostingProfileId === id) {
|
|
145
|
+
profile.hostingProfileId = null;
|
|
146
|
+
profile.hostingProfileMode = "off";
|
|
147
|
+
}
|
|
148
|
+
// Clear the mode alongside the id on every other model. Leaving `custom`
|
|
149
|
+
// behind with no id left those models unsavable: the mode guard above
|
|
150
|
+
// rejects the next write each of them makes.
|
|
151
|
+
for (const saved of Object.values(store.profiles)) {
|
|
152
|
+
if (saved.hostingProfileId !== id)
|
|
153
|
+
continue;
|
|
154
|
+
saved.hostingProfileId = null;
|
|
155
|
+
saved.hostingProfileMode = "off";
|
|
156
|
+
}
|
|
157
|
+
for (const [key, selected] of Object.entries(store.familyHostingProfileIds)) {
|
|
158
|
+
if (selected === id)
|
|
159
|
+
store.familyHostingProfileIds[key] = null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// One invariant, enforced after every branch: an id belongs only to `custom`.
|
|
163
|
+
// It is what lets `forModel`'s legacy migration read a stored id as an
|
|
164
|
+
// unambiguous "this profile predates hostingProfileMode".
|
|
165
|
+
if (profile.hostingProfileMode !== "custom")
|
|
166
|
+
profile.hostingProfileId = null;
|
|
167
|
+
}
|
|
168
|
+
/** The hosting profiles a model may choose from: its family's bucket, nothing else. */
|
|
169
|
+
function hostingProfilesFor(store, model) {
|
|
170
|
+
const family = hostingFamily(model.family);
|
|
171
|
+
return Object.values(store.hostingProfiles).filter((candidate) => candidate.family === family);
|
|
172
|
+
}
|
|
27
173
|
function stateOf(supervisor, model) {
|
|
28
174
|
if (!supervisor.model || supervisor.model.id !== model.id)
|
|
29
175
|
return "not-loaded";
|
|
@@ -41,9 +187,9 @@ function stateOf(supervisor, model) {
|
|
|
41
187
|
* client would otherwise have to correlate three unrelated lists by display name.
|
|
42
188
|
*/
|
|
43
189
|
export function buildInventoryRow(params) {
|
|
44
|
-
const { model, store, defaults, gpu, ranking, supervisor } = params;
|
|
190
|
+
const { model, store, defaults, gpu, ranking, supervisor, runtimeBuild: activeRuntimeBuild = null, } = params;
|
|
45
191
|
const profile = forModel(store, model, defaults);
|
|
46
|
-
const calibration = getCalibration(store, model, profile);
|
|
192
|
+
const calibration = profile.calibrationRequired ? null : getCalibration(store, model, profile);
|
|
47
193
|
const budgetOptions = gpu
|
|
48
194
|
? { model, profile, calibration, totalVramBytes: gpu.totalBytes }
|
|
49
195
|
: null;
|
|
@@ -51,6 +197,7 @@ export function buildInventoryRow(params) {
|
|
|
51
197
|
return {
|
|
52
198
|
id: model.id,
|
|
53
199
|
displayName: model.displayName,
|
|
200
|
+
family: model.family ?? null,
|
|
54
201
|
publisher: model.publisher ?? null,
|
|
55
202
|
quant: model.quant,
|
|
56
203
|
sizeBytes: model.sizeBytes,
|
|
@@ -60,7 +207,11 @@ export function buildInventoryRow(params) {
|
|
|
60
207
|
contextLength: model.metadata?.contextLength ?? null,
|
|
61
208
|
blockCount: model.metadata?.blockCount ?? null,
|
|
62
209
|
headCountKv: model.metadata?.headCountKv ?? null,
|
|
63
|
-
|
|
210
|
+
// A bundle declares vision capability even before its projector is
|
|
211
|
+
// downloaded. The badge describes what the model supports, while the
|
|
212
|
+
// profile's component row describes whether that artifact is ready.
|
|
213
|
+
hasProjector: Boolean(model.mmprojPath) ||
|
|
214
|
+
Boolean(model.components?.some((component) => component.role === "vision_projector")),
|
|
64
215
|
reasoning: Boolean(model.metadata?.reasoning ?? model.thinking),
|
|
65
216
|
mtp: Boolean(model.features?.mtp),
|
|
66
217
|
distilled: Boolean(model.features?.distilled),
|
|
@@ -73,8 +224,36 @@ export function buildInventoryRow(params) {
|
|
|
73
224
|
score: ranked,
|
|
74
225
|
state: stateOf(supervisor, model),
|
|
75
226
|
warnings: profileWarnings(profile, model, store),
|
|
227
|
+
components: model.components?.map((component) => {
|
|
228
|
+
if (component.minRuntimeBuild === undefined ||
|
|
229
|
+
(activeRuntimeBuild !== null && activeRuntimeBuild >= component.minRuntimeBuild)) {
|
|
230
|
+
return component;
|
|
231
|
+
}
|
|
232
|
+
const active = activeRuntimeBuild === null ? "unknown" : `b${activeRuntimeBuild}`;
|
|
233
|
+
return {
|
|
234
|
+
...component,
|
|
235
|
+
available: false,
|
|
236
|
+
unavailableReason: `Requires llama.cpp build b${component.minRuntimeBuild} or newer ` +
|
|
237
|
+
`(active build: ${active})`,
|
|
238
|
+
};
|
|
239
|
+
}) ?? null,
|
|
76
240
|
};
|
|
77
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Reject a request value that the CLI would read as a flag rather than as a
|
|
244
|
+
* value.
|
|
245
|
+
*
|
|
246
|
+
* An option value has to sit before the `--` separator by construction - the
|
|
247
|
+
* separator only ends *positional* parsing - so unlike `model` and `repo` these
|
|
248
|
+
* cannot be moved out of harm's way, and the argv is unambiguous only if the
|
|
249
|
+
* value itself is. The throw surfaces as the route's 400, next to the type
|
|
250
|
+
* checks in each `makeArgs`.
|
|
251
|
+
*/
|
|
252
|
+
function optionValue(label, value) {
|
|
253
|
+
if (value.startsWith("-"))
|
|
254
|
+
throw new Error(`${label} must not start with "-"`);
|
|
255
|
+
return value;
|
|
256
|
+
}
|
|
78
257
|
/** Resolve a model by id or display name, the same way the completion path does. */
|
|
79
258
|
function resolveModel(catalog, needle) {
|
|
80
259
|
if (!needle)
|
|
@@ -106,7 +285,7 @@ function profileFromQuery(base, params, model) {
|
|
|
106
285
|
}
|
|
107
286
|
if (Object.keys(patch).length === 0)
|
|
108
287
|
return base;
|
|
109
|
-
return sanitizeProfilePatch(base, patch, model).profile;
|
|
288
|
+
return sanitizeProfilePatch(base, patch, model, runtimeBuild(null)).profile;
|
|
110
289
|
}
|
|
111
290
|
/**
|
|
112
291
|
* Build the `/__host/*` management handler.
|
|
@@ -142,9 +321,15 @@ export function createHostApi(deps) {
|
|
|
142
321
|
const [gpu, store] = [await deps.queryGpuInfo(), deps.getProfilesStore()];
|
|
143
322
|
const defaults = deps.getProfileDefaults();
|
|
144
323
|
const ranking = deps.getRanking();
|
|
145
|
-
return deps
|
|
146
|
-
|
|
147
|
-
|
|
324
|
+
return deps.getCatalog().map((model) => buildInventoryRow({
|
|
325
|
+
model,
|
|
326
|
+
store,
|
|
327
|
+
defaults,
|
|
328
|
+
gpu,
|
|
329
|
+
ranking,
|
|
330
|
+
supervisor: deps.supervisor,
|
|
331
|
+
runtimeBuild: runtimeBuild(deps.supervisor.runtime),
|
|
332
|
+
}));
|
|
148
333
|
};
|
|
149
334
|
const handleModelsList = (res) => {
|
|
150
335
|
void (async () => {
|
|
@@ -167,9 +352,12 @@ export function createHostApi(deps) {
|
|
|
167
352
|
const profile = forModel(store, model, deps.getProfileDefaults());
|
|
168
353
|
sendJson(res, {
|
|
169
354
|
profile,
|
|
170
|
-
fields: profileFieldDescriptors(model),
|
|
355
|
+
fields: profileFieldDescriptors(model, profile),
|
|
171
356
|
warnings: profileWarnings(profile, model, store),
|
|
172
357
|
calibration: calibrationInfo(store, model, profile),
|
|
358
|
+
requiresRestart: store.pendingReloadModelIds[model.id] === true,
|
|
359
|
+
hostingProfiles: hostingProfilesFor(store, model),
|
|
360
|
+
familyHostingProfileId: familyHostingProfileId(store, model.family),
|
|
173
361
|
});
|
|
174
362
|
};
|
|
175
363
|
const handleProfileSet = (req, res, model) => {
|
|
@@ -182,24 +370,38 @@ export function createHostApi(deps) {
|
|
|
182
370
|
try {
|
|
183
371
|
const store = deps.getProfilesStore();
|
|
184
372
|
const current = forModel(store, model, deps.getProfileDefaults());
|
|
185
|
-
const
|
|
186
|
-
|
|
373
|
+
const activeRuntimeBuild = runtimeBuild(deps.supervisor.runtime);
|
|
374
|
+
const { profile, adjustments } = sanitizeProfilePatch(current, result.body, model, activeRuntimeBuild);
|
|
375
|
+
applyHostingProfilePatch(store, model, profile, result.body, (id) => removeHostingProfileMaterialization(deps.supervisor.paths, id));
|
|
376
|
+
put(store, model, profile);
|
|
377
|
+
// A setting is only unapplied when it was changed on the currently
|
|
378
|
+
// resident model. Edits to an unloaded model take effect naturally
|
|
379
|
+
// when it is next loaded and do not earn a misleading reload badge.
|
|
380
|
+
const requiresRestart = deps.supervisor.model?.id === model.id;
|
|
381
|
+
if (requiresRestart)
|
|
382
|
+
store.pendingReloadModelIds[model.id] = true;
|
|
383
|
+
deps.saveProfiles(store);
|
|
187
384
|
// Return the recomputed budget so an edit costs one round trip rather
|
|
188
385
|
// than a write followed by a read the UI has to sequence.
|
|
189
386
|
const gpu = await deps.queryGpuInfo();
|
|
190
|
-
const calibration =
|
|
387
|
+
const calibration = profile.calibrationRequired
|
|
388
|
+
? null
|
|
389
|
+
: getCalibration(store, model, profile);
|
|
191
390
|
const options = gpu
|
|
192
391
|
? { model, profile, calibration, totalVramBytes: gpu.totalBytes }
|
|
193
392
|
: null;
|
|
194
393
|
sendJson(res, {
|
|
195
394
|
profile,
|
|
395
|
+
fields: profileFieldDescriptors(model, profile),
|
|
196
396
|
adjustments,
|
|
197
397
|
warnings: profileWarnings(profile, model, store),
|
|
198
398
|
calibration: calibrationInfo(store, model, profile),
|
|
199
399
|
budget: options ? vram.budget(options) : null,
|
|
200
400
|
maxContextThatFits: options ? vram.maxContextThatFits(options) : null,
|
|
201
401
|
/** True when the running model is the one just edited: a restart applies it. */
|
|
202
|
-
requiresRestart
|
|
402
|
+
requiresRestart,
|
|
403
|
+
hostingProfiles: hostingProfilesFor(store, model),
|
|
404
|
+
familyHostingProfileId: familyHostingProfileId(store, model.family),
|
|
203
405
|
});
|
|
204
406
|
}
|
|
205
407
|
catch (error) {
|
|
@@ -281,7 +483,7 @@ export function createHostApi(deps) {
|
|
|
281
483
|
const options = {
|
|
282
484
|
model,
|
|
283
485
|
profile,
|
|
284
|
-
calibration: getCalibration(store, model, profile),
|
|
486
|
+
calibration: profile.calibrationRequired ? null : getCalibration(store, model, profile),
|
|
285
487
|
totalVramBytes: gpu.totalBytes,
|
|
286
488
|
};
|
|
287
489
|
sendJson(res, {
|
|
@@ -352,6 +554,24 @@ export function createHostApi(deps) {
|
|
|
352
554
|
sendError(res, 500, `could not delete ${model.displayName}: ${errorMessage(error)}`);
|
|
353
555
|
}
|
|
354
556
|
};
|
|
557
|
+
const handleComponentDelete = (res, model, componentId) => {
|
|
558
|
+
if (deps.supervisor.model?.id === model.id && deps.supervisor.state !== "stopped") {
|
|
559
|
+
sendError(res, 409, "stop the model before removing a bundle component");
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
try {
|
|
563
|
+
const plan = deleteComponentFile(model, componentId);
|
|
564
|
+
deps.rescan();
|
|
565
|
+
sendJson(res, {
|
|
566
|
+
deleted: plan.files,
|
|
567
|
+
freedBytes: plan.bytes,
|
|
568
|
+
componentIds: plan.componentIds,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
sendError(res, 409, errorMessage(error));
|
|
573
|
+
}
|
|
574
|
+
};
|
|
355
575
|
const handleLogs = (res, params) => {
|
|
356
576
|
const raw = Number(params.get("limit"));
|
|
357
577
|
const limit = Number.isFinite(raw) && raw > 0 ? Math.min(Math.round(raw), 1000) : DEFAULT_LOG_LINES;
|
|
@@ -482,7 +702,7 @@ export function createHostApi(deps) {
|
|
|
482
702
|
sendJson(res, {
|
|
483
703
|
job: deps.jobs?.start("bench", model ?? null, [
|
|
484
704
|
"bench",
|
|
485
|
-
...(model ? ["--model", model] : []),
|
|
705
|
+
...(model ? ["--model", optionValue("model", model)] : []),
|
|
486
706
|
]) ?? null,
|
|
487
707
|
});
|
|
488
708
|
}
|
|
@@ -499,7 +719,25 @@ export function createHostApi(deps) {
|
|
|
499
719
|
const model = body.model;
|
|
500
720
|
if (typeof model !== "string" || !model)
|
|
501
721
|
throw new Error("model is required");
|
|
502
|
-
|
|
722
|
+
const components = body.components;
|
|
723
|
+
const quant = body.quant;
|
|
724
|
+
if (components !== undefined &&
|
|
725
|
+
(!Array.isArray(components) || !components.every((id) => typeof id === "string"))) {
|
|
726
|
+
throw new Error("components must be component ids");
|
|
727
|
+
}
|
|
728
|
+
if (quant !== undefined && typeof quant !== "string")
|
|
729
|
+
throw new Error("quant must be a string");
|
|
730
|
+
return {
|
|
731
|
+
target: model,
|
|
732
|
+
args: [
|
|
733
|
+
"pull",
|
|
734
|
+
...(typeof quant === "string" ? ["--quant", optionValue("quant", quant)] : []),
|
|
735
|
+
...(components ?? []).flatMap((id) => ["--component", optionValue("component", id)]),
|
|
736
|
+
"--json",
|
|
737
|
+
"--",
|
|
738
|
+
model,
|
|
739
|
+
],
|
|
740
|
+
};
|
|
503
741
|
},
|
|
504
742
|
},
|
|
505
743
|
"/__host/jobs/add": {
|
|
@@ -507,11 +745,24 @@ export function createHostApi(deps) {
|
|
|
507
745
|
makeArgs: (body) => {
|
|
508
746
|
const repo = body.repo;
|
|
509
747
|
const quant = body.quant;
|
|
748
|
+
const components = body.components;
|
|
510
749
|
if (typeof repo !== "string" || !repo || typeof quant !== "string" || !quant)
|
|
511
750
|
throw new Error("repo and quant are required");
|
|
751
|
+
if (components !== undefined &&
|
|
752
|
+
(!Array.isArray(components) || !components.every((id) => typeof id === "string")))
|
|
753
|
+
throw new Error("components must be component ids");
|
|
512
754
|
return {
|
|
513
755
|
target: `${repo}#${quant}`,
|
|
514
|
-
args: [
|
|
756
|
+
args: [
|
|
757
|
+
"add",
|
|
758
|
+
"--quant",
|
|
759
|
+
optionValue("quant", quant),
|
|
760
|
+
...(components === undefined ? [] : ["--primary-only"]),
|
|
761
|
+
...(components ?? []).flatMap((id) => ["--component", optionValue("component", id)]),
|
|
762
|
+
"--json",
|
|
763
|
+
"--",
|
|
764
|
+
repo,
|
|
765
|
+
],
|
|
515
766
|
};
|
|
516
767
|
},
|
|
517
768
|
},
|
|
@@ -527,18 +778,36 @@ export function createHostApi(deps) {
|
|
|
527
778
|
"runtime",
|
|
528
779
|
"install",
|
|
529
780
|
"--json",
|
|
530
|
-
...(typeof build === "string" ? ["--build", build] : []),
|
|
781
|
+
...(typeof build === "string" ? ["--build", optionValue("build", build)] : []),
|
|
531
782
|
],
|
|
532
783
|
};
|
|
533
784
|
},
|
|
534
785
|
},
|
|
786
|
+
// Removal is host-owned for the same reason installation is: the runtimes
|
|
787
|
+
// directory belongs to this machine. A daemon in brain.mode=remote that ran
|
|
788
|
+
// this locally would delete a same-named runtime out of its own OTTO_HOME.
|
|
789
|
+
"/__host/jobs/runtime-remove": {
|
|
790
|
+
kind: "runtime-remove",
|
|
791
|
+
makeArgs: (body) => {
|
|
792
|
+
const name = body.name;
|
|
793
|
+
if (typeof name !== "string" || !name)
|
|
794
|
+
throw new Error("name is required");
|
|
795
|
+
// Pass the name as an operand, not as a flag candidate. The CLI still
|
|
796
|
+
// owns the real safety check (the name regex plus the parent-dir
|
|
797
|
+
// assertion in removeManagedRuntime).
|
|
798
|
+
return { target: name, args: ["runtime", "remove", "--json", "--", name] };
|
|
799
|
+
},
|
|
800
|
+
},
|
|
535
801
|
"/__host/jobs/calibrate": {
|
|
536
802
|
kind: "calibrate",
|
|
537
803
|
makeArgs: (body) => {
|
|
538
804
|
const model = body.model;
|
|
539
805
|
if (typeof model !== "string" || !model)
|
|
540
806
|
throw new Error("model is required");
|
|
541
|
-
return {
|
|
807
|
+
return {
|
|
808
|
+
target: model,
|
|
809
|
+
args: ["calibrate", "--model", optionValue("model", model), "--json"],
|
|
810
|
+
};
|
|
542
811
|
},
|
|
543
812
|
},
|
|
544
813
|
"/__host/jobs/sweep": {
|
|
@@ -547,7 +816,10 @@ export function createHostApi(deps) {
|
|
|
547
816
|
const model = body.model;
|
|
548
817
|
if (typeof model !== "string" || !model)
|
|
549
818
|
throw new Error("model is required");
|
|
550
|
-
return {
|
|
819
|
+
return {
|
|
820
|
+
target: model,
|
|
821
|
+
args: ["sweep", "--model", optionValue("model", model), "--json"],
|
|
822
|
+
};
|
|
551
823
|
},
|
|
552
824
|
},
|
|
553
825
|
};
|
|
@@ -636,6 +908,14 @@ export function createHostApi(deps) {
|
|
|
636
908
|
handleModelsList(res);
|
|
637
909
|
return true;
|
|
638
910
|
}
|
|
911
|
+
// A local daemon may run downloads in its own tracked job process while
|
|
912
|
+
// this service owns the in-memory inventory. Reconcile that disk mutation
|
|
913
|
+
// without restarting the host or unloading its resident model.
|
|
914
|
+
if (route === "/__host/models/rescan" && method === "POST") {
|
|
915
|
+
const models = deps.rescan();
|
|
916
|
+
sendJson(res, { models: models.length });
|
|
917
|
+
return true;
|
|
918
|
+
}
|
|
639
919
|
if (route === "/__host/model/unload" && method === "POST") {
|
|
640
920
|
if (!guardWrite(res))
|
|
641
921
|
return true;
|
|
@@ -651,6 +931,7 @@ export function createHostApi(deps) {
|
|
|
651
931
|
"/__host/model/fields",
|
|
652
932
|
"/__host/model/rename",
|
|
653
933
|
"/__host/model/rename/reset",
|
|
934
|
+
"/__host/model/component",
|
|
654
935
|
]);
|
|
655
936
|
if (!modelRoutes.has(route))
|
|
656
937
|
return false;
|
|
@@ -702,6 +983,17 @@ export function createHostApi(deps) {
|
|
|
702
983
|
handleDelete(res, model);
|
|
703
984
|
return true;
|
|
704
985
|
}
|
|
986
|
+
if (route === "/__host/model/component" && method === "DELETE") {
|
|
987
|
+
if (!guardWrite(res))
|
|
988
|
+
return true;
|
|
989
|
+
const componentId = params.get("component");
|
|
990
|
+
if (!componentId) {
|
|
991
|
+
sendError(res, 400, "a component query parameter is required");
|
|
992
|
+
return true;
|
|
993
|
+
}
|
|
994
|
+
handleComponentDelete(res, model, componentId);
|
|
995
|
+
return true;
|
|
996
|
+
}
|
|
705
997
|
if (route === "/__host/model" && method === "GET") {
|
|
706
998
|
// A single inventory row, for a detail pane that does not want the whole list.
|
|
707
999
|
void (async () => {
|
|
@@ -714,6 +1006,7 @@ export function createHostApi(deps) {
|
|
|
714
1006
|
gpu,
|
|
715
1007
|
ranking: deps.getRanking(),
|
|
716
1008
|
supervisor: deps.supervisor,
|
|
1009
|
+
runtimeBuild: runtimeBuild(deps.supervisor.runtime),
|
|
717
1010
|
}));
|
|
718
1011
|
}
|
|
719
1012
|
catch (error) {
|
package/dist/service/router.d.ts
CHANGED
|
@@ -73,6 +73,7 @@ export interface ModelEntry {
|
|
|
73
73
|
id: string;
|
|
74
74
|
/** Brain's editable human-facing name; `id` remains the stable model key. */
|
|
75
75
|
name: string;
|
|
76
|
+
family?: string;
|
|
76
77
|
object: "model";
|
|
77
78
|
created: number;
|
|
78
79
|
owned_by: string;
|
|
@@ -104,6 +105,23 @@ export declare function describeModel(model: Model | null, options?: DescribeOpt
|
|
|
104
105
|
* model when no catalog provider is wired in.
|
|
105
106
|
*/
|
|
106
107
|
export declare function buildModelList(supervisor: Supervisor, getCatalog: GetCatalog): ModelEntry[];
|
|
108
|
+
/** Which shape a completion path uses to carry its system turn. */
|
|
109
|
+
export type CompletionShape = "anthropic" | "openai";
|
|
110
|
+
export declare function completionShape(url: string | null | undefined): CompletionShape;
|
|
111
|
+
/**
|
|
112
|
+
* Append the active hosting profile's system-prompt addendum to a buffered
|
|
113
|
+
* completion body.
|
|
114
|
+
*
|
|
115
|
+
* Appending rather than prepending or replacing is the whole point: the agent's
|
|
116
|
+
* own system prompt still leads, and the profile's instructions are read last.
|
|
117
|
+
* A body this cannot understand is forwarded untouched - a malformed or
|
|
118
|
+
* unfamiliar request must still reach llama-server and get llama-server's own
|
|
119
|
+
* error, not a 400 invented here.
|
|
120
|
+
*
|
|
121
|
+
* Cost is one extra parse/serialize per request, paid only by models whose
|
|
122
|
+
* profile actually sets an addendum.
|
|
123
|
+
*/
|
|
124
|
+
export declare function injectSystemAddendum(body: Buffer, addendum: string | null, shape: CompletionShape): Buffer;
|
|
107
125
|
export type ModelGateResult = {
|
|
108
126
|
ok: true;
|
|
109
127
|
model: Model;
|