@otto-code/brain 0.8.8 → 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.
Files changed (45) hide show
  1. package/dist/commands/bench.js +19 -5
  2. package/dist/commands/catalog.d.ts +1 -0
  3. package/dist/commands/catalog.js +1 -0
  4. package/dist/commands/pull.js +14 -33
  5. package/dist/commands/repo-download.d.ts +14 -0
  6. package/dist/commands/repo-download.js +29 -0
  7. package/dist/commands/search.js +6 -14
  8. package/dist/config/builtin-hosting-profiles.d.ts +8 -0
  9. package/dist/config/builtin-hosting-profiles.js +32 -0
  10. package/dist/config/hosting-profiles.d.ts +33 -0
  11. package/dist/config/hosting-profiles.js +71 -0
  12. package/dist/config/index.d.ts +1 -0
  13. package/dist/config/index.js +1 -0
  14. package/dist/config/paths.d.ts +2 -0
  15. package/dist/config/paths.js +1 -0
  16. package/dist/config/profile-edit.d.ts +4 -2
  17. package/dist/config/profile-edit.js +94 -4
  18. package/dist/config/profiles.js +25 -2
  19. package/dist/config/schema.d.ts +494 -24
  20. package/dist/config/schema.js +55 -0
  21. package/dist/config/store.js +12 -2
  22. package/dist/gguf.d.ts +1 -0
  23. package/dist/gguf.js +1 -0
  24. package/dist/models/download.js +5 -0
  25. package/dist/models/enrich.d.ts +6 -0
  26. package/dist/models/enrich.js +50 -2
  27. package/dist/ops/calibrate.d.ts +4 -1
  28. package/dist/ops/calibrate.js +10 -7
  29. package/dist/ops/sweep.d.ts +3 -1
  30. package/dist/ops/sweep.js +3 -3
  31. package/dist/runtime/args.d.ts +2 -2
  32. package/dist/runtime/args.js +13 -1
  33. package/dist/runtime/index.d.ts +7 -0
  34. package/dist/runtime/index.js +10 -0
  35. package/dist/service/host-api.d.ts +17 -1
  36. package/dist/service/host-api.js +196 -13
  37. package/dist/service/router.d.ts +18 -0
  38. package/dist/service/router.js +89 -4
  39. package/dist/service/serve.js +184 -12
  40. package/dist/service/supervisor.d.ts +32 -4
  41. package/dist/service/supervisor.js +30 -6
  42. package/dist/tui/app.js +1 -1
  43. package/dist/types.d.ts +4 -0
  44. package/dist/vram.js +3 -3
  45. package/package.json +1 -1
@@ -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 ?? null,
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
- .getCatalog()
167
- .map((model) => buildInventoryRow({ model, store, defaults, gpu, ranking, supervisor: deps.supervisor }));
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 { profile, adjustments } = sanitizeProfilePatch(current, result.body, model);
206
- deps.saveProfiles(put(store, model, profile));
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 = getCalibration(store, model, profile);
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: deps.supervisor.model?.id === model.id,
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) {
@@ -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;
@@ -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
- max_context_length: md.contextLength ?? null,
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(body);
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(body);
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
- const agent = new http.Agent({ keepAlive: true, maxSockets: 32 });
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,