@otto-code/brain 0.8.10 → 0.8.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/calibrate.js +9 -0
- package/dist/commands/catalog.d.ts +1 -0
- package/dist/commands/catalog.js +1 -0
- package/dist/commands/pull.d.ts +1 -0
- package/dist/commands/pull.js +12 -3
- package/dist/commands/search.d.ts +1 -0
- package/dist/commands/search.js +12 -2
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +1 -1
- package/dist/config/profile-edit.d.ts +88 -1
- package/dist/config/profile-edit.js +280 -29
- package/dist/config/profiles.js +16 -0
- package/dist/config/schema.d.ts +608 -0
- package/dist/config/schema.js +58 -0
- package/dist/config/store.js +7 -4
- package/dist/gguf.d.ts +7 -0
- package/dist/gguf.js +15 -2
- package/dist/models/download.d.ts +1 -1
- package/dist/models/download.js +2 -2
- package/dist/models/enrich.d.ts +6 -0
- package/dist/models/enrich.js +27 -1
- package/dist/models/index.d.ts +1 -1
- package/dist/models/index.js +4 -3
- package/dist/ops/calibrate.d.ts +38 -3
- package/dist/ops/calibrate.js +68 -19
- package/dist/ops/report.js +51 -1
- package/dist/ops/results.d.ts +57 -11
- package/dist/ops/results.js +75 -10
- package/dist/ops/sweep.d.ts +38 -1
- package/dist/ops/sweep.js +61 -10
- package/dist/runtime/args.d.ts +15 -2
- package/dist/runtime/args.js +60 -5
- package/dist/runtime/managed.js +2 -2
- package/dist/service/activity.d.ts +19 -0
- package/dist/service/activity.js +47 -4
- package/dist/service/host-api.d.ts +25 -4
- package/dist/service/host-api.js +82 -16
- package/dist/service/log-format.d.ts +18 -0
- package/dist/service/log-format.js +32 -0
- package/dist/service/router.d.ts +70 -2
- package/dist/service/router.js +219 -21
- package/dist/service/run-log.d.ts +6 -1
- package/dist/service/run-log.js +46 -4
- package/dist/service/scheduler.d.ts +227 -24
- package/dist/service/scheduler.js +395 -63
- package/dist/service/serve.d.ts +4 -0
- package/dist/service/serve.js +302 -117
- package/dist/service/status-events.d.ts +14 -1
- package/dist/service/status-events.js +111 -12
- package/dist/service/supervisor.d.ts +9 -7
- package/dist/service/supervisor.js +37 -12
- package/dist/sysmon.d.ts +15 -0
- package/dist/sysmon.js +56 -9
- package/dist/tui/app.d.ts +8 -2
- package/dist/tui/app.js +65 -17
- package/dist/types.d.ts +18 -0
- package/dist/vram.d.ts +37 -0
- package/dist/vram.js +57 -18
- package/package.json +1 -1
package/dist/config/schema.js
CHANGED
|
@@ -6,6 +6,25 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
export const DEFAULT_REASONING_MESSAGE = "Enough analysis. Write the complete answer now.";
|
|
9
|
+
const TemplateArgumentSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/u);
|
|
10
|
+
/**
|
|
11
|
+
* Model-native names for reasoning controls exposed by its chat template.
|
|
12
|
+
* These are catalog metadata, not caller-provided flags: the host owns the
|
|
13
|
+
* translation from the OpenAI-compatible request into template arguments.
|
|
14
|
+
*/
|
|
15
|
+
export const ReasoningTemplateSchema = z
|
|
16
|
+
.object({
|
|
17
|
+
enableThinkingArgument: TemplateArgumentSchema,
|
|
18
|
+
effortArgument: TemplateArgumentSchema,
|
|
19
|
+
})
|
|
20
|
+
.strict();
|
|
21
|
+
/** A template's native spelling for Brain's provider-neutral preservation setting. */
|
|
22
|
+
export const ReasoningPreservationSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
templateArgument: TemplateArgumentSchema,
|
|
25
|
+
default: z.boolean().optional(),
|
|
26
|
+
})
|
|
27
|
+
.strict();
|
|
9
28
|
// --------------------------------------------------------------- profiles store
|
|
10
29
|
export const ProfileSchema = z
|
|
11
30
|
.object({
|
|
@@ -24,7 +43,37 @@ export const ProfileSchema = z
|
|
|
24
43
|
vision: z.boolean().default(false),
|
|
25
44
|
reasoningBudget: z.number().default(1536),
|
|
26
45
|
reasoningBudgetMessage: z.string().default(DEFAULT_REASONING_MESSAGE),
|
|
46
|
+
/**
|
|
47
|
+
* Tri-state, matching llama-server's own `--reasoning-preserve` /
|
|
48
|
+
* `--no-reasoning-preserve` / unset: `true` keeps the reasoning trace in the
|
|
49
|
+
* whole history, `false` forces it to the last assistant message only, and
|
|
50
|
+
* null/undefined leaves the template's own default alone. Null is the
|
|
51
|
+
* default precisely so an untouched profile emits no flag and launches
|
|
52
|
+
* exactly as it did before this field existed.
|
|
53
|
+
*/
|
|
54
|
+
preserveReasoning: z.boolean().nullable().optional(),
|
|
55
|
+
// ------------------------------------------------------------- sampling
|
|
56
|
+
// Defaults are llama.cpp's own, read from `llama-server --help` on the
|
|
57
|
+
// pinned build (b10433) so an untouched profile runs identically to one
|
|
58
|
+
// that never emitted these flags. They are stored and emitted explicitly
|
|
59
|
+
// rather than left implicit because a sampler the user can see is one they
|
|
60
|
+
// can reason about; re-check them when DEFAULT_LLAMA_BUILD moves.
|
|
61
|
+
temperature: z.number().default(0.8),
|
|
62
|
+
topP: z.number().default(0.95),
|
|
63
|
+
topK: z.number().default(40),
|
|
64
|
+
minP: z.number().default(0.05),
|
|
65
|
+
presencePenalty: z.number().default(0),
|
|
66
|
+
repeatPenalty: z.number().default(1),
|
|
27
67
|
parallelSlots: z.number().default(1),
|
|
68
|
+
/**
|
|
69
|
+
* How many chats' KV state llama-server may park in system RAM when they
|
|
70
|
+
* lose their GPU slot, so returning to one costs a bulk copy instead of a
|
|
71
|
+
* full re-prefill. Stored as a count, not a size: the byte budget it turns
|
|
72
|
+
* into (`--cache-ram`) depends on the measured KV bytes/token and the
|
|
73
|
+
* per-slot context, both of which move when other fields are edited.
|
|
74
|
+
* 0 means "leave llama.cpp's own default alone" - the flag is not emitted.
|
|
75
|
+
*/
|
|
76
|
+
cachedChats: z.number().default(0),
|
|
28
77
|
/** RoPE extension factor; 1 keeps the GGUF-native context window. */
|
|
29
78
|
contextMultiplier: z.number().default(1),
|
|
30
79
|
/** Cleared only by a successful calibration of this saved model profile. */
|
|
@@ -171,6 +220,8 @@ export const RuntimeConfigSchema = z
|
|
|
171
220
|
// auto = prefer a managed runtime, fall back to LM Studio discovery.
|
|
172
221
|
source: z.enum(["auto", "managed", "lmstudio"]).default("auto"),
|
|
173
222
|
path: z.string().nullable().default(null),
|
|
223
|
+
/** llama.cpp's `--log-verbosity`: 0 generic output through 5 debug. */
|
|
224
|
+
logVerbosity: z.number().int().min(0).max(5).default(3),
|
|
174
225
|
})
|
|
175
226
|
.strict();
|
|
176
227
|
export const ProfileDefaultsSchema = z
|
|
@@ -224,6 +275,8 @@ export const CatalogModelSchema = z
|
|
|
224
275
|
name: z.string(),
|
|
225
276
|
/** Stable UI family identity. Otto clients resolve this to a monochrome glyph. */
|
|
226
277
|
family: z.string().optional(),
|
|
278
|
+
/** Otto-curated favorite shown as a gold premium badge in the Brain Library. */
|
|
279
|
+
favorite: z.boolean().default(false),
|
|
227
280
|
publisher: z.string().optional(),
|
|
228
281
|
hfRepo: z.string(),
|
|
229
282
|
quant: z.string(),
|
|
@@ -234,6 +287,9 @@ export const CatalogModelSchema = z
|
|
|
234
287
|
vision: z.boolean().optional(),
|
|
235
288
|
thinking: z.boolean().optional(),
|
|
236
289
|
reasoningEfforts: z.array(z.string()).optional(),
|
|
290
|
+
reasoningEffortDefault: z.string().optional(),
|
|
291
|
+
reasoningTemplate: ReasoningTemplateSchema.optional(),
|
|
292
|
+
reasoningPreservation: ReasoningPreservationSchema.optional(),
|
|
237
293
|
contextMax: z.number().optional(),
|
|
238
294
|
useCases: z.array(z.string()).optional(),
|
|
239
295
|
tier: z.string().optional(),
|
|
@@ -265,6 +321,8 @@ export const CatalogSchema = z
|
|
|
265
321
|
note: z.string().optional(),
|
|
266
322
|
vramBudgetBytes: z.number().optional(),
|
|
267
323
|
systemRamBytes: z.number().optional(),
|
|
324
|
+
/** Retired curated ids that no longer belong to any canonical catalog entry. */
|
|
325
|
+
retiredModelIds: z.array(z.string()).default([]),
|
|
268
326
|
models: z.array(CatalogModelSchema).default([]),
|
|
269
327
|
})
|
|
270
328
|
.passthrough();
|
package/dist/config/store.js
CHANGED
|
@@ -65,10 +65,13 @@ export function loadCatalog(paths = resolveBrainPaths()) {
|
|
|
65
65
|
if (!legacy)
|
|
66
66
|
return current;
|
|
67
67
|
const seedIds = new Set(legacy.models.map((model) => model.id));
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
const retiredSeedIds = new Set(
|
|
68
|
+
// Curated models can be replaced or intentionally removed from the
|
|
69
|
+
// shortlist. Both kinds of retired ids stay product-owned so they do not
|
|
70
|
+
// survive a seed refresh as accidental "user-added" catalog rows.
|
|
71
|
+
const retiredSeedIds = new Set([
|
|
72
|
+
...legacy.retiredModelIds,
|
|
73
|
+
...legacy.models.flatMap((model) => model.replaces ?? []),
|
|
74
|
+
]);
|
|
72
75
|
const userModels = current.models.filter((model) => !seedIds.has(model.id) && !retiredSeedIds.has(model.id));
|
|
73
76
|
const models = [...legacy.models, ...userModels];
|
|
74
77
|
const changed = JSON.stringify(models) !== JSON.stringify(current.models);
|
package/dist/gguf.d.ts
CHANGED
|
@@ -53,7 +53,14 @@ interface GgufSummary {
|
|
|
53
53
|
isProjector: boolean;
|
|
54
54
|
/** Chat template exposes a thinking/reasoning channel. */
|
|
55
55
|
reasoning: boolean;
|
|
56
|
+
/** Native preservation argument, normalized by model enrichment when present. */
|
|
57
|
+
reasoningPreservationArgument?: "preserve_thinking" | "preserve_reasoning";
|
|
56
58
|
}
|
|
59
|
+
/** Read model-specific template spellings without leaking them into the UI contract. */
|
|
60
|
+
export declare function detectTemplateReasoningCapabilities(chatTemplate: string): {
|
|
61
|
+
reasoning: boolean;
|
|
62
|
+
reasoningPreservationArgument?: "preserve_thinking" | "preserve_reasoning";
|
|
63
|
+
};
|
|
57
64
|
/**
|
|
58
65
|
* Pull out the fields that matter for hosting decisions.
|
|
59
66
|
*/
|
package/dist/gguf.js
CHANGED
|
@@ -176,6 +176,19 @@ export function readMetadata(file) {
|
|
|
176
176
|
}
|
|
177
177
|
throw new Error(`could not read GGUF header from ${file}`);
|
|
178
178
|
}
|
|
179
|
+
/** Read model-specific template spellings without leaking them into the UI contract. */
|
|
180
|
+
export function detectTemplateReasoningCapabilities(chatTemplate) {
|
|
181
|
+
const reasoningPreservationArgument = /\bpreserve_thinking\b/iu.test(chatTemplate)
|
|
182
|
+
? "preserve_thinking"
|
|
183
|
+
: /\bpreserve_reasoning\b/iu.test(chatTemplate)
|
|
184
|
+
? "preserve_reasoning"
|
|
185
|
+
: undefined;
|
|
186
|
+
return {
|
|
187
|
+
reasoning: Boolean(reasoningPreservationArgument ||
|
|
188
|
+
/<think>|<\/think>|reasoning_content|enable_thinking/iu.test(chatTemplate)),
|
|
189
|
+
...(reasoningPreservationArgument ? { reasoningPreservationArgument } : {}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
179
192
|
/**
|
|
180
193
|
* Pull out the fields that matter for hosting decisions.
|
|
181
194
|
*/
|
|
@@ -202,7 +215,7 @@ export function summarize(file) {
|
|
|
202
215
|
const chatTemplate = typeof meta["tokenizer.chat_template"] === "string"
|
|
203
216
|
? meta["tokenizer.chat_template"]
|
|
204
217
|
: "";
|
|
205
|
-
const
|
|
218
|
+
const reasoningCapabilities = detectTemplateReasoningCapabilities(chatTemplate);
|
|
206
219
|
return {
|
|
207
220
|
file,
|
|
208
221
|
fileSize,
|
|
@@ -226,7 +239,7 @@ export function summarize(file) {
|
|
|
226
239
|
expertCount: (get("expert_count") ?? null),
|
|
227
240
|
eosTokenId: (meta["tokenizer.ggml.eos_token_id"] ?? null),
|
|
228
241
|
isProjector: Boolean(meta["clip.has_vision_encoder"] || arch === "clip"),
|
|
229
|
-
|
|
242
|
+
...reasoningCapabilities,
|
|
230
243
|
};
|
|
231
244
|
}
|
|
232
245
|
//# sourceMappingURL=gguf.js.map
|
|
@@ -13,7 +13,7 @@ export interface PullOptions {
|
|
|
13
13
|
}
|
|
14
14
|
/** Exact, manifest-driven files for a bundle selection. No quant discovery is
|
|
15
15
|
* involved, so a component pull can never select an arbitrary projector. */
|
|
16
|
-
export declare function bundleDownloadPlan(model: CatalogModel, componentIds?: string[], primaryFiles?: string[], primaryBytes?: number): {
|
|
16
|
+
export declare function bundleDownloadPlan(model: CatalogModel, componentIds?: string[], primaryFiles?: string[], primaryBytes?: number, includeRequired?: boolean): {
|
|
17
17
|
repo: string;
|
|
18
18
|
files: string[];
|
|
19
19
|
totalBytes: number | null;
|
package/dist/models/download.js
CHANGED
|
@@ -195,13 +195,13 @@ async function streamRepoFile(url, destPath, label, token, onProgress, received)
|
|
|
195
195
|
}
|
|
196
196
|
/** Exact, manifest-driven files for a bundle selection. No quant discovery is
|
|
197
197
|
* involved, so a component pull can never select an arbitrary projector. */
|
|
198
|
-
export function bundleDownloadPlan(model, componentIds = [], primaryFiles, primaryBytes) {
|
|
198
|
+
export function bundleDownloadPlan(model, componentIds = [], primaryFiles, primaryBytes, includeRequired = true) {
|
|
199
199
|
const selected = new Set(componentIds);
|
|
200
200
|
const known = new Set((model.components ?? []).map((component) => component.id));
|
|
201
201
|
const unknown = componentIds.filter((id) => !known.has(id));
|
|
202
202
|
if (unknown.length)
|
|
203
203
|
throw new Error(`unknown bundle components: ${unknown.join(", ")}`);
|
|
204
|
-
const components = (model.components ?? []).filter((component) => component.required || selected.has(component.id));
|
|
204
|
+
const components = (model.components ?? []).filter((component) => (includeRequired && component.required) || selected.has(component.id));
|
|
205
205
|
const foreign = components.find((component) => (component.hfRepo ?? model.hfRepo) !== model.hfRepo);
|
|
206
206
|
if (foreign)
|
|
207
207
|
throw new Error(`component ${foreign.id} uses a separate repository and needs its own plan`);
|
package/dist/models/enrich.d.ts
CHANGED
|
@@ -14,6 +14,12 @@ export declare function familyFromGgufMetadata(model: Model): string | undefined
|
|
|
14
14
|
* specific (longest) hfRepo.
|
|
15
15
|
*/
|
|
16
16
|
export declare function matchCatalogEntry(model: Model, catalog: Catalog): CatalogModel | null;
|
|
17
|
+
/**
|
|
18
|
+
* Bundle components are supporting artifacts, not independently selectable
|
|
19
|
+
* models. The disk scanner sees every GGUF, so remove an exact manifest match
|
|
20
|
+
* before the host exposes the inventory to the CLI or desktop client.
|
|
21
|
+
*/
|
|
22
|
+
export declare function excludeCatalogComponentArtifacts(models: Model[], catalog: Catalog): Model[];
|
|
17
23
|
/**
|
|
18
24
|
* Return copies of the models with catalog coding metadata attached where a match
|
|
19
25
|
* exists; models with no match (and every model when the catalog is empty) pass
|
package/dist/models/enrich.js
CHANGED
|
@@ -112,6 +112,15 @@ export function matchCatalogEntry(model, catalog) {
|
|
|
112
112
|
}
|
|
113
113
|
return best;
|
|
114
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Bundle components are supporting artifacts, not independently selectable
|
|
117
|
+
* models. The disk scanner sees every GGUF, so remove an exact manifest match
|
|
118
|
+
* before the host exposes the inventory to the CLI or desktop client.
|
|
119
|
+
*/
|
|
120
|
+
export function excludeCatalogComponentArtifacts(models, catalog) {
|
|
121
|
+
const componentIds = new Set(catalog.models.flatMap((entry) => (entry.components ?? []).map((component) => normalizePath(`${component.hfRepo ?? entry.hfRepo}/${component.file}`))));
|
|
122
|
+
return models.filter((model) => !componentIds.has(normalizePath(model.id)));
|
|
123
|
+
}
|
|
115
124
|
/**
|
|
116
125
|
* Return copies of the models with catalog coding metadata attached where a match
|
|
117
126
|
* exists; models with no match (and every model when the catalog is empty) pass
|
|
@@ -123,7 +132,14 @@ export function enrichWithCatalog(models, catalog) {
|
|
|
123
132
|
if (!entry) {
|
|
124
133
|
const enriched = enrichDiscoveredProjector(model);
|
|
125
134
|
const family = familyFromGgufMetadata(enriched);
|
|
126
|
-
|
|
135
|
+
const reasoningPreservation = detectedReasoningPreservation(enriched);
|
|
136
|
+
if (!family && !reasoningPreservation)
|
|
137
|
+
return enriched;
|
|
138
|
+
return {
|
|
139
|
+
...enriched,
|
|
140
|
+
...(family ? { family } : {}),
|
|
141
|
+
...(reasoningPreservation ? { reasoningPreservation } : {}),
|
|
142
|
+
};
|
|
127
143
|
}
|
|
128
144
|
const components = resolveComponents(model, entry);
|
|
129
145
|
const projector = components?.find((component) => component.role === "vision_projector");
|
|
@@ -141,10 +157,20 @@ export function enrichWithCatalog(models, catalog) {
|
|
|
141
157
|
tier: entry.tier,
|
|
142
158
|
thinking: entry.thinking,
|
|
143
159
|
reasoningEfforts: entry.reasoningEfforts,
|
|
160
|
+
reasoningEffortDefault: entry.reasoningEffortDefault,
|
|
161
|
+
reasoningTemplate: entry.reasoningTemplate,
|
|
162
|
+
reasoningPreservation: entry.reasoningPreservation ?? detectedReasoningPreservation(model),
|
|
144
163
|
contextMax: entry.contextMax,
|
|
145
164
|
};
|
|
146
165
|
});
|
|
147
166
|
}
|
|
167
|
+
/** Map the two known template spellings to one model capability. */
|
|
168
|
+
function detectedReasoningPreservation(model) {
|
|
169
|
+
const templateArgument = model.metadata?.reasoningPreservationArgument;
|
|
170
|
+
return templateArgument === "preserve_thinking" || templateArgument === "preserve_reasoning"
|
|
171
|
+
? { templateArgument }
|
|
172
|
+
: undefined;
|
|
173
|
+
}
|
|
148
174
|
/** Promote a scanner-paired projector in an arbitrary Hugging Face repository
|
|
149
175
|
* into the same component inventory shape used by curated bundles. */
|
|
150
176
|
function enrichDiscoveredProjector(model) {
|
package/dist/models/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export * from "./scan.js";
|
|
|
4
4
|
export { pickModel, pickAutoModel } from "./pick.js";
|
|
5
5
|
export { resolveModelsDirs, managedModelsDir, type ModelsDir } from "./dirs.js";
|
|
6
6
|
export { pullModel, bundleDownloadPlan, downloadRepoFiles, type PullOptions, type PullProgress, type DownloadFilesOptions, } from "./download.js";
|
|
7
|
-
export { matchCatalogEntry, enrichWithCatalog } from "./enrich.js";
|
|
7
|
+
export { matchCatalogEntry, enrichWithCatalog, excludeCatalogComponentArtifacts, } from "./enrich.js";
|
|
8
8
|
export { resolveHfToken, listRepoQuants, searchModels, clearCardSummaryCache, repoOfModel, quantRank, type QuantOption, type RepoQuants, type ModelSearchResult, } from "./hf.js";
|
|
9
9
|
export { diskUsage, totalModelBytes, planDelete, deleteModelFiles, type DiskUsage, type DeletePlan, } from "./manage.js";
|
|
10
10
|
export interface ScanModelsOptions {
|
package/dist/models/index.js
CHANGED
|
@@ -3,14 +3,14 @@ import { resolveBrainPaths } from "../config/paths.js";
|
|
|
3
3
|
import { CatalogSchema } from "../config/schema.js";
|
|
4
4
|
import { loadCatalog } from "../config/store.js";
|
|
5
5
|
import { resolveModelsDirs } from "./dirs.js";
|
|
6
|
-
import { enrichWithCatalog } from "./enrich.js";
|
|
6
|
+
import { enrichWithCatalog, excludeCatalogComponentArtifacts } from "./enrich.js";
|
|
7
7
|
import { loadRenameMap } from "./rename-map.js";
|
|
8
8
|
import { scan } from "./scan.js";
|
|
9
9
|
export * from "./scan.js";
|
|
10
10
|
export { pickModel, pickAutoModel } from "./pick.js";
|
|
11
11
|
export { resolveModelsDirs, managedModelsDir } from "./dirs.js";
|
|
12
12
|
export { pullModel, bundleDownloadPlan, downloadRepoFiles, } from "./download.js";
|
|
13
|
-
export { matchCatalogEntry, enrichWithCatalog } from "./enrich.js";
|
|
13
|
+
export { matchCatalogEntry, enrichWithCatalog, excludeCatalogComponentArtifacts, } from "./enrich.js";
|
|
14
14
|
export { resolveHfToken, listRepoQuants, searchModels, clearCardSummaryCache, repoOfModel, quantRank, } from "./hf.js";
|
|
15
15
|
export { diskUsage, totalModelBytes, planDelete, deleteModelFiles, } from "./manage.js";
|
|
16
16
|
/** Scan every configured models directory (managed ∪ LM Studio), de-duplicated. */
|
|
@@ -33,7 +33,8 @@ export function scanModels(config, env = process.env, options = {}) {
|
|
|
33
33
|
all.push(model);
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
-
const
|
|
36
|
+
const catalog = loadCatalogSafe(env);
|
|
37
|
+
const enriched = enrichWithCatalog(excludeCatalogComponentArtifacts(all, catalog), catalog);
|
|
37
38
|
const renameMap = loadRenameMap(resolveBrainPaths(env));
|
|
38
39
|
for (const model of enriched) {
|
|
39
40
|
if (renameMap[model.id]) {
|
package/dist/ops/calibrate.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Supervisor } from "../service/supervisor.js";
|
|
2
2
|
import type { Model, Runtime } from "../types.js";
|
|
3
|
-
import type { Profile } from "../config/schema.js";
|
|
3
|
+
import type { Calibration, Profile } from "../config/schema.js";
|
|
4
4
|
/**
|
|
5
5
|
* Measure a model's real KV-cache cost per token.
|
|
6
6
|
*
|
|
@@ -10,8 +10,15 @@ import type { Profile } from "../config/schema.js";
|
|
|
10
10
|
* slope: bytes/token = (vram_b - vram_a) / (ctx_b - ctx_a). Everything that
|
|
11
11
|
* does not scale with context (weights, projector, CUDA context, compute
|
|
12
12
|
* buffers) cancels out of the difference.
|
|
13
|
+
*
|
|
14
|
+
* Two invariants keep that slope honest. The high sample is the profile's
|
|
15
|
+
* configured context (the depth the user will actually serve at) capped by
|
|
16
|
+
* what fits in VRAM - never the raw native × multiplier ceiling, which on a
|
|
17
|
+
* tight card loads a cache that spills to system RAM and biases the slope low.
|
|
18
|
+
* A sample whose load split the KV cache to CPU is unusable: the GPU delta
|
|
19
|
+
* misses the spilled share, the bytes/token comes out understated, and the
|
|
20
|
+
* budget then declares a context "fits" that actually runs on a RAM cache.
|
|
13
21
|
*/
|
|
14
|
-
export declare const DEFAULT_SAMPLES: number[];
|
|
15
22
|
/** A single context-size measurement collected during calibration. */
|
|
16
23
|
export interface CalibrationSample {
|
|
17
24
|
contextSize: number;
|
|
@@ -26,14 +33,42 @@ export interface CalibrateProgress {
|
|
|
26
33
|
deltaBytes?: number;
|
|
27
34
|
error?: string;
|
|
28
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Detect that a load split its KV cache onto system RAM.
|
|
38
|
+
*
|
|
39
|
+
* `usedBytes()` reads nvidia-smi, which only sees the GPU share of the cache.
|
|
40
|
+
* When llama-server cannot hold the whole allocation in VRAM it does not fail
|
|
41
|
+
* the load - it offloads the overflow to CPU and the load "succeeds" at a
|
|
42
|
+
* fraction of the VRAM the context should cost. The startup banner is the
|
|
43
|
+
* only place the split is stated.
|
|
44
|
+
*/
|
|
45
|
+
export declare function kvSpilledToCpu(logLines: string[]): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* The largest context size the static VRAM budget allows, or null when the
|
|
48
|
+
* cost per token is unknown and the budget cannot answer. Used to keep the
|
|
49
|
+
* calibration samples inside the region where the GPU sees the whole cache.
|
|
50
|
+
*/
|
|
51
|
+
export declare function maxContextForCalibration(model: Model, profile: Profile, calibration: Calibration | null, totalVramBytes: number): number | null;
|
|
29
52
|
export interface CalibrateOptions {
|
|
30
53
|
runtime: Runtime;
|
|
31
54
|
model: Model;
|
|
32
55
|
profile: Profile;
|
|
56
|
+
/** Explicit context sizes to measure. Replaces the default strategy entirely. */
|
|
33
57
|
samples?: number[];
|
|
58
|
+
/**
|
|
59
|
+
* The calibration being replaced, if one exists. The best prior on bytes/token
|
|
60
|
+
* for this exact profile shape, so the sample cap can use the measured figure
|
|
61
|
+
* instead of the (over-estimating) theoretical one.
|
|
62
|
+
*/
|
|
63
|
+
priorCalibration?: Calibration | null;
|
|
34
64
|
internalPort?: number;
|
|
35
65
|
/** Reuse the host's resident supervisor instead of creating a sidecar server. */
|
|
36
66
|
supervisor?: Supervisor;
|
|
67
|
+
/**
|
|
68
|
+
* Pause after stopping each sample so the driver releases its allocation
|
|
69
|
+
* before the next load. Tests run with zero.
|
|
70
|
+
*/
|
|
71
|
+
releaseDelayMs?: number;
|
|
37
72
|
onProgress?: (event: CalibrateProgress) => void;
|
|
38
73
|
}
|
|
39
74
|
/** The measured KV-cache profile calibration produces. */
|
|
@@ -48,5 +83,5 @@ export interface CalibrationMeasurement {
|
|
|
48
83
|
vision: boolean;
|
|
49
84
|
measuredAt: string;
|
|
50
85
|
}
|
|
51
|
-
export declare function calibrate({ runtime, model, profile, samples, internalPort, supervisor: optionsSupervisor, onProgress, }: CalibrateOptions): Promise<CalibrationMeasurement>;
|
|
86
|
+
export declare function calibrate({ runtime, model, profile, samples, priorCalibration, internalPort, supervisor: optionsSupervisor, releaseDelayMs, onProgress, }: CalibrateOptions): Promise<CalibrationMeasurement>;
|
|
52
87
|
//# sourceMappingURL=calibrate.d.ts.map
|
package/dist/ops/calibrate.js
CHANGED
|
@@ -1,24 +1,67 @@
|
|
|
1
|
-
import { usedBytes } from "../gpu.js";
|
|
1
|
+
import { query, usedBytes } from "../gpu.js";
|
|
2
2
|
import * as vram from "../vram.js";
|
|
3
3
|
import { DEFAULT_INTERNAL_PORT, Supervisor } from "../service/supervisor.js";
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* Detect that a load split its KV cache onto system RAM.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* buffers) cancels out of the difference.
|
|
7
|
+
* `usedBytes()` reads nvidia-smi, which only sees the GPU share of the cache.
|
|
8
|
+
* When llama-server cannot hold the whole allocation in VRAM it does not fail
|
|
9
|
+
* the load - it offloads the overflow to CPU and the load "succeeds" at a
|
|
10
|
+
* fraction of the VRAM the context should cost. The startup banner is the
|
|
11
|
+
* only place the split is stated.
|
|
13
12
|
*/
|
|
14
|
-
export
|
|
15
|
-
|
|
13
|
+
export function kvSpilledToCpu(logLines) {
|
|
14
|
+
return logLines.some((line) => /KV .*split|CPU buffer size|KV cache.*offload|offloading \d+ layers? to cpu/i.test(line));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The largest context size the static VRAM budget allows, or null when the
|
|
18
|
+
* cost per token is unknown and the budget cannot answer. Used to keep the
|
|
19
|
+
* calibration samples inside the region where the GPU sees the whole cache.
|
|
20
|
+
*/
|
|
21
|
+
export function maxContextForCalibration(model, profile, calibration, totalVramBytes) {
|
|
22
|
+
const max = vram.maxContextThatFits({
|
|
23
|
+
model,
|
|
24
|
+
profile,
|
|
25
|
+
calibration,
|
|
26
|
+
totalVramBytes,
|
|
27
|
+
});
|
|
28
|
+
return max && max >= 4096 ? max : null;
|
|
29
|
+
}
|
|
30
|
+
export async function calibrate({ runtime, model, profile, samples, priorCalibration = null, internalPort = DEFAULT_INTERNAL_PORT + 1, supervisor: optionsSupervisor, releaseDelayMs = 3000, onProgress = () => { }, }) {
|
|
16
31
|
const nativeContext = model.metadata?.contextLength ?? null;
|
|
17
|
-
const
|
|
18
|
-
|
|
32
|
+
const nativeCeiling = nativeContext ? nativeContext * profile.contextMultiplier : null;
|
|
33
|
+
const gpu = (await query());
|
|
34
|
+
// The high sample is the depth the profile will actually serve at - its
|
|
35
|
+
// configured context, clamped to the native × multiplier ceiling - not the
|
|
36
|
+
// raw ceiling itself. On a card where that depth does not fit, the static
|
|
37
|
+
// budget caps it at the largest context that does. Measuring at a context
|
|
38
|
+
// whose KV spills to CPU understates bytes/token and poisons the budget.
|
|
39
|
+
const configured = Math.max(4096, profile.contextSize);
|
|
40
|
+
let high = configured;
|
|
41
|
+
if (nativeCeiling !== null && high > nativeCeiling)
|
|
42
|
+
high = nativeCeiling;
|
|
43
|
+
if (gpu !== null) {
|
|
44
|
+
const maxFits = maxContextForCalibration(model, profile, priorCalibration, gpu.totalBytes);
|
|
45
|
+
if (maxFits !== null && high > maxFits) {
|
|
46
|
+
high = maxFits;
|
|
47
|
+
onProgress({
|
|
48
|
+
phase: "skip",
|
|
49
|
+
contextSize: configured,
|
|
50
|
+
reason: `configured context exceeds what fits in VRAM; measuring at ${high.toLocaleString()}`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// The low sample is a fixed small context far enough below the serving depth
|
|
55
|
+
// that the KV delta dominates the fixed terms (weights, CUDA context). The
|
|
56
|
+
// high sample never drops below it: a VRAM cap that pushed the two together
|
|
57
|
+
// would make the slope a difference of near-equal numbers, and at high == low
|
|
58
|
+
// a division by zero.
|
|
59
|
+
const lowContext = 4096;
|
|
60
|
+
const highContext = Math.max(high, lowContext * 2);
|
|
61
|
+
const effectiveSamples = samples ?? [lowContext, highContext];
|
|
19
62
|
if (effectiveSamples.length < 2)
|
|
20
63
|
throw new Error("calibration needs at least two context sizes");
|
|
21
|
-
const native =
|
|
64
|
+
const native = nativeCeiling ?? Math.max(...effectiveSamples);
|
|
22
65
|
const points = [];
|
|
23
66
|
for (const contextSize of effectiveSamples) {
|
|
24
67
|
if (contextSize > native) {
|
|
@@ -29,7 +72,12 @@ export async function calibrate({ runtime, model, profile, samples, internalPort
|
|
|
29
72
|
onProgress({ phase: "loading", contextSize });
|
|
30
73
|
const baseline = await usedBytes();
|
|
31
74
|
try {
|
|
32
|
-
await supervisor.start(model, { ...profile, contextSize }
|
|
75
|
+
await supervisor.start(model, { ...profile, contextSize });
|
|
76
|
+
if (kvSpilledToCpu(supervisor.logLines)) {
|
|
77
|
+
const reason = `KV cache split to CPU at ${contextSize.toLocaleString()} context`;
|
|
78
|
+
onProgress({ phase: "failed", contextSize, reason });
|
|
79
|
+
throw new Error(`${reason} - the context does not fit in VRAM. Lower the context or use a smaller KV cache type before calibrating.`);
|
|
80
|
+
}
|
|
33
81
|
const used = supervisor.vramAtReadyBytes ?? (await usedBytes());
|
|
34
82
|
const delta = Number(used) - Number(supervisor.vramBaselineBytes ?? baseline);
|
|
35
83
|
points.push({ contextSize, deltaBytes: delta, loadSeconds: supervisor.loadSeconds });
|
|
@@ -46,21 +94,22 @@ export async function calibrate({ runtime, model, profile, samples, internalPort
|
|
|
46
94
|
finally {
|
|
47
95
|
await supervisor.stop();
|
|
48
96
|
// Let the driver actually release the allocation before the next sample.
|
|
49
|
-
|
|
97
|
+
if (releaseDelayMs > 0)
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, releaseDelayMs));
|
|
50
99
|
}
|
|
51
100
|
}
|
|
52
101
|
if (points.length < 2) {
|
|
53
102
|
throw new Error("calibration produced fewer than two usable samples");
|
|
54
103
|
}
|
|
55
104
|
points.sort((a, b) => a.contextSize - b.contextSize);
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const kvBytesPerToken = (
|
|
105
|
+
const lowPoint = points[0];
|
|
106
|
+
const highPoint = points[points.length - 1];
|
|
107
|
+
const kvBytesPerToken = (highPoint.deltaBytes - lowPoint.deltaBytes) / (highPoint.contextSize - lowPoint.contextSize);
|
|
59
108
|
if (!(kvBytesPerToken > 0)) {
|
|
60
109
|
throw new Error("measured a non-positive bytes-per-token; results are unusable");
|
|
61
110
|
}
|
|
62
111
|
const fixedBytes = model.sizeBytes + (profile.vision && model.mmprojPath ? model.mmprojBytes : 0);
|
|
63
|
-
const baseOverheadBytes = Math.max(0,
|
|
112
|
+
const baseOverheadBytes = Math.max(0, lowPoint.deltaBytes - fixedBytes - kvBytesPerToken * lowPoint.contextSize);
|
|
64
113
|
const theoretical = vram.theoreticalKvBytesPerToken(model.metadata, profile.cacheTypeK, profile.cacheTypeV);
|
|
65
114
|
return {
|
|
66
115
|
kvBytesPerToken,
|
package/dist/ops/report.js
CHANGED
|
@@ -28,6 +28,54 @@ function escapeHtml(text) {
|
|
|
28
28
|
function formatGiB(bytes) {
|
|
29
29
|
return bytes ? `${(bytes / 1024 ** 3).toFixed(1)} GB` : "-";
|
|
30
30
|
}
|
|
31
|
+
// The sampler defaults come from `configKey`'s own constant: the report and
|
|
32
|
+
// the grouping key must agree on what counts as "the engine default", or the
|
|
33
|
+
// report would print settings the key says were never changed.
|
|
34
|
+
const SAMPLER_DEFAULTS = results.ENGINE_SAMPLER_DEFAULTS;
|
|
35
|
+
/**
|
|
36
|
+
* The schema-3 profile settings that changed what a run was measured with,
|
|
37
|
+
* as a short human line. Returns "" when nothing deviates from the engine
|
|
38
|
+
* defaults, so an untouched run prints exactly what it always did. Older
|
|
39
|
+
* records that predate these fields store `null` for every one, which also
|
|
40
|
+
* collapses to "" - the report never guesses a value it was not given.
|
|
41
|
+
*/
|
|
42
|
+
function formatProfileExtras(record) {
|
|
43
|
+
const p = record.profile;
|
|
44
|
+
if (!p)
|
|
45
|
+
return "";
|
|
46
|
+
const parts = [];
|
|
47
|
+
if (p.contextMultiplier !== null && p.contextMultiplier > 1) {
|
|
48
|
+
parts.push(`x${p.contextMultiplier} ctx`);
|
|
49
|
+
}
|
|
50
|
+
if (p.cachedChats !== null && p.cachedChats > 0) {
|
|
51
|
+
parts.push(`${p.cachedChats} cached`);
|
|
52
|
+
}
|
|
53
|
+
if (p.preserveReasoning === true)
|
|
54
|
+
parts.push("reasoning preserved");
|
|
55
|
+
else if (p.preserveReasoning === false)
|
|
56
|
+
parts.push("reasoning trimmed");
|
|
57
|
+
// Each entry pairs the display name with the `SAMPLER_DEFAULTS` key the
|
|
58
|
+
// default is looked up by - the two deliberately differ (see `configKey`).
|
|
59
|
+
const sampler = [
|
|
60
|
+
["temp", p.temperature, "temperature"],
|
|
61
|
+
["topP", p.topP, "topP"],
|
|
62
|
+
["topK", p.topK, "topK"],
|
|
63
|
+
["minP", p.minP, "minP"],
|
|
64
|
+
["pres", p.presencePenalty, "presencePenalty"],
|
|
65
|
+
["rep", p.repeatPenalty, "repeatPenalty"],
|
|
66
|
+
];
|
|
67
|
+
// `typeof` rather than a null check: a record from before the sampler was
|
|
68
|
+
// stored carries `undefined` for every one of these, and that must read as
|
|
69
|
+
// "not recorded", not as a deviation.
|
|
70
|
+
const deviated = sampler
|
|
71
|
+
.filter(([, value, key]) => typeof value === "number" && value !== SAMPLER_DEFAULTS[key])
|
|
72
|
+
.map(([name, value]) => `${name} ${value}`);
|
|
73
|
+
if (deviated.length > 0)
|
|
74
|
+
parts.push(deviated.join(", "));
|
|
75
|
+
if (p.hostingProfileId)
|
|
76
|
+
parts.push("hosting profile");
|
|
77
|
+
return parts.join(" · ");
|
|
78
|
+
}
|
|
31
79
|
/**
|
|
32
80
|
* Per-model score card: each task labelled directly with its weight, then the
|
|
33
81
|
* weighted Overall on its own row so the headline number reads as the sum of its
|
|
@@ -61,6 +109,7 @@ function groupedBars(records, columns, weightOf) {
|
|
|
61
109
|
record.model.quant,
|
|
62
110
|
`ctx ${(record.profile?.contextSize || 0).toLocaleString()}`,
|
|
63
111
|
`rb ${record.profile?.reasoningBudget}`,
|
|
112
|
+
formatProfileExtras(record),
|
|
64
113
|
]
|
|
65
114
|
.filter(Boolean)
|
|
66
115
|
.join(" · ");
|
|
@@ -267,6 +316,7 @@ function build(records, allRuns = records) {
|
|
|
267
316
|
return (`<tr><td>${escapeHtml(r.model.displayName)}</td><td>${escapeHtml(r.model.quant || "-")}</td>` +
|
|
268
317
|
`<td class="num">${(r.profile?.contextSize || 0).toLocaleString()}</td>` +
|
|
269
318
|
`<td class="num">${r.profile?.reasoningBudget ?? "-"}</td>` +
|
|
319
|
+
`<td class="muted">${escapeHtml(formatProfileExtras(r) || "-")}</td>` +
|
|
270
320
|
`${cells}<td class="num strong">${(r.overall * 100).toFixed(0)}%</td>` +
|
|
271
321
|
`<td class="num">${formatGiB(r.vramBytes)}</td>` +
|
|
272
322
|
`<td class="muted">${escapeHtml(r.ranAt.slice(0, 16).replace("T", " "))}</td></tr>`);
|
|
@@ -434,7 +484,7 @@ function build(records, allRuns = records) {
|
|
|
434
484
|
<h2>All runs</h2>
|
|
435
485
|
<div class="scroll">
|
|
436
486
|
<table>
|
|
437
|
-
<thead><tr><th>Model</th><th>Quant</th><th class="num">Context</th><th class="num">Reasoning</th>
|
|
487
|
+
<thead><tr><th>Model</th><th>Quant</th><th class="num">Context</th><th class="num">Reasoning</th><th>Settings</th>
|
|
438
488
|
${columns.map((c) => `<th class="num">${escapeHtml(c.category)}<span class="wt">×${weightOf.get(c.id) ?? "?"}</span></th>`).join("")}
|
|
439
489
|
<th class="num">Overall<span class="wt">wtd</span></th><th class="num">VRAM</th><th>Run at</th></tr></thead>
|
|
440
490
|
<tbody>${tableRows}</tbody>
|