@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 +9 -0
- package/dist/gguf.js +52 -1
- package/dist/models/enrich.js +40 -3
- package/dist/models/pick.d.ts +2 -3
- package/dist/models/pick.js +2 -3
- package/dist/ops/calibrate.d.ts +10 -1
- package/dist/ops/calibrate.js +13 -3
- package/dist/ops/sweep.d.ts +6 -1
- package/dist/ops/sweep.js +13 -3
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/model-server-driver.d.ts +42 -0
- package/dist/runtime/model-server-driver.js +50 -0
- package/dist/service/process-pool.d.ts +17 -1
- package/dist/service/process-pool.js +31 -9
- package/dist/service/router.d.ts +4 -2
- package/dist/service/router.js +44 -12
- package/dist/service/serve.js +60 -27
- package/dist/service/supervisor.d.ts +11 -6
- package/dist/service/supervisor.js +38 -45
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
/**
|
package/dist/models/enrich.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/models/pick.d.ts
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
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
|
package/dist/models/pick.js
CHANGED
|
@@ -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
|
|
41
|
-
*
|
|
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) {
|
package/dist/ops/calibrate.d.ts
CHANGED
|
@@ -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
|
package/dist/ops/calibrate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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));
|
package/dist/ops/sweep.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -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[];
|
package/dist/runtime/index.js
CHANGED
|
@@ -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);
|
package/dist/service/router.d.ts
CHANGED
|
@@ -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
|
|
117
|
-
*
|
|
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
|
/**
|
package/dist/service/router.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
288
|
-
*
|
|
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
|
-
|
|
309
|
-
|
|
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 =
|
|
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
|
package/dist/service/serve.js
CHANGED
|
@@ -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,
|
|
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
|
-
.
|
|
175
|
+
.submitOperation(model, (process) => controller.signal.aborted
|
|
176
176
|
? Promise.reject(new Error("Operation canceled."))
|
|
177
|
-
: this.runResidentJob(
|
|
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
|
-
|
|
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 =
|
|
460
|
+
model = pickModel(catalog, needle);
|
|
458
461
|
}
|
|
459
462
|
catch (error) {
|
|
460
|
-
// A removed default
|
|
461
|
-
//
|
|
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
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
93
|
-
* selected hosting profile here. Keeping it
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
79
|
-
* selected hosting profile here. Keeping it
|
|
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 =
|
|
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
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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(
|
|
124
|
-
cwd:
|
|
125
|
-
env:
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
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