@otto-code/brain 0.8.17 → 0.8.19

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/gguf.d.ts CHANGED
@@ -55,11 +55,20 @@ interface GgufSummary {
55
55
  reasoning: boolean;
56
56
  /** Native preservation argument, normalized by model enrichment when present. */
57
57
  reasoningPreservationArgument?: "preserve_thinking" | "preserve_reasoning";
58
+ /** Template argument that switches the thinking channel on or off. */
59
+ reasoningToggleArgument?: string;
60
+ /** Template argument that carries a graduated effort level. */
61
+ reasoningEffortArgument?: string;
62
+ /** Effort levels the template itself names, when it validates a literal set. */
63
+ reasoningEffortValues?: string[];
58
64
  }
59
65
  /** Read model-specific template spellings without leaking them into the UI contract. */
60
66
  export declare function detectTemplateReasoningCapabilities(chatTemplate: string): {
61
67
  reasoning: boolean;
62
68
  reasoningPreservationArgument?: "preserve_thinking" | "preserve_reasoning";
69
+ reasoningToggleArgument?: string;
70
+ reasoningEffortArgument?: string;
71
+ reasoningEffortValues?: string[];
63
72
  };
64
73
  /**
65
74
  * Pull out the fields that matter for hosting decisions.
package/dist/gguf.js CHANGED
@@ -176,6 +176,43 @@ export function readMetadata(file) {
176
176
  }
177
177
  throw new Error(`could not read GGUF header from ${file}`);
178
178
  }
179
+ /** Effort levels Otto can express, in the spelling a chat template uses. */
180
+ const TEMPLATE_EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh"]);
181
+ /** Template argument spellings that switch the thinking channel on or off. */
182
+ const TOGGLE_ARGUMENTS = ["enable_thinking", "enable_reasoning"];
183
+ /** Template argument spellings that carry a graduated effort level. */
184
+ const EFFORT_ARGUMENTS = ["reasoning_effort", "thinking_effort"];
185
+ /**
186
+ * Pull the effort levels a template names itself, e.g. Qwen3.8's guard clause
187
+ * `{%- if reasoning_effort not in ['low', 'medium', 'xhigh'] %}`. Only literals
188
+ * from a list sitting next to the effort argument are trusted: a level this
189
+ * function invents is one llama.cpp fails the whole completion over.
190
+ */
191
+ function detectTemplateEffortValues(chatTemplate, effortArgument) {
192
+ const values = [];
193
+ let from = 0;
194
+ for (;;) {
195
+ const at = chatTemplate.indexOf(effortArgument, from);
196
+ if (at < 0)
197
+ break;
198
+ from = at + effortArgument.length;
199
+ // The list has to sit in the same expression as the argument, so a window
200
+ // this short is what keeps an unrelated list elsewhere in the template out.
201
+ const window = chatTemplate.slice(from, from + 120);
202
+ const open = window.indexOf("[");
203
+ const close = window.indexOf("]");
204
+ if (open < 0 || close < open)
205
+ continue;
206
+ for (const literal of window.slice(open + 1, close).matchAll(/["']([a-z]+)["']/giu)) {
207
+ const value = literal[1].toLowerCase();
208
+ if (TEMPLATE_EFFORT_LEVELS.has(value) && !values.includes(value))
209
+ values.push(value);
210
+ }
211
+ if (values.length > 0)
212
+ break;
213
+ }
214
+ return values;
215
+ }
179
216
  /** Read model-specific template spellings without leaking them into the UI contract. */
180
217
  export function detectTemplateReasoningCapabilities(chatTemplate) {
181
218
  const reasoningPreservationArgument = /\bpreserve_thinking\b/iu.test(chatTemplate)
@@ -183,10 +220,24 @@ export function detectTemplateReasoningCapabilities(chatTemplate) {
183
220
  : /\bpreserve_reasoning\b/iu.test(chatTemplate)
184
221
  ? "preserve_reasoning"
185
222
  : undefined;
223
+ // The control contract, read from the template instead of the catalog. An
224
+ // uncurated GGUF can expose a thinking channel and the argument that steers
225
+ // it, and without reading that here the model reports a reasoning channel no
226
+ // Otto control can reach.
227
+ const reasoningToggleArgument = TOGGLE_ARGUMENTS.find((argument) => chatTemplate.includes(argument));
228
+ const reasoningEffortArgument = EFFORT_ARGUMENTS.find((argument) => chatTemplate.includes(argument));
229
+ const reasoningEffortValues = reasoningEffortArgument
230
+ ? detectTemplateEffortValues(chatTemplate, reasoningEffortArgument)
231
+ : [];
186
232
  return {
187
233
  reasoning: Boolean(reasoningPreservationArgument ||
188
- /<think>|<\/think>|reasoning_content|enable_thinking/iu.test(chatTemplate)),
234
+ reasoningToggleArgument ||
235
+ reasoningEffortArgument ||
236
+ /<think>|<\/think>|reasoning_content/iu.test(chatTemplate)),
189
237
  ...(reasoningPreservationArgument ? { reasoningPreservationArgument } : {}),
238
+ ...(reasoningToggleArgument ? { reasoningToggleArgument } : {}),
239
+ ...(reasoningEffortArgument ? { reasoningEffortArgument } : {}),
240
+ ...(reasoningEffortValues.length > 0 ? { reasoningEffortValues } : {}),
190
241
  };
191
242
  }
192
243
  /**
@@ -133,16 +133,19 @@ export function enrichWithCatalog(models, catalog) {
133
133
  const enriched = enrichDiscoveredProjector(model);
134
134
  const family = familyFromGgufMetadata(enriched);
135
135
  const reasoningPreservation = detectedReasoningPreservation(enriched);
136
- if (!family && !reasoningPreservation)
136
+ const reasoningControl = detectedReasoningControl(enriched);
137
+ if (!family && !reasoningPreservation && !reasoningControl)
137
138
  return enriched;
138
139
  return {
139
140
  ...enriched,
140
141
  ...(family ? { family } : {}),
141
142
  ...(reasoningPreservation ? { reasoningPreservation } : {}),
143
+ ...reasoningControl,
142
144
  };
143
145
  }
144
146
  const components = resolveComponents(model, entry);
145
147
  const projector = components?.find((component) => component.role === "vision_projector");
148
+ const reasoningControl = detectedReasoningControl(model);
146
149
  return {
147
150
  ...model,
148
151
  catalogId: entry.id,
@@ -156,14 +159,48 @@ export function enrichWithCatalog(models, catalog) {
156
159
  useCases: entry.useCases,
157
160
  tier: entry.tier,
158
161
  thinking: entry.thinking,
159
- reasoningEfforts: entry.reasoningEfforts,
162
+ // A curated declaration wins, but an entry that only says "this model
163
+ // thinks" still needs a way to steer it, so fall back to the contract the
164
+ // chat template states.
165
+ reasoningEfforts: entry.reasoningEfforts ?? reasoningControl?.reasoningEfforts,
160
166
  reasoningEffortDefault: entry.reasoningEffortDefault,
161
- reasoningTemplate: entry.reasoningTemplate,
167
+ reasoningTemplate: entry.reasoningTemplate ?? reasoningControl?.reasoningTemplate,
162
168
  reasoningPreservation: entry.reasoningPreservation ?? detectedReasoningPreservation(model),
163
169
  contextMax: entry.contextMax,
164
170
  };
165
171
  });
166
172
  }
173
+ /**
174
+ * The reasoning control contract a model's own chat template declares.
175
+ *
176
+ * Otto only shows an Effort control for a model it can actually steer, and it
177
+ * only forwards a level the template accepts - an unknown value makes llama.cpp
178
+ * fail the whole completion with a Jinja exception. A template that names a
179
+ * toggle argument therefore earns a binary On, a template that also validates a
180
+ * literal level set earns those levels, and a template that merely emits a
181
+ * `<think>` block earns no control at all.
182
+ */
183
+ function detectedReasoningControl(model) {
184
+ const toggleArgument = model.metadata?.reasoningToggleArgument;
185
+ const effortArgument = model.metadata?.reasoningEffortArgument;
186
+ if (typeof toggleArgument !== "string" && typeof effortArgument !== "string")
187
+ return undefined;
188
+ const declaredValues = model.metadata?.reasoningEffortValues;
189
+ const levels = typeof effortArgument === "string" && Array.isArray(declaredValues)
190
+ ? declaredValues.filter((value) => typeof value === "string")
191
+ : [];
192
+ return {
193
+ // Levels when the template names them, otherwise the generic On, which the
194
+ // router resolves to the template's own default.
195
+ reasoningEfforts: levels.length > 0 ? levels : ["on"],
196
+ reasoningTemplate: {
197
+ // An argument the template never reads is inert in the kwargs payload, so
198
+ // a placeholder here costs nothing and keeps the toggle path uniform.
199
+ enableThinkingArgument: toggleArgument ?? "enable_thinking",
200
+ effortArgument: effortArgument ?? "reasoning_effort",
201
+ },
202
+ };
203
+ }
167
204
  /** Map the two known template spellings to one model capability. */
168
205
  function detectedReasoningPreservation(model) {
169
206
  const templateArgument = model.metadata?.reasoningPreservationArgument;
@@ -4,9 +4,8 @@ export declare function pickModel(catalog: Model[], needle: string | undefined):
4
4
  /**
5
5
  * Choose a model when nothing named one: the best-ranked model from bench
6
6
  * history that is still installed, or the first catalog entry when nothing
7
- * has been benched yet. This is what "Automatic" (a null default model) means
8
- * in the UI - it must always start something for a non-empty catalog. The
9
- * VRAM fit check downstream in startService is what can still refuse the pick.
7
+ * has been benched yet. This is for callers that explicitly request automatic
8
+ * selection; a null startup default intentionally leaves Brain unloaded.
10
9
  */
11
10
  export declare function pickAutoModel(catalog: Model[], ranked?: RankedModel[]): Model;
12
11
  //# sourceMappingURL=pick.d.ts.map
@@ -37,9 +37,8 @@ export function pickModel(catalog, needle) {
37
37
  /**
38
38
  * Choose a model when nothing named one: the best-ranked model from bench
39
39
  * history that is still installed, or the first catalog entry when nothing
40
- * has been benched yet. This is what "Automatic" (a null default model) means
41
- * in the UI - it must always start something for a non-empty catalog. The
42
- * VRAM fit check downstream in startService is what can still refuse the pick.
40
+ * has been benched yet. This is for callers that explicitly request automatic
41
+ * selection; a null startup default intentionally leaves Brain unloaded.
43
42
  */
44
43
  export function pickAutoModel(catalog, ranked = rankModels()) {
45
44
  if (catalog.length === 0) {
@@ -64,6 +64,15 @@ export interface CalibrateOptions {
64
64
  internalPort?: number;
65
65
  /** Reuse the host's resident supervisor instead of creating a sidecar server. */
66
66
  supervisor?: Supervisor;
67
+ /**
68
+ * Pool-owned lifecycle for a hosted operation. Both callbacks must be
69
+ * supplied together; standalone CLI/TUI runs keep using their local
70
+ * supervisor.
71
+ */
72
+ lifecycle?: {
73
+ start: (profile: Profile) => Promise<void>;
74
+ stop: () => Promise<void>;
75
+ };
67
76
  /**
68
77
  * Pause after stopping each sample so the driver releases its allocation
69
78
  * before the next load. Tests run with zero.
@@ -83,5 +92,5 @@ export interface CalibrationMeasurement {
83
92
  vision: boolean;
84
93
  measuredAt: string;
85
94
  }
86
- export declare function calibrate({ runtime, model, profile, samples, priorCalibration, internalPort, supervisor: optionsSupervisor, releaseDelayMs, onProgress, }: CalibrateOptions): Promise<CalibrationMeasurement>;
95
+ export declare function calibrate({ runtime, model, profile, samples, priorCalibration, internalPort, supervisor: optionsSupervisor, lifecycle, releaseDelayMs, onProgress, }: CalibrateOptions): Promise<CalibrationMeasurement>;
87
96
  //# sourceMappingURL=calibrate.d.ts.map
@@ -27,7 +27,7 @@ export function maxContextForCalibration(model, profile, calibration, totalVramB
27
27
  });
28
28
  return max && max >= 4096 ? max : null;
29
29
  }
30
- export async function calibrate({ runtime, model, profile, samples, priorCalibration = null, internalPort = DEFAULT_INTERNAL_PORT + 1, supervisor: optionsSupervisor, releaseDelayMs = 3000, onProgress = () => { }, }) {
30
+ export async function calibrate({ runtime, model, profile, samples, priorCalibration = null, internalPort = DEFAULT_INTERNAL_PORT + 1, supervisor: optionsSupervisor, lifecycle, releaseDelayMs = 3000, onProgress = () => { }, }) {
31
31
  const nativeContext = model.metadata?.contextLength ?? null;
32
32
  const nativeCeiling = nativeContext ? nativeContext * profile.contextMultiplier : null;
33
33
  const gpu = (await query());
@@ -63,6 +63,9 @@ export async function calibrate({ runtime, model, profile, samples, priorCalibra
63
63
  throw new Error("calibration needs at least two context sizes");
64
64
  const native = nativeCeiling ?? Math.max(...effectiveSamples);
65
65
  const points = [];
66
+ if (lifecycle && !optionsSupervisor) {
67
+ throw new Error("a managed calibration lifecycle requires its resident supervisor");
68
+ }
66
69
  for (const contextSize of effectiveSamples) {
67
70
  if (contextSize > native) {
68
71
  onProgress({ phase: "skip", contextSize, reason: `exceeds configured context ${native}` });
@@ -72,7 +75,11 @@ export async function calibrate({ runtime, model, profile, samples, priorCalibra
72
75
  onProgress({ phase: "loading", contextSize });
73
76
  const baseline = await usedBytes();
74
77
  try {
75
- await supervisor.start(model, { ...profile, contextSize });
78
+ const sampleProfile = { ...profile, contextSize };
79
+ if (lifecycle)
80
+ await lifecycle.start(sampleProfile);
81
+ else
82
+ await supervisor.start(model, sampleProfile);
76
83
  if (kvSpilledToCpu(supervisor.logLines)) {
77
84
  const reason = `KV cache split to CPU at ${contextSize.toLocaleString()} context`;
78
85
  onProgress({ phase: "failed", contextSize, reason });
@@ -92,7 +99,10 @@ export async function calibrate({ runtime, model, profile, samples, priorCalibra
92
99
  throw error;
93
100
  }
94
101
  finally {
95
- await supervisor.stop();
102
+ if (lifecycle)
103
+ await lifecycle.stop();
104
+ else
105
+ await supervisor.stop();
96
106
  // Let the driver actually release the allocation before the next sample.
97
107
  if (releaseDelayMs > 0)
98
108
  await new Promise((resolve) => setTimeout(resolve, releaseDelayMs));
@@ -84,9 +84,14 @@ export interface SweepOptions {
84
84
  internalPort?: number;
85
85
  /** Reuse the host's resident supervisor instead of creating a sidecar server. */
86
86
  supervisor?: Supervisor;
87
+ /** Pool-owned lifecycle for hosted runs; standalone runs use their local supervisor. */
88
+ lifecycle?: {
89
+ start: (profile: Profile) => Promise<void>;
90
+ stop: () => Promise<void>;
91
+ };
87
92
  onProgress?: (event: SweepProgress) => void;
88
93
  }
89
- export declare function sweep({ runtime, model, profile, budgets, maxTokens, temperature, internalPort, supervisor: optionsSupervisor, onProgress, }: SweepOptions): Promise<SweepReport>;
94
+ export declare function sweep({ runtime, model, profile, budgets, maxTokens, temperature, internalPort, supervisor: optionsSupervisor, lifecycle, onProgress, }: SweepOptions): Promise<SweepReport>;
90
95
  /**
91
96
  * Rank a sweep's trials, best first. `ranked[0].budget` is the recommendation.
92
97
  *
package/dist/ops/sweep.js CHANGED
@@ -95,13 +95,20 @@ export async function runTrial({ supervisor, maxTokens, temperature, }) {
95
95
  contentPerSecond: elapsedSeconds > 0 ? content.length / elapsedSeconds : 0,
96
96
  };
97
97
  }
98
- export async function sweep({ runtime, model, profile, budgets = DEFAULT_BUDGETS, maxTokens = 8192, temperature = 0.7, internalPort = DEFAULT_INTERNAL_PORT + 2, supervisor: optionsSupervisor, onProgress = () => { }, }) {
98
+ export async function sweep({ runtime, model, profile, budgets = DEFAULT_BUDGETS, maxTokens = 8192, temperature = 0.7, internalPort = DEFAULT_INTERNAL_PORT + 2, supervisor: optionsSupervisor, lifecycle, onProgress = () => { }, }) {
99
99
  const results = [];
100
+ if (lifecycle && !optionsSupervisor) {
101
+ throw new Error("a managed sweep lifecycle requires its resident supervisor");
102
+ }
100
103
  for (const budget of budgets) {
101
104
  const supervisor = optionsSupervisor ?? new Supervisor({ runtime, internalPort });
102
105
  onProgress({ phase: "loading", budget });
103
106
  try {
104
- await supervisor.start(model, { ...profile, reasoningBudget: budget });
107
+ const trialProfile = { ...profile, reasoningBudget: budget };
108
+ if (lifecycle)
109
+ await lifecycle.start(trialProfile);
110
+ else
111
+ await supervisor.start(model, trialProfile);
105
112
  onProgress({ phase: "generating", budget });
106
113
  const trial = await runTrial({ supervisor, maxTokens, temperature });
107
114
  results.push({ budget, ...trial, error: null });
@@ -119,7 +126,10 @@ export async function sweep({ runtime, model, profile, budgets = DEFAULT_BUDGETS
119
126
  onProgress({ phase: "failed", budget, error: message });
120
127
  }
121
128
  finally {
122
- await supervisor.stop();
129
+ if (lifecycle)
130
+ await lifecycle.stop();
131
+ else
132
+ await supervisor.stop();
123
133
  await new Promise((resolve) => setTimeout(resolve, 2500));
124
134
  }
125
135
  }
@@ -3,6 +3,7 @@ import type { Runtime } from "../types.js";
3
3
  import { type InstallProgress, type RuntimeTarget } from "./managed.js";
4
4
  export { BACKENDS_DIR, LMSTUDIO_ROOT, listRuntimes as listLmStudioRuntimes } from "./lmstudio.js";
5
5
  export { buildArgs, buildEnv, formatCommand, type ServeTarget } from "./args.js";
6
+ export { llamaCppRuntimeDriver, type ModelServerDriverLaunchInput, type ModelServerLaunch, type ModelServerRuntimeDriver, } from "./model-server-driver.js";
6
7
  export { installManagedRuntime, removeManagedRuntime, listManagedRuntimes, listRuntimeDevices, verifyRuntimeExecutable, defaultRuntimeSpec, extractArchive, resolveRuntimeVariant, serverExeName, supportedVariants, DEFAULT_LLAMA_BUILD, listRuntimeReleases, latestRuntimeBuild, resolveLatestBuildOrPin, MissingAssetError, type ResolvedBuild, type RuntimeRelease, type RuntimeSpec, type RuntimeTarget, type RuntimeVariant, type InstallProgress, } from "./managed.js";
7
8
  /** Every runtime available on this machine, managed first then LM Studio. */
8
9
  export declare function listAllRuntimes(env?: NodeJS.ProcessEnv): Runtime[];
@@ -16,6 +16,7 @@ import { listRuntimes as listLmStudioRuntimes, resolveOverride } from "./lmstudi
16
16
  import { defaultRuntimeSpec, installManagedRuntime, listManagedRuntimes, } from "./managed.js";
17
17
  export { BACKENDS_DIR, LMSTUDIO_ROOT, listRuntimes as listLmStudioRuntimes } from "./lmstudio.js";
18
18
  export { buildArgs, buildEnv, formatCommand } from "./args.js";
19
+ export { llamaCppRuntimeDriver, } from "./model-server-driver.js";
19
20
  export { installManagedRuntime, removeManagedRuntime, listManagedRuntimes, listRuntimeDevices, verifyRuntimeExecutable, defaultRuntimeSpec, extractArchive, resolveRuntimeVariant, serverExeName, supportedVariants, DEFAULT_LLAMA_BUILD, listRuntimeReleases, latestRuntimeBuild, resolveLatestBuildOrPin, MissingAssetError, } from "./managed.js";
20
21
  /** Every runtime available on this machine, managed first then LM Studio. */
21
22
  export function listAllRuntimes(env = process.env) {
@@ -0,0 +1,42 @@
1
+ import type { Calibration, Profile } from "../config/schema.js";
2
+ import type { BrainPaths } from "../config/paths.js";
3
+ import type { Model, Runtime } from "../types.js";
4
+ export interface ModelServerLaunch {
5
+ executable: string;
6
+ args: string[];
7
+ cwd: string;
8
+ env: NodeJS.ProcessEnv;
9
+ command: string;
10
+ readinessPath: string;
11
+ propertiesPath: string | null;
12
+ formatLogLine(line: string): string;
13
+ }
14
+ export interface ModelServerDriverLaunchInput {
15
+ runtime: Runtime;
16
+ model: Model;
17
+ profile: Profile;
18
+ calibration: Calibration | null;
19
+ paths: BrainPaths;
20
+ host: string;
21
+ port: number;
22
+ logVerbosity: number;
23
+ }
24
+ /**
25
+ * Driver mechanics live here; the host owns process supervision, security,
26
+ * scheduler admission, status events, and the stable public endpoint.
27
+ */
28
+ export interface ModelServerRuntimeDriver {
29
+ readonly id: string;
30
+ readonly displayName: string;
31
+ /** Native process name for engine-originated diagnostics. */
32
+ readonly processName: string;
33
+ describeProcessExit(input: {
34
+ code: number | null;
35
+ signal: NodeJS.Signals | null;
36
+ }): string;
37
+ describeLaunchError(error: Error): string;
38
+ createLaunch(input: ModelServerDriverLaunchInput): ModelServerLaunch;
39
+ }
40
+ /** The first driver preserves the existing managed llama.cpp launch exactly. */
41
+ export declare const llamaCppRuntimeDriver: ModelServerRuntimeDriver;
42
+ //# sourceMappingURL=model-server-driver.d.ts.map
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The narrow, observed seam between the Brain host and a model-server engine.
3
+ *
4
+ * This intentionally starts with launch and introspection only. A method joins
5
+ * this contract when the common host needs the behavior from more than one
6
+ * driver; naming every current llama.cpp flag as a generic operation would
7
+ * create a false lowest common denominator before a second engine exists.
8
+ */
9
+ import path from "node:path";
10
+ import { mkdirSync } from "node:fs";
11
+ import { buildArgs, buildEnv, formatCommand } from "./args.js";
12
+ import { formatLlamaServerLog } from "../service/log-format.js";
13
+ /** The first driver preserves the existing managed llama.cpp launch exactly. */
14
+ export const llamaCppRuntimeDriver = {
15
+ id: "llama.cpp",
16
+ displayName: "llama.cpp",
17
+ processName: "llama-server",
18
+ describeProcessExit({ code, signal }) {
19
+ // 3221225781 == 0xC0000135 STATUS_DLL_NOT_FOUND: the vendor DLL trap.
20
+ const hint = code === 3221225781 ? " (missing runtime DLLs - the vendor directory was not on PATH)" : "";
21
+ return `llama-server exited with code ${code}${signal ? ` signal ${signal}` : ""}${hint}`;
22
+ },
23
+ describeLaunchError(error) {
24
+ return `could not launch llama-server: ${error.message}`;
25
+ },
26
+ createLaunch({ runtime, model, profile, calibration, paths, host, port, logVerbosity }) {
27
+ // llama.cpp enables scheduler-required slot erasure only when this existing
28
+ // directory is passed at launch. Failure to create it preserves the old
29
+ // behavior rather than making model startup fail for a cleanup feature.
30
+ const slotSavePath = path.join(paths.root, "slot-saves");
31
+ try {
32
+ mkdirSync(slotSavePath, { recursive: true });
33
+ }
34
+ catch {
35
+ /* launch without native slot actions */
36
+ }
37
+ const args = buildArgs({ ...profile, modelPath: model.modelPath, mmprojPath: model.mmprojPath }, { port, host, logVerbosity, slotSavePath }, model, calibration);
38
+ return {
39
+ executable: runtime.exe,
40
+ args,
41
+ cwd: runtime.dir,
42
+ env: buildEnv(runtime),
43
+ command: formatCommand(runtime, args),
44
+ readinessPath: "/health",
45
+ propertiesPath: "/props",
46
+ formatLogLine: formatLlamaServerLog,
47
+ };
48
+ },
49
+ };
50
+ //# sourceMappingURL=model-server-driver.js.map
@@ -1,3 +1,4 @@
1
+ import type { Profile } from "../config/schema.js";
1
2
  import type { Model } from "../types.js";
2
3
  import type { ModelScheduler, SchedulerStats, SchedulerSubmitOptions } from "./scheduler.js";
3
4
  import { Scheduler } from "./scheduler.js";
@@ -8,10 +9,23 @@ export interface ModelProcessPoolOptions {
8
9
  createSupervisor: (index: number) => Supervisor;
9
10
  createScheduler: (supervisor: Supervisor, loadModel: (model: Model) => Promise<void>, onChange: () => void) => Scheduler<Supervisor>;
10
11
  /** Returns the complete VRAM reservation for the process after it is ready. */
11
- loadModel: (supervisor: Supervisor, model: Model, reservedElsewhereBytes: number) => Promise<number>;
12
+ loadModel: (supervisor: Supervisor, model: Model, reservedElsewhereBytes: number, profile?: Profile) => Promise<number>;
12
13
  onChange?: (() => void) | null;
13
14
  logger?: ((message: string) => void) | null;
14
15
  }
16
+ /**
17
+ * The only lifecycle surface exposed to a resident host operation.
18
+ *
19
+ * Calibration and sweep need to relaunch their target with temporary profiles.
20
+ * Keeping those launches on the pool-owned lease means process limits, VRAM
21
+ * reservations, eviction state, scheduler slot ownership, and status updates
22
+ * all observe the same lifecycle.
23
+ */
24
+ export interface ManagedModelProcess {
25
+ supervisor: Supervisor;
26
+ start: (profile: Profile) => Promise<void>;
27
+ stop: () => Promise<void>;
28
+ }
15
29
  /**
16
30
  * Global admission and eviction boundary for independently hosted models.
17
31
  *
@@ -27,6 +41,8 @@ export declare class ModelProcessPool implements ModelScheduler<Supervisor> {
27
41
  constructor(options: ModelProcessPoolOptions);
28
42
  get maxModels(): number;
29
43
  submit(model: Model, run: (supervisor: Supervisor) => Promise<unknown>, options?: SchedulerSubmitOptions): Promise<unknown>;
44
+ /** Run an exclusive operation with pool-owned model lifecycle controls. */
45
+ submitOperation(model: Model, run: (process: ManagedModelProcess) => Promise<unknown>, options: SchedulerSubmitOptions): Promise<unknown>;
30
46
  /** Load a model and leave it resident without consuming an inference slot. */
31
47
  preload(model: Model): Promise<void>;
32
48
  /** Apply the host-owned process limit and retire excess idle processes. */
@@ -9,7 +9,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
9
9
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
10
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
11
  };
12
- var _ModelProcessPool_instances, _ModelProcessPool_createSupervisor, _ModelProcessPool_createScheduler, _ModelProcessPool_loadModel, _ModelProcessPool_onChange, _ModelProcessPool_logger, _ModelProcessPool_slots, _ModelProcessPool_queue, _ModelProcessPool_maxModels, _ModelProcessPool_busy, _ModelProcessPool_dirty, _ModelProcessPool_clock, _ModelProcessPool_makeSlot, _ModelProcessPool_announce, _ModelProcessPool_dispatch, _ModelProcessPool_pass, _ModelProcessPool_slotFor, _ModelProcessPool_nextSlotIndex, _ModelProcessPool_releaseSlot, _ModelProcessPool_trimIdleSlots;
12
+ var _ModelProcessPool_instances, _ModelProcessPool_createSupervisor, _ModelProcessPool_createScheduler, _ModelProcessPool_loadModel, _ModelProcessPool_onChange, _ModelProcessPool_logger, _ModelProcessPool_slots, _ModelProcessPool_queue, _ModelProcessPool_maxModels, _ModelProcessPool_busy, _ModelProcessPool_dirty, _ModelProcessPool_clock, _ModelProcessPool_makeSlot, _ModelProcessPool_loadSlot, _ModelProcessPool_announce, _ModelProcessPool_dispatch, _ModelProcessPool_pass, _ModelProcessPool_slotFor, _ModelProcessPool_nextSlotIndex, _ModelProcessPool_releaseSlot, _ModelProcessPool_trimIdleSlots;
13
13
  /**
14
14
  * Global admission and eviction boundary for independently hosted models.
15
15
  *
@@ -52,6 +52,28 @@ export class ModelProcessPool {
52
52
  queueMicrotask(() => void __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_dispatch).call(this));
53
53
  });
54
54
  }
55
+ /** Run an exclusive operation with pool-owned model lifecycle controls. */
56
+ submitOperation(model, run, options) {
57
+ return this.submit(model, async (supervisor) => {
58
+ const slot = __classPrivateFieldGet(this, _ModelProcessPool_slots, "f").find((candidate) => candidate.supervisor === supervisor);
59
+ if (!slot)
60
+ throw new Error("The scheduled model process is no longer in the pool.");
61
+ return run({
62
+ supervisor,
63
+ start: async (profile) => {
64
+ await __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_loadSlot).call(this, slot, model, profile);
65
+ },
66
+ stop: async () => {
67
+ var _a;
68
+ await supervisor.stop();
69
+ slot.scheduler.forgetSlots();
70
+ slot.reservationBytes = 0;
71
+ slot.lastUsedAt = __classPrivateFieldSet(this, _ModelProcessPool_clock, (_a = __classPrivateFieldGet(this, _ModelProcessPool_clock, "f"), ++_a), "f");
72
+ __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_announce).call(this);
73
+ },
74
+ });
75
+ }, options);
76
+ }
55
77
  /** Load a model and leave it resident without consuming an inference slot. */
56
78
  async preload(model) {
57
79
  await this.submit(model, async () => undefined);
@@ -141,18 +163,18 @@ _ModelProcessPool_createSupervisor = new WeakMap(), _ModelProcessPool_createSche
141
163
  reservationBytes: 0,
142
164
  lastUsedAt: __classPrivateFieldSet(this, _ModelProcessPool_clock, (_a = __classPrivateFieldGet(this, _ModelProcessPool_clock, "f"), ++_a), "f"),
143
165
  };
144
- slot.scheduler = __classPrivateFieldGet(this, _ModelProcessPool_createScheduler, "f").call(this, supervisor, async (model) => {
145
- var _a;
146
- const reservedElsewhereBytes = __classPrivateFieldGet(this, _ModelProcessPool_slots, "f").reduce((total, candidate) => (candidate === slot ? total : total + candidate.reservationBytes), 0);
147
- slot.reservationBytes = await __classPrivateFieldGet(this, _ModelProcessPool_loadModel, "f").call(this, supervisor, model, reservedElsewhereBytes);
148
- slot.assignedModelId = model.id;
149
- slot.lastUsedAt = __classPrivateFieldSet(this, _ModelProcessPool_clock, (_a = __classPrivateFieldGet(this, _ModelProcessPool_clock, "f"), ++_a), "f");
150
- __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_announce).call(this);
151
- }, () => {
166
+ slot.scheduler = __classPrivateFieldGet(this, _ModelProcessPool_createScheduler, "f").call(this, supervisor, async (model) => __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_loadSlot).call(this, slot, model), () => {
152
167
  __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_announce).call(this);
153
168
  void __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_dispatch).call(this);
154
169
  });
155
170
  return slot;
171
+ }, _ModelProcessPool_loadSlot = async function _ModelProcessPool_loadSlot(slot, model, profile) {
172
+ var _a;
173
+ const reservedElsewhereBytes = __classPrivateFieldGet(this, _ModelProcessPool_slots, "f").reduce((total, candidate) => (candidate === slot ? total : total + candidate.reservationBytes), 0);
174
+ slot.reservationBytes = await __classPrivateFieldGet(this, _ModelProcessPool_loadModel, "f").call(this, slot.supervisor, model, reservedElsewhereBytes, profile);
175
+ slot.assignedModelId = model.id;
176
+ slot.lastUsedAt = __classPrivateFieldSet(this, _ModelProcessPool_clock, (_a = __classPrivateFieldGet(this, _ModelProcessPool_clock, "f"), ++_a), "f");
177
+ __classPrivateFieldGet(this, _ModelProcessPool_instances, "m", _ModelProcessPool_announce).call(this);
156
178
  }, _ModelProcessPool_announce = function _ModelProcessPool_announce() {
157
179
  try {
158
180
  __classPrivateFieldGet(this, _ModelProcessPool_onChange, "f")?.call(this);
@@ -113,8 +113,10 @@ export declare function buildModelList(supervisor: Supervisor, getCatalog: GetCa
113
113
  * Map an OpenAI-compatible effort request onto a model's own chat-template
114
114
  * arguments. llama.cpp does not know every model's dialect: Qwen3.8 calls the
115
115
  * controls `enable_thinking` and `reasoning_effort`, for example. Only catalog
116
- * entries that declare these names are rewritten, so generic models and GPT-OSS
117
- * keep their existing server-native request handling.
116
+ * entries that declare these names receive a translated request. An arbitrary
117
+ * GGUF template must never see Otto's generic `reasoning_effort` field: its
118
+ * accepted values are template-specific, and an incompatible value makes
119
+ * llama.cpp fail the whole completion with a Jinja exception.
118
120
  */
119
121
  export declare function applyModelReasoningTemplate(body: Buffer, model: Model): Buffer;
120
122
  /**
@@ -26,7 +26,11 @@ const MAX_ANALYSIS_BYTES = 2 * 1024 * 1024;
26
26
  // Completion bodies are buffered so the scheduler can read `model` and replay
27
27
  // them after a possible model switch. Long-context prompts are large but bounded.
28
28
  const MAX_REQUEST_BYTES = 64 * 1024 * 1024;
29
- const COMPLETION_RE = /\/v1\/(messages|chat\/completions)/;
29
+ // llama.cpp accepts both its versioned OpenAI paths and the unversioned
30
+ // aliases. They are the same completion surface to Brain: both must enter the
31
+ // scheduler so generic reasoning_effort is translated before a chat template
32
+ // can render it.
33
+ const COMPLETION_RE = /\/(?:v1\/)?(messages|chat\/completions)(?:[/?]|$)/;
30
34
  export class Telemetry {
31
35
  constructor(keep = 50) {
32
36
  this.keep = keep;
@@ -284,14 +288,14 @@ const reasoningTracker = new ReasoningTracker();
284
288
  * Map an OpenAI-compatible effort request onto a model's own chat-template
285
289
  * arguments. llama.cpp does not know every model's dialect: Qwen3.8 calls the
286
290
  * controls `enable_thinking` and `reasoning_effort`, for example. Only catalog
287
- * entries that declare these names are rewritten, so generic models and GPT-OSS
288
- * keep their existing server-native request handling.
291
+ * entries that declare these names receive a translated request. An arbitrary
292
+ * GGUF template must never see Otto's generic `reasoning_effort` field: its
293
+ * accepted values are template-specific, and an incompatible value makes
294
+ * llama.cpp fail the whole completion with a Jinja exception.
289
295
  */
290
296
  export function applyModelReasoningTemplate(body, model) {
291
297
  const template = model.reasoningTemplate;
292
298
  const advertised = model.reasoningEfforts;
293
- if (!template || !Array.isArray(advertised))
294
- return body;
295
299
  let parsed;
296
300
  try {
297
301
  parsed = JSON.parse(body.toString("utf8"));
@@ -301,26 +305,38 @@ export function applyModelReasoningTemplate(body, model) {
301
305
  }
302
306
  if (!isRecord(parsed) || typeof parsed.reasoning_effort !== "string")
303
307
  return body;
308
+ const { reasoning_effort: _reasoningEffort, ...withoutReasoningEffort } = parsed;
309
+ // The Brain owns this generic field. An uncurated model can expose a
310
+ // reasoning channel without declaring how to control it, so preserve the
311
+ // template default rather than forwarding a value that can crash rendering.
312
+ if (!template || !Array.isArray(advertised)) {
313
+ return Buffer.from(JSON.stringify(withoutReasoningEffort), "utf8");
314
+ }
304
315
  const requested = parsed.reasoning_effort.toLowerCase();
305
316
  const knownEfforts = new Set(advertised.map((value) => value.toLowerCase()));
306
317
  const isDisabled = requested === "off" || requested === "none";
307
318
  const isEnabled = requested === "on" || knownEfforts.has(requested);
308
- if (!isDisabled && !isEnabled)
309
- return body;
319
+ // Old chats can retain a formerly valid generic level that this model does
320
+ // not support. Do not let that stale state turn into a server error.
321
+ if (!isDisabled && !isEnabled) {
322
+ return Buffer.from(JSON.stringify(withoutReasoningEffort), "utf8");
323
+ }
310
324
  const suppliedKwargs = parsed.chat_template_kwargs;
311
325
  if (suppliedKwargs !== undefined && !isRecord(suppliedKwargs))
312
326
  return body;
313
327
  const templateKwargs = { ...suppliedKwargs };
314
328
  templateKwargs[template.enableThinkingArgument] = !isDisabled;
315
- if (knownEfforts.has(requested)) {
329
+ if (requested !== "on" && knownEfforts.has(requested)) {
316
330
  templateKwargs[template.effortArgument] = requested;
317
331
  }
318
332
  else {
319
333
  // The generic On selection means the model's native default, not a stale
320
- // explicit effort that happened to be supplied by a previous client.
334
+ // explicit effort that happened to be supplied by a previous client. It
335
+ // stays generic even for a model that advertises "on" as its only level:
336
+ // that answer says the template has a toggle and no named ladder, so
337
+ // forwarding the word itself is the invalid value this guard exists to stop.
321
338
  delete templateKwargs[template.effortArgument];
322
339
  }
323
- const { reasoning_effort: _reasoningEffort, ...withoutReasoningEffort } = parsed;
324
340
  return Buffer.from(JSON.stringify({ ...withoutReasoningEffort, chat_template_kwargs: templateKwargs }), "utf8");
325
341
  }
326
342
  /**
@@ -588,7 +604,7 @@ function proxyBuffered({ agent, model, supervisor, telemetry, logger, req, res,
588
604
  });
589
605
  }
590
606
  export function completionShape(url) {
591
- return /\/v1\/messages/.test(url ?? "") ? "anthropic" : "openai";
607
+ return /\/(?:v1\/)?messages(?:[/?]|$)/.test(url ?? "") ? "anthropic" : "openai";
592
608
  }
593
609
  /** One text block, as both API shapes spell it inside a structured content array. */
594
610
  function textBlock(text) {
@@ -995,6 +1011,22 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
995
1011
  // Otto's GUI consume, so the two never drift. Status is live; config and
996
1012
  // evals are point-in-time reads the daemon proxies to its settings UI.
997
1013
  const path = (req.url || "").split("?")[0];
1014
+ // `/health` is the SERVICE's liveness, not the model's, and it is answered
1015
+ // here rather than proxied to llama-server. This host is multi-model and
1016
+ // starts unloaded whenever no default is configured ("Automatic"), so the
1017
+ // proxy path below would answer 503 for a perfectly healthy service with an
1018
+ // empty slot - which is exactly what the daemon's startup probe and `otto
1019
+ // brain status` read as "the service failed to start". Model readiness is
1020
+ // reported by `/__host/status` (and its event stream), which is where a
1021
+ // caller that actually needs it must look.
1022
+ if (path === "/health") {
1023
+ sendJson(res, {
1024
+ status: "ok",
1025
+ state: supervisor.state,
1026
+ model: supervisor.model?.displayName ?? null,
1027
+ });
1028
+ return;
1029
+ }
998
1030
  if (path === "/__host/status") {
999
1031
  // Resources cost an `nvidia-smi` spawn, so they are opt-in: the daemon
1000
1032
  // reads this route far more often than any UI does, and must not pay for
@@ -1099,7 +1131,7 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
1099
1131
  outHeaders[name] = value;
1100
1132
  }
1101
1133
  res.writeHead(upstreamRes.statusCode ?? 502, outHeaders);
1102
- const isCompletion = /\/v1\/(messages|chat\/completions)/.test(req.url || "");
1134
+ const isCompletion = COMPLETION_RE.test(req.url || "");
1103
1135
  const isStream = String(upstreamRes.headers["content-type"] || "").includes("event-stream");
1104
1136
  if (!isCompletion || isStream) {
1105
1137
  // Streaming: pass through untouched, but note whether any content
@@ -16,7 +16,7 @@ import https from "node:https";
16
16
  import { getCalibrationForBudget, forModel, loadPersistedConfig, loadProfilesStore, put, putCalibration, saveBrainConfig, saveProfilesStore, } from "../config/index.js";
17
17
  import { resolveBrainPaths } from "../config/paths.js";
18
18
  import { query as queryGpu } from "../gpu.js";
19
- import { managedModelsDir, pickAutoModel, pickModel, scanModels } from "../models/index.js";
19
+ import { managedModelsDir, pickModel, scanModels } from "../models/index.js";
20
20
  import { CommandError } from "../output/types.js";
21
21
  import { resolveRuntime } from "../runtime/index.js";
22
22
  import * as vram from "../vram.js";
@@ -172,9 +172,9 @@ class ServiceJobRunner {
172
172
  job.queuePosition = this.scheduler.stats().queued + 1;
173
173
  job.message = `Queued for ${model.displayName}`;
174
174
  void this.scheduler
175
- .submit(model, (supervisor) => controller.signal.aborted
175
+ .submitOperation(model, (process) => controller.signal.aborted
176
176
  ? Promise.reject(new Error("Operation canceled."))
177
- : this.runResidentJob(supervisor, kind, model.id, {
177
+ : this.runResidentJob(process, kind, model.id, {
178
178
  message: (value) => {
179
179
  if (job.status === "running")
180
180
  job.message = value.slice(-1000);
@@ -450,15 +450,18 @@ export async function startService({ config, modelNeedle, env = process.env, onL
450
450
  catalog = scanModels(config, env);
451
451
  return catalog;
452
452
  };
453
- const needle = modelNeedle ?? config.defaultModel ?? store.lastModelId ?? undefined;
453
+ // A configured default is the only implicit startup load. Keeping this null
454
+ // deliberately starts the host unloaded; an incoming request selects and
455
+ // loads its model on demand. `lastModelId` is history, never a hidden default.
456
+ const needle = modelNeedle ?? config.defaultModel ?? undefined;
454
457
  let model = null;
455
- if (catalog.length > 0) {
458
+ if (catalog.length > 0 && needle) {
456
459
  try {
457
- model = needle ? pickModel(catalog, needle) : pickAutoModel(catalog);
460
+ model = pickModel(catalog, needle);
458
461
  }
459
462
  catch (error) {
460
- // A removed default or last-used model must not take the management
461
- // service down. An explicit CLI selection remains an actionable error.
463
+ // A removed configured default must not take the management service down.
464
+ // An explicit CLI selection remains an actionable error.
462
465
  if (modelNeedle)
463
466
  throw error;
464
467
  log("server", `note: ${error instanceof Error ? error.message : "configured model is unavailable"}`);
@@ -496,7 +499,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
496
499
  });
497
500
  supervisor.on("log", (line) => log("server", line));
498
501
  supervisor.on("crashed", (error) => log("model", `FATAL ${error}`));
499
- const loadModelInto = async (resident, target, reservedElsewhereBytes) => {
502
+ const loadModelInto = async (resident, target, reservedElsewhereBytes, exactProfile) => {
500
503
  // A runtime can be installed from the Library tab after this service starts.
501
504
  // Resolve it at load time so the user does not have to restart the brain.
502
505
  resident.runtime = resolveRuntime(config, env);
@@ -504,26 +507,47 @@ export async function startService({ config, modelNeedle, env = process.env, onL
504
507
  throw new Error("no llama.cpp runtime available; install one from the Library tab");
505
508
  }
506
509
  const gpuInfo = await queryGpu();
507
- let fitProfile = forModel(store, target, config.defaults);
510
+ let fitProfile = exactProfile ?? forModel(store, target, config.defaults);
508
511
  let reservationBytes = 0;
509
512
  if (gpuInfo) {
510
- const fit = vram.fitToBudget({
511
- model: target,
512
- profile: fitProfile,
513
- calibration: getCalibrationForBudget(store, target, fitProfile),
514
- // Every resident process keeps its complete budget reserved. Fit this
515
- // process against the capacity left after those independent allocations.
516
- totalVramBytes: Math.max(0, gpuInfo.totalBytes - reservedElsewhereBytes),
517
- });
518
- if (!fit.adjusted && !fit.budget.fits)
519
- throw new Error(fit.reason ?? "does not fit");
520
- fitProfile = fit.profile;
521
- reservationBytes = fit.budget.totalBytes;
513
+ const totalVramBytes = Math.max(0, gpuInfo.totalBytes - reservedElsewhereBytes);
514
+ const calibration = getCalibrationForBudget(store, target, fitProfile);
515
+ if (exactProfile) {
516
+ const exactBudget = vram.budget({
517
+ model: target,
518
+ profile: fitProfile,
519
+ calibration,
520
+ totalVramBytes,
521
+ });
522
+ if (!exactBudget.fits) {
523
+ throw new Error("the operation profile does not fit beside the other resident models");
524
+ }
525
+ reservationBytes = exactBudget.totalBytes;
526
+ }
527
+ else {
528
+ const fit = vram.fitToBudget({
529
+ model: target,
530
+ profile: fitProfile,
531
+ calibration,
532
+ // Every resident process keeps its complete budget reserved. Fit this
533
+ // process against the capacity left after those independent allocations.
534
+ totalVramBytes,
535
+ });
536
+ if (!fit.adjusted && !fit.budget.fits)
537
+ throw new Error(fit.reason ?? "does not fit");
538
+ fitProfile = fit.profile;
539
+ reservationBytes = fit.budget.totalBytes;
540
+ }
522
541
  }
523
542
  await resident.start(target, fitProfile);
524
- delete store.pendingReloadModelIds[target.id];
525
- store.lastModelId = target.id;
526
- saveProfilesStore(store, paths);
543
+ // Temporary calibration/sweep profiles are not a user-requested reload of
544
+ // the saved hosting profile. Only an ordinary pool load clears that badge
545
+ // and advances the durable last-model selection.
546
+ if (!exactProfile) {
547
+ delete store.pendingReloadModelIds[target.id];
548
+ store.lastModelId = target.id;
549
+ saveProfilesStore(store, paths);
550
+ }
527
551
  return reservationBytes;
528
552
  };
529
553
  let processPool = null;
@@ -604,7 +628,8 @@ export async function startService({ config, modelNeedle, env = process.env, onL
604
628
  // /__host/events. One instance, so `capabilities.events` and the stream can
605
629
  // never disagree about whether this brain publishes.
606
630
  const statusEvents = new BrainStatusPublisher();
607
- const runResidentJob = async (supervisor, kind, target, update, signal) => {
631
+ const runResidentJob = async (process, kind, target, update, signal) => {
632
+ const { supervisor } = process;
608
633
  const ensureActive = () => {
609
634
  if (signal.aborted)
610
635
  throw new Error("Operation canceled.");
@@ -627,6 +652,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
627
652
  model: targetModel,
628
653
  profile,
629
654
  supervisor,
655
+ lifecycle: process,
630
656
  onProgress: (event) => {
631
657
  ensureActive();
632
658
  const message = event.phase === "loading"
@@ -659,6 +685,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
659
685
  model: targetModel,
660
686
  profile,
661
687
  supervisor,
688
+ lifecycle: process,
662
689
  onProgress: (event) => {
663
690
  ensureActive();
664
691
  const message = event.phase === "loading"
@@ -900,9 +927,15 @@ export async function startService({ config, modelNeedle, env = process.env, onL
900
927
  else if (!runtime) {
901
928
  log("server", "ready: no llama.cpp runtime installed; use the Library tab to download one");
902
929
  }
903
- else {
930
+ else if (catalog.length === 0) {
904
931
  log("server", "ready: no model installed; use the Library tab to download one");
905
932
  }
933
+ else {
934
+ // The "Automatic" default: nothing is preloaded on purpose, and the first
935
+ // completion picks and loads its own model. Say so, or the empty slot reads
936
+ // as a failure to anyone tailing the log.
937
+ log("server", "ready: no startup model configured; a request will load one on demand");
938
+ }
906
939
  writePidFile({
907
940
  pid: process.pid,
908
941
  host: bindHost,
@@ -1,5 +1,6 @@
1
1
  import { type ChildProcess } from "node:child_process";
2
2
  import { EventEmitter } from "node:events";
3
+ import { type ModelServerLaunch, type ModelServerRuntimeDriver } from "../runtime/index.js";
3
4
  import { type BrainPaths } from "../config/paths.js";
4
5
  import type { Model, Runtime } from "../types.js";
5
6
  import type { Profile, ProfilesStore } from "../config/schema.js";
@@ -18,6 +19,8 @@ export declare const DEFAULT_INTERNAL_PORT = 20800;
18
19
  export type SupervisorState = "stopped" | "starting" | "ready" | "failed" | "stopping";
19
20
  export interface SupervisorOptions {
20
21
  runtime: Runtime | null;
22
+ /** Native launch/introspection mechanics; lifecycle policy stays in this host. */
23
+ driver?: ModelServerRuntimeDriver;
21
24
  internalPort?: number;
22
25
  host?: string;
23
26
  logVerbosity?: number;
@@ -44,7 +47,7 @@ export interface SupervisorStatus {
44
47
  runtime: string;
45
48
  }
46
49
  /**
47
- * Owns the llama-server child process.
50
+ * Owns the model-server child process.
48
51
  *
49
52
  * The server always listens on a private port; `router.js` fronts it on a
50
53
  * stable one so switching models never asks a client to reconnect elsewhere.
@@ -58,6 +61,7 @@ export declare class Supervisor extends EventEmitter {
58
61
  readyTimeoutMs: number;
59
62
  paths: BrainPaths;
60
63
  getProfilesStore: () => ProfilesStore;
64
+ driver: ModelServerRuntimeDriver;
61
65
  state: SupervisorState;
62
66
  child: ChildProcess | null;
63
67
  model: Model | null;
@@ -75,10 +79,11 @@ export declare class Supervisor extends EventEmitter {
75
79
  * shell line is for reading, not for re-parsing.
76
80
  */
77
81
  args: string[] | null;
78
- constructor({ runtime, internalPort, host, logVerbosity, readyTimeoutMs, paths, getProfilesStore, }: SupervisorOptions);
82
+ launch: ModelServerLaunch | null;
83
+ constructor({ runtime, driver, internalPort, host, logVerbosity, readyTimeoutMs, paths, getProfilesStore, }: SupervisorOptions);
79
84
  get upstreamBase(): string;
80
85
  /**
81
- * Add a host-operation event to the same in-process tail as llama-server output.
86
+ * Add a host-operation event to the same in-process tail as model-server output.
82
87
  *
83
88
  * Calibrate, sweep, and benchmark deliberately reuse this supervisor rather
84
89
  * than creating invisible sidecar servers. Their lifecycle markers belong in
@@ -89,9 +94,9 @@ export declare class Supervisor extends EventEmitter {
89
94
  /**
90
95
  * Start (or restart) the server for a model + profile.
91
96
  *
92
- * This is the sole llama-server launch boundary, so it materializes the
93
- * selected hosting profile here. Keeping it beside `buildArgs()` makes the
94
- * Jinja template and router-visible system addendum mandatory for every
97
+ * This is the sole model-server launch boundary, so it materializes the
98
+ * selected hosting profile here. Keeping it at this one driver call makes
99
+ * the Jinja template and router-visible system addendum mandatory for every
95
100
  * caller, including future maintenance operations that start a sidecar.
96
101
  */
97
102
  start(model: Model, profile: Profile): Promise<this>;
@@ -5,17 +5,15 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
5
5
  };
6
6
  var _Supervisor_instances, _Supervisor_setState, _Supervisor_log, _Supervisor_health;
7
7
  import http from "node:http";
8
- import path from "node:path";
9
- import { mkdirSync } from "node:fs";
10
8
  import { spawn } from "node:child_process";
11
9
  import { EventEmitter } from "node:events";
12
- import { buildArgs, buildEnv, formatCommand } from "../runtime/index.js";
10
+ import { llamaCppRuntimeDriver, } from "../runtime/index.js";
13
11
  import { resolveHostingProfileForLaunch } from "../config/hosting-profiles.js";
14
12
  import { getCalibrationForBudget } from "../config/profiles.js";
15
13
  import { resolveBrainPaths } from "../config/paths.js";
16
14
  import { loadProfilesStore } from "../config/store.js";
17
15
  import { usedBytes } from "../gpu.js";
18
- import { formatBrainLog, formatLlamaServerLog } from "./log-format.js";
16
+ import { formatBrainLog } from "./log-format.js";
19
17
  const LOG_LINES_KEPT = 10000;
20
18
  /**
21
19
  * Default loopback port for the private llama-server child. Deliberately clear
@@ -29,16 +27,17 @@ const LOG_LINES_KEPT = 10000;
29
27
  */
30
28
  export const DEFAULT_INTERNAL_PORT = 20800;
31
29
  /**
32
- * Owns the llama-server child process.
30
+ * Owns the model-server child process.
33
31
  *
34
32
  * The server always listens on a private port; `router.js` fronts it on a
35
33
  * stable one so switching models never asks a client to reconnect elsewhere.
36
34
  */
37
35
  export class Supervisor extends EventEmitter {
38
- constructor({ runtime, internalPort = DEFAULT_INTERNAL_PORT, host = "127.0.0.1", logVerbosity = 3, readyTimeoutMs = 300000, paths = resolveBrainPaths(), getProfilesStore = loadProfilesStore, }) {
36
+ constructor({ runtime, driver = llamaCppRuntimeDriver, internalPort = DEFAULT_INTERNAL_PORT, host = "127.0.0.1", logVerbosity = 3, readyTimeoutMs = 300000, paths = resolveBrainPaths(), getProfilesStore = loadProfilesStore, }) {
39
37
  super();
40
38
  _Supervisor_instances.add(this);
41
39
  this.runtime = runtime;
40
+ this.driver = driver;
42
41
  this.internalPort = internalPort;
43
42
  this.host = host;
44
43
  this.logVerbosity = logVerbosity;
@@ -57,12 +56,13 @@ export class Supervisor extends EventEmitter {
57
56
  this.vramBaselineBytes = null;
58
57
  this.command = null;
59
58
  this.args = null;
59
+ this.launch = null;
60
60
  }
61
61
  get upstreamBase() {
62
62
  return `http://${this.host}:${this.internalPort}`;
63
63
  }
64
64
  /**
65
- * Add a host-operation event to the same in-process tail as llama-server output.
65
+ * Add a host-operation event to the same in-process tail as model-server output.
66
66
  *
67
67
  * Calibrate, sweep, and benchmark deliberately reuse this supervisor rather
68
68
  * than creating invisible sidecar servers. Their lifecycle markers belong in
@@ -75,61 +75,53 @@ export class Supervisor extends EventEmitter {
75
75
  /**
76
76
  * Start (or restart) the server for a model + profile.
77
77
  *
78
- * This is the sole llama-server launch boundary, so it materializes the
79
- * selected hosting profile here. Keeping it beside `buildArgs()` makes the
80
- * Jinja template and router-visible system addendum mandatory for every
78
+ * This is the sole model-server launch boundary, so it materializes the
79
+ * selected hosting profile here. Keeping it at this one driver call makes
80
+ * the Jinja template and router-visible system addendum mandatory for every
81
81
  * caller, including future maintenance operations that start a sidecar.
82
82
  */
83
83
  async start(model, profile) {
84
84
  await this.stop();
85
85
  if (!this.runtime) {
86
- this.lastError = "no llama.cpp runtime available";
86
+ this.lastError = `no ${this.driver.displayName} runtime available`;
87
87
  __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "failed", this.lastError);
88
88
  throw new Error(this.lastError);
89
89
  }
90
90
  const runtime = this.runtime;
91
- // The engine's slot save/erase directory, under the brain's home so it
92
- // survives across model relaunches (the dir is persistent; the engine only
93
- // ever uses it for the `action=erase` the scheduler issues on a handoff,
94
- // which never writes a file). Created before the args are built because
95
- // llama.cpp validates it exists at launch and throws otherwise.
96
- const slotSavePath = path.join(this.paths.root, "slot-saves");
97
- try {
98
- mkdirSync(slotSavePath, { recursive: true });
99
- }
100
- catch {
101
- /* the engine then starts without slot actions - the pre-fix behavior */
102
- }
103
91
  const launchProfile = resolveHostingProfileForLaunch(this.paths, this.getProfilesStore(), profile, model.family);
104
92
  this.model = model;
105
93
  this.profile = launchProfile;
106
94
  this.lastError = null;
107
95
  this.vramBaselineBytes = await usedBytes();
108
96
  __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "starting");
109
- const args = buildArgs({ ...launchProfile, modelPath: model.modelPath, mmprojPath: model.mmprojPath }, {
110
- port: this.internalPort,
97
+ const launch = this.driver.createLaunch({
98
+ runtime,
99
+ model,
100
+ profile: launchProfile,
101
+ // The prompt-cache budget is derived from measured KV bytes/token, so the
102
+ // launch boundary is where it has to be resolved - nothing downstream of
103
+ // here can reach the calibration store.
104
+ calibration: getCalibrationForBudget(this.getProfilesStore(), model, launchProfile),
105
+ paths: this.paths,
111
106
  host: this.host,
107
+ port: this.internalPort,
112
108
  logVerbosity: this.logVerbosity,
113
- slotSavePath,
114
- }, model,
115
- // The prompt-cache budget is derived from measured KV bytes/token, so the
116
- // launch boundary is where it has to be resolved - nothing downstream of
117
- // here can reach the calibration store.
118
- getCalibrationForBudget(this.getProfilesStore(), model, launchProfile));
119
- this.args = args;
120
- this.command = formatCommand(runtime, args);
109
+ });
110
+ this.launch = launch;
111
+ this.args = launch.args;
112
+ this.command = launch.command;
121
113
  __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, formatBrainLog("model", `launching: ${this.command}`));
122
114
  const started = Date.now();
123
- this.child = spawn(runtime.exe, args, {
124
- cwd: runtime.dir,
125
- env: buildEnv(runtime),
115
+ this.child = spawn(launch.executable, launch.args, {
116
+ cwd: launch.cwd,
117
+ env: launch.env,
126
118
  windowsHide: true,
127
119
  stdio: ["ignore", "pipe", "pipe"],
128
120
  });
129
121
  const onChunk = (chunk) => {
130
122
  for (const line of String(chunk).split(/\r?\n/)) {
131
123
  if (line.trim())
132
- __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, formatLlamaServerLog(line.trim()));
124
+ __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, launch.formatLogLine(line.trim()));
133
125
  }
134
126
  };
135
127
  this.child.stdout?.on("data", onChunk);
@@ -143,15 +135,13 @@ export class Supervisor extends EventEmitter {
143
135
  return;
144
136
  }
145
137
  exitedEarly = { code, signal };
146
- // 3221225781 == 0xC0000135 STATUS_DLL_NOT_FOUND: the vendor DLL trap.
147
- const hint = code === 3221225781 ? " (missing runtime DLLs - the vendor directory was not on PATH)" : "";
148
- this.lastError = `llama-server exited with code ${code}${signal ? ` signal ${signal}` : ""}${hint}`;
138
+ this.lastError = this.driver.describeProcessExit({ code, signal });
149
139
  __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "failed", this.lastError);
150
140
  if (wasReady)
151
141
  this.emit("crashed", this.lastError);
152
142
  });
153
143
  this.child.once("error", (error) => {
154
- this.lastError = `could not launch llama-server: ${error.message}`;
144
+ this.lastError = this.driver.describeLaunchError(error);
155
145
  __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "failed", this.lastError);
156
146
  });
157
147
  // Poll /health until the model finishes loading.
@@ -164,7 +154,7 @@ export class Supervisor extends EventEmitter {
164
154
  const used = await usedBytes();
165
155
  if (used && used > peakVram)
166
156
  peakVram = used;
167
- const health = await __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_health).call(this);
157
+ const health = await __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_health).call(this, launch.readinessPath);
168
158
  if (health) {
169
159
  this.loadSeconds = (Date.now() - started) / 1000;
170
160
  this.startedAt = new Date();
@@ -185,8 +175,11 @@ export class Supervisor extends EventEmitter {
185
175
  }
186
176
  /** Fetch /props from the running server (modalities, template caps, defaults). */
187
177
  props() {
178
+ const pathname = this.launch?.propertiesPath;
179
+ if (!pathname)
180
+ return Promise.resolve(null);
188
181
  return new Promise((resolve) => {
189
- const req = http.get({ host: this.host, port: this.internalPort, path: "/props", timeout: 5000 }, (res) => {
182
+ const req = http.get({ host: this.host, port: this.internalPort, path: pathname, timeout: 5000 }, (res) => {
190
183
  let body = "";
191
184
  res.on("data", (c) => (body += c));
192
185
  res.on("end", () => {
@@ -263,9 +256,9 @@ _Supervisor_instances = new WeakSet(), _Supervisor_setState = function _Supervis
263
256
  if (this.logLines.length > LOG_LINES_KEPT)
264
257
  this.logLines.shift();
265
258
  this.emit("log", line);
266
- }, _Supervisor_health = function _Supervisor_health() {
259
+ }, _Supervisor_health = function _Supervisor_health(pathname) {
267
260
  return new Promise((resolve) => {
268
- const req = http.get({ host: this.host, port: this.internalPort, path: "/health", timeout: 2500 }, (res) => {
261
+ const req = http.get({ host: this.host, port: this.internalPort, path: pathname, timeout: 2500 }, (res) => {
269
262
  res.resume();
270
263
  resolve(res.statusCode === 200);
271
264
  });
package/dist/types.d.ts CHANGED
@@ -23,6 +23,12 @@ export interface ModelMetadata {
23
23
  reasoning?: boolean;
24
24
  /** Native template key detected from `preserve_thinking` or `preserve_reasoning`. */
25
25
  reasoningPreservationArgument?: string;
26
+ /** Template argument that switches the thinking channel on or off. */
27
+ reasoningToggleArgument?: string;
28
+ /** Template argument that carries a graduated effort level. */
29
+ reasoningEffortArgument?: string;
30
+ /** Effort levels the chat template names itself, when it validates a set. */
31
+ reasoningEffortValues?: string[];
26
32
  [key: string]: unknown;
27
33
  }
28
34
  export interface ModelFeatures {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otto-code/brain",
3
- "version": "0.8.17",
3
+ "version": "0.8.19",
4
4
  "description": "Otto Brain - self-contained host for local GGUF models, with measured VRAM budgeting and reasoning-budget control",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "bin": {