@otto-code/brain 0.8.8 → 0.8.10
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 +19 -5
- package/dist/commands/catalog.d.ts +1 -0
- package/dist/commands/catalog.js +1 -0
- package/dist/commands/pull.js +14 -33
- package/dist/commands/repo-download.d.ts +14 -0
- package/dist/commands/repo-download.js +29 -0
- package/dist/commands/search.js +6 -14
- 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 +4 -2
- package/dist/config/profile-edit.js +94 -4
- package/dist/config/profiles.js +25 -2
- package/dist/config/schema.d.ts +494 -24
- package/dist/config/schema.js +55 -0
- package/dist/config/store.js +12 -2
- package/dist/gguf.d.ts +1 -0
- package/dist/gguf.js +1 -0
- package/dist/models/download.js +5 -0
- package/dist/models/enrich.d.ts +6 -0
- package/dist/models/enrich.js +50 -2
- 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 +13 -1
- package/dist/runtime/index.d.ts +7 -0
- package/dist/runtime/index.js +10 -0
- package/dist/service/host-api.d.ts +17 -1
- package/dist/service/host-api.js +196 -13
- package/dist/service/router.d.ts +18 -0
- package/dist/service/router.js +89 -4
- package/dist/service/serve.js +184 -12
- 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 +4 -0
- package/dist/vram.js +3 -3
- 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";
|
|
5
|
+
import { HostingProfileSchema, } from "../config/schema.js";
|
|
3
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,
|
|
@@ -77,7 +224,19 @@ export function buildInventoryRow(params) {
|
|
|
77
224
|
score: ranked,
|
|
78
225
|
state: stateOf(supervisor, model),
|
|
79
226
|
warnings: profileWarnings(profile, model, store),
|
|
80
|
-
components: model.components
|
|
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,
|
|
81
240
|
};
|
|
82
241
|
}
|
|
83
242
|
/**
|
|
@@ -126,7 +285,7 @@ function profileFromQuery(base, params, model) {
|
|
|
126
285
|
}
|
|
127
286
|
if (Object.keys(patch).length === 0)
|
|
128
287
|
return base;
|
|
129
|
-
return sanitizeProfilePatch(base, patch, model).profile;
|
|
288
|
+
return sanitizeProfilePatch(base, patch, model, runtimeBuild(null)).profile;
|
|
130
289
|
}
|
|
131
290
|
/**
|
|
132
291
|
* Build the `/__host/*` management handler.
|
|
@@ -162,9 +321,15 @@ export function createHostApi(deps) {
|
|
|
162
321
|
const [gpu, store] = [await deps.queryGpuInfo(), deps.getProfilesStore()];
|
|
163
322
|
const defaults = deps.getProfileDefaults();
|
|
164
323
|
const ranking = deps.getRanking();
|
|
165
|
-
return deps
|
|
166
|
-
|
|
167
|
-
|
|
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
|
+
}));
|
|
168
333
|
};
|
|
169
334
|
const handleModelsList = (res) => {
|
|
170
335
|
void (async () => {
|
|
@@ -187,9 +352,12 @@ export function createHostApi(deps) {
|
|
|
187
352
|
const profile = forModel(store, model, deps.getProfileDefaults());
|
|
188
353
|
sendJson(res, {
|
|
189
354
|
profile,
|
|
190
|
-
fields: profileFieldDescriptors(model),
|
|
355
|
+
fields: profileFieldDescriptors(model, profile),
|
|
191
356
|
warnings: profileWarnings(profile, model, store),
|
|
192
357
|
calibration: calibrationInfo(store, model, profile),
|
|
358
|
+
requiresRestart: store.pendingReloadModelIds[model.id] === true,
|
|
359
|
+
hostingProfiles: hostingProfilesFor(store, model),
|
|
360
|
+
familyHostingProfileId: familyHostingProfileId(store, model.family),
|
|
193
361
|
});
|
|
194
362
|
};
|
|
195
363
|
const handleProfileSet = (req, res, model) => {
|
|
@@ -202,24 +370,38 @@ export function createHostApi(deps) {
|
|
|
202
370
|
try {
|
|
203
371
|
const store = deps.getProfilesStore();
|
|
204
372
|
const current = forModel(store, model, deps.getProfileDefaults());
|
|
205
|
-
const
|
|
206
|
-
|
|
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);
|
|
207
384
|
// Return the recomputed budget so an edit costs one round trip rather
|
|
208
385
|
// than a write followed by a read the UI has to sequence.
|
|
209
386
|
const gpu = await deps.queryGpuInfo();
|
|
210
|
-
const calibration =
|
|
387
|
+
const calibration = profile.calibrationRequired
|
|
388
|
+
? null
|
|
389
|
+
: getCalibration(store, model, profile);
|
|
211
390
|
const options = gpu
|
|
212
391
|
? { model, profile, calibration, totalVramBytes: gpu.totalBytes }
|
|
213
392
|
: null;
|
|
214
393
|
sendJson(res, {
|
|
215
394
|
profile,
|
|
395
|
+
fields: profileFieldDescriptors(model, profile),
|
|
216
396
|
adjustments,
|
|
217
397
|
warnings: profileWarnings(profile, model, store),
|
|
218
398
|
calibration: calibrationInfo(store, model, profile),
|
|
219
399
|
budget: options ? vram.budget(options) : null,
|
|
220
400
|
maxContextThatFits: options ? vram.maxContextThatFits(options) : null,
|
|
221
401
|
/** True when the running model is the one just edited: a restart applies it. */
|
|
222
|
-
requiresRestart
|
|
402
|
+
requiresRestart,
|
|
403
|
+
hostingProfiles: hostingProfilesFor(store, model),
|
|
404
|
+
familyHostingProfileId: familyHostingProfileId(store, model.family),
|
|
223
405
|
});
|
|
224
406
|
}
|
|
225
407
|
catch (error) {
|
|
@@ -301,7 +483,7 @@ export function createHostApi(deps) {
|
|
|
301
483
|
const options = {
|
|
302
484
|
model,
|
|
303
485
|
profile,
|
|
304
|
-
calibration: getCalibration(store, model, profile),
|
|
486
|
+
calibration: profile.calibrationRequired ? null : getCalibration(store, model, profile),
|
|
305
487
|
totalVramBytes: gpu.totalBytes,
|
|
306
488
|
};
|
|
307
489
|
sendJson(res, {
|
|
@@ -824,6 +1006,7 @@ export function createHostApi(deps) {
|
|
|
824
1006
|
gpu,
|
|
825
1007
|
ranking: deps.getRanking(),
|
|
826
1008
|
supervisor: deps.supervisor,
|
|
1009
|
+
runtimeBuild: runtimeBuild(deps.supervisor.runtime),
|
|
827
1010
|
}));
|
|
828
1011
|
}
|
|
829
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;
|
package/dist/service/router.js
CHANGED
|
@@ -149,6 +149,7 @@ export function describeModel(model, options = {}) {
|
|
|
149
149
|
// OpenAI-compatible clients send `id`; Otto uses `name` for presentation.
|
|
150
150
|
id: model.id,
|
|
151
151
|
name: model.displayName,
|
|
152
|
+
...(model.family ? { family: model.family } : {}),
|
|
152
153
|
object: "model",
|
|
153
154
|
created: Math.floor((createdAt ? createdAt.getTime() : Date.now()) / 1000),
|
|
154
155
|
owned_by: model.publisher || "local",
|
|
@@ -159,7 +160,13 @@ export function describeModel(model, options = {}) {
|
|
|
159
160
|
compatibility_type: "gguf",
|
|
160
161
|
quantization: model.quant || null,
|
|
161
162
|
state,
|
|
162
|
-
|
|
163
|
+
// The GGUF header remains the native limit, but an actively loaded YaRN
|
|
164
|
+
// profile intentionally extends the server's usable maximum. Publish that
|
|
165
|
+
// effective ceiling so OpenAI-compatible clients do not reject a context
|
|
166
|
+
// that this very llama-server instance has been configured to accept.
|
|
167
|
+
max_context_length: typeof md.contextLength === "number"
|
|
168
|
+
? md.contextLength * (profile?.contextMultiplier ?? 1)
|
|
169
|
+
: null,
|
|
163
170
|
// GGUF template detection is deliberately conservative. A false result
|
|
164
171
|
// means "not detected", not proof that a catalog-marked reasoner is not
|
|
165
172
|
// one, so preserve the catalog's positive capability metadata.
|
|
@@ -290,13 +297,17 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
290
297
|
resolve();
|
|
291
298
|
}
|
|
292
299
|
};
|
|
300
|
+
// Injected here, not at queue time: the scheduler may switch models between
|
|
301
|
+
// buffering and dispatch, and the addendum belongs to whichever model ends
|
|
302
|
+
// up resident, which is the one `supervisor.profile` now describes.
|
|
303
|
+
const outbound = injectSystemAddendum(body, supervisor.profile?.chatSystemAddendum ?? null, completionShape(req.url));
|
|
293
304
|
const headers = {};
|
|
294
305
|
for (const [name, value] of Object.entries(req.headers)) {
|
|
295
306
|
if (!HOP_BY_HOP.has(name.toLowerCase()))
|
|
296
307
|
headers[name] = value;
|
|
297
308
|
}
|
|
298
309
|
headers.host = `${supervisor.host}:${supervisor.internalPort}`;
|
|
299
|
-
headers["content-length"] = Buffer.byteLength(
|
|
310
|
+
headers["content-length"] = Buffer.byteLength(outbound);
|
|
300
311
|
const started = Date.now();
|
|
301
312
|
const upstream = http.request({
|
|
302
313
|
host: supervisor.host,
|
|
@@ -406,9 +417,78 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
|
|
|
406
417
|
// being dispatched to llama-server and is waiting for prompt processing or
|
|
407
418
|
// its first output delta.
|
|
408
419
|
reasoning?.begin(streamId);
|
|
409
|
-
upstream.end(
|
|
420
|
+
upstream.end(outbound);
|
|
410
421
|
});
|
|
411
422
|
}
|
|
423
|
+
export function completionShape(url) {
|
|
424
|
+
return /\/v1\/messages/.test(url ?? "") ? "anthropic" : "openai";
|
|
425
|
+
}
|
|
426
|
+
/** One text block, as both API shapes spell it inside a structured content array. */
|
|
427
|
+
function textBlock(text) {
|
|
428
|
+
return { type: "text", text };
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Append the active hosting profile's system-prompt addendum to a buffered
|
|
432
|
+
* completion body.
|
|
433
|
+
*
|
|
434
|
+
* Appending rather than prepending or replacing is the whole point: the agent's
|
|
435
|
+
* own system prompt still leads, and the profile's instructions are read last.
|
|
436
|
+
* A body this cannot understand is forwarded untouched - a malformed or
|
|
437
|
+
* unfamiliar request must still reach llama-server and get llama-server's own
|
|
438
|
+
* error, not a 400 invented here.
|
|
439
|
+
*
|
|
440
|
+
* Cost is one extra parse/serialize per request, paid only by models whose
|
|
441
|
+
* profile actually sets an addendum.
|
|
442
|
+
*/
|
|
443
|
+
export function injectSystemAddendum(body, addendum, shape) {
|
|
444
|
+
if (!addendum)
|
|
445
|
+
return body;
|
|
446
|
+
let parsed;
|
|
447
|
+
try {
|
|
448
|
+
parsed = JSON.parse(body.toString("utf8"));
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
return body;
|
|
452
|
+
}
|
|
453
|
+
if (!isRecord(parsed))
|
|
454
|
+
return body;
|
|
455
|
+
if (shape === "anthropic") {
|
|
456
|
+
// Anthropic carries the system turn beside `messages`, never inside it.
|
|
457
|
+
const system = parsed.system;
|
|
458
|
+
if (system === undefined || system === null || system === "")
|
|
459
|
+
parsed.system = addendum;
|
|
460
|
+
else if (typeof system === "string")
|
|
461
|
+
parsed.system = `${system}\n\n${addendum}`;
|
|
462
|
+
else if (Array.isArray(system))
|
|
463
|
+
parsed.system = [...system, textBlock(addendum)];
|
|
464
|
+
else
|
|
465
|
+
return body;
|
|
466
|
+
return Buffer.from(JSON.stringify(parsed), "utf8");
|
|
467
|
+
}
|
|
468
|
+
const messages = parsed.messages;
|
|
469
|
+
if (!Array.isArray(messages))
|
|
470
|
+
return body;
|
|
471
|
+
// `developer` is the newer OpenAI spelling of the same turn; either one is
|
|
472
|
+
// the message this addendum belongs on.
|
|
473
|
+
const index = messages.findIndex((message) => isRecord(message) && (message.role === "system" || message.role === "developer"));
|
|
474
|
+
if (index === -1) {
|
|
475
|
+
parsed.messages = [{ role: "system", content: addendum }, ...messages];
|
|
476
|
+
return Buffer.from(JSON.stringify(parsed), "utf8");
|
|
477
|
+
}
|
|
478
|
+
const existing = messages[index];
|
|
479
|
+
const content = existing.content;
|
|
480
|
+
let merged;
|
|
481
|
+
if (content === undefined || content === null || content === "")
|
|
482
|
+
merged = addendum;
|
|
483
|
+
else if (typeof content === "string")
|
|
484
|
+
merged = `${content}\n\n${addendum}`;
|
|
485
|
+
else if (Array.isArray(content))
|
|
486
|
+
merged = [...content, textBlock(addendum)];
|
|
487
|
+
else
|
|
488
|
+
return body;
|
|
489
|
+
parsed.messages = messages.map((message, at) => at === index ? { ...existing, content: merged } : message);
|
|
490
|
+
return Buffer.from(JSON.stringify(parsed), "utf8");
|
|
491
|
+
}
|
|
412
492
|
/**
|
|
413
493
|
* Pure model-admission decision, factored out of the router so it is unit
|
|
414
494
|
* testable. `pinned` is the single model a locked host serves; `resolved` is the
|
|
@@ -497,7 +577,12 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
|
|
|
497
577
|
// window - the cheap time-based trigger.
|
|
498
578
|
const RANKING_TTL_MS = 60000;
|
|
499
579
|
export function createRouter({ supervisor, telemetry, logger, getCatalog = null, loadModel = null, loadRanking = () => rankModels(), queryGpuInfo = queryGpu, version = null, getConfig = null, getEvals = null, getLockModel = () => false, getDefaultModel = () => null, applyConfigPatch = null, getAllowConfigWrite = () => false, hostApi = null, getResources = null, statusEvents = null, }) {
|
|
500
|
-
|
|
580
|
+
// llama-server may close an idle response socket while this scheduler holds
|
|
581
|
+
// the next request in queue. A reused keep-alive socket then fails as
|
|
582
|
+
// ECONNRESET ("socket hang up") before the queued request reaches inference.
|
|
583
|
+
// Inference time dwarfs localhost connection setup, so isolate each request
|
|
584
|
+
// instead of letting a second client inherit a stale upstream connection.
|
|
585
|
+
const agent = new http.Agent({ keepAlive: false, maxSockets: 32 });
|
|
501
586
|
const scheduler = loadModel
|
|
502
587
|
? new Scheduler({
|
|
503
588
|
supervisor,
|