@otto-code/brain 0.8.7 → 0.8.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +2 -1
- package/dist/commands/bench.js +19 -5
- package/dist/commands/catalog.d.ts +3 -0
- package/dist/commands/catalog.js +2 -0
- package/dist/commands/pull.d.ts +1 -0
- package/dist/commands/pull.js +47 -14
- package/dist/commands/repo-download.d.ts +14 -0
- package/dist/commands/repo-download.js +29 -0
- package/dist/commands/runtime.d.ts +3 -0
- package/dist/commands/runtime.js +65 -18
- package/dist/commands/search.d.ts +3 -0
- package/dist/commands/search.js +38 -17
- package/dist/config/builtin-hosting-profiles.d.ts +8 -0
- package/dist/config/builtin-hosting-profiles.js +32 -0
- package/dist/config/hosting-profiles.d.ts +33 -0
- package/dist/config/hosting-profiles.js +71 -0
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +1 -0
- package/dist/config/paths.d.ts +2 -0
- package/dist/config/paths.js +1 -0
- package/dist/config/profile-edit.d.ts +5 -3
- package/dist/config/profile-edit.js +134 -14
- package/dist/config/profiles.js +66 -3
- package/dist/config/schema.d.ts +998 -0
- package/dist/config/schema.js +79 -0
- package/dist/config/store.js +28 -16
- package/dist/gguf.d.ts +1 -0
- package/dist/gguf.js +1 -0
- package/dist/models/download.d.ts +7 -0
- package/dist/models/download.js +165 -17
- package/dist/models/enrich.d.ts +6 -19
- package/dist/models/enrich.js +138 -4
- package/dist/models/hf.d.ts +14 -1
- package/dist/models/hf.js +239 -6
- package/dist/models/index.d.ts +2 -2
- package/dist/models/index.js +8 -5
- package/dist/models/manage.d.ts +4 -0
- package/dist/models/manage.js +34 -4
- package/dist/models/scan.js +8 -42
- package/dist/ops/calibrate.d.ts +4 -1
- package/dist/ops/calibrate.js +10 -7
- package/dist/ops/sweep.d.ts +3 -1
- package/dist/ops/sweep.js +3 -3
- package/dist/runtime/args.d.ts +2 -2
- package/dist/runtime/args.js +18 -1
- package/dist/runtime/index.d.ts +8 -1
- package/dist/runtime/index.js +11 -1
- package/dist/runtime/managed.d.ts +40 -0
- package/dist/runtime/managed.js +146 -7
- package/dist/service/host-api.d.ts +20 -3
- package/dist/service/host-api.js +313 -20
- package/dist/service/router.d.ts +18 -0
- package/dist/service/router.js +89 -4
- package/dist/service/serve.js +221 -22
- package/dist/service/supervisor.d.ts +32 -4
- package/dist/service/supervisor.js +30 -6
- package/dist/tui/app.js +1 -1
- package/dist/types.d.ts +24 -0
- package/dist/vram.d.ts +3 -0
- package/dist/vram.js +24 -6
- package/package.json +1 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { BrainPaths } from "./paths.js";
|
|
2
|
+
import type { HostingProfile, Profile, ProfilesStore } from "./schema.js";
|
|
3
|
+
/**
|
|
4
|
+
* The bucket a model's hosting profiles live in. Families are catalog-authoritative
|
|
5
|
+
* when available, otherwise discovery derives them from GGUF metadata; models
|
|
6
|
+
* with neither use one real bucket rather than each being unaddressable. This
|
|
7
|
+
* must be the single definition: a writer that stored under "generic" while a
|
|
8
|
+
* reader looked up `null` was why a family default could be set on an unfamilied
|
|
9
|
+
* model and never take effect.
|
|
10
|
+
*/
|
|
11
|
+
export declare const GENERIC_HOSTING_FAMILY = "generic";
|
|
12
|
+
export declare function hostingFamily(family: string | null | undefined): string;
|
|
13
|
+
export declare function familyHostingProfileId(store: ProfilesStore, family: string | null | undefined): string | null;
|
|
14
|
+
export declare function effectiveHostingProfile(store: ProfilesStore, profile: Profile, family: string | null | undefined): HostingProfile | null;
|
|
15
|
+
/**
|
|
16
|
+
* Materialize a selected profile immediately before launch: the template as a
|
|
17
|
+
* file llama-server reads, the system addendum as a string the router injects
|
|
18
|
+
* into each completion request.
|
|
19
|
+
*
|
|
20
|
+
* The addendum is deliberately *not* spliced into the Jinja template, which was
|
|
21
|
+
* the first design. Doing that means rewriting the `messages` list from inside
|
|
22
|
+
* the template, and there is no portable way to do it: minja's support for list
|
|
23
|
+
* mutation differs from Jinja2's, every model family's template consumes the
|
|
24
|
+
* system turn differently, and a message's `content` is an array of parts for
|
|
25
|
+
* multimodal requests rather than a string to concatenate onto. Injecting into
|
|
26
|
+
* the parsed request body is the same composition (it appends to the agent's
|
|
27
|
+
* existing system message instead of replacing it) done where the shape is
|
|
28
|
+
* known and testable.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resolveHostingProfileForLaunch(paths: BrainPaths, store: ProfilesStore, profile: Profile, family: string | null | undefined): Profile;
|
|
31
|
+
/** Remove a deleted profile's generated template. Missing or locked files are non-fatal. */
|
|
32
|
+
export declare function removeHostingProfileMaterialization(paths: BrainPaths, id: string): void;
|
|
33
|
+
//# sourceMappingURL=hosting-profiles.d.ts.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Brain-owned named chat-template compositions for llama-server. */
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { writePrivateFileAtomicSync } from "./private-files.js";
|
|
5
|
+
const SAFE_FILE = /[^a-zA-Z0-9_-]/gu;
|
|
6
|
+
function materializedTemplatePath(paths, id) {
|
|
7
|
+
return path.join(paths.templatesDir, `${id.replace(SAFE_FILE, "_")}.jinja`);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The bucket a model's hosting profiles live in. Families are catalog-authoritative
|
|
11
|
+
* when available, otherwise discovery derives them from GGUF metadata; models
|
|
12
|
+
* with neither use one real bucket rather than each being unaddressable. This
|
|
13
|
+
* must be the single definition: a writer that stored under "generic" while a
|
|
14
|
+
* reader looked up `null` was why a family default could be set on an unfamilied
|
|
15
|
+
* model and never take effect.
|
|
16
|
+
*/
|
|
17
|
+
export const GENERIC_HOSTING_FAMILY = "generic";
|
|
18
|
+
export function hostingFamily(family) {
|
|
19
|
+
return family || GENERIC_HOSTING_FAMILY;
|
|
20
|
+
}
|
|
21
|
+
export function familyHostingProfileId(store, family) {
|
|
22
|
+
return store.familyHostingProfileIds[hostingFamily(family)] ?? null;
|
|
23
|
+
}
|
|
24
|
+
export function effectiveHostingProfile(store, profile, family) {
|
|
25
|
+
const selected = profile.hostingProfileMode === "inherit"
|
|
26
|
+
? familyHostingProfileId(store, family)
|
|
27
|
+
: profile.hostingProfileMode === "custom"
|
|
28
|
+
? profile.hostingProfileId
|
|
29
|
+
: null;
|
|
30
|
+
return selected ? (store.hostingProfiles[selected] ?? null) : null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Materialize a selected profile immediately before launch: the template as a
|
|
34
|
+
* file llama-server reads, the system addendum as a string the router injects
|
|
35
|
+
* into each completion request.
|
|
36
|
+
*
|
|
37
|
+
* The addendum is deliberately *not* spliced into the Jinja template, which was
|
|
38
|
+
* the first design. Doing that means rewriting the `messages` list from inside
|
|
39
|
+
* the template, and there is no portable way to do it: minja's support for list
|
|
40
|
+
* mutation differs from Jinja2's, every model family's template consumes the
|
|
41
|
+
* system turn differently, and a message's `content` is an array of parts for
|
|
42
|
+
* multimodal requests rather than a string to concatenate onto. Injecting into
|
|
43
|
+
* the parsed request body is the same composition (it appends to the agent's
|
|
44
|
+
* existing system message instead of replacing it) done where the shape is
|
|
45
|
+
* known and testable.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveHostingProfileForLaunch(paths, store, profile, family) {
|
|
48
|
+
const selected = effectiveHostingProfile(store, profile, family);
|
|
49
|
+
const chatSystemAddendum = selected?.systemPromptAddendum?.trim() || null;
|
|
50
|
+
if (!selected?.template) {
|
|
51
|
+
return { ...profile, chatTemplateFile: null, chatTemplateKwargs: {}, chatSystemAddendum };
|
|
52
|
+
}
|
|
53
|
+
const file = materializedTemplatePath(paths, selected.id);
|
|
54
|
+
writePrivateFileAtomicSync(file, selected.template);
|
|
55
|
+
return {
|
|
56
|
+
...profile,
|
|
57
|
+
chatTemplateFile: file,
|
|
58
|
+
chatTemplateKwargs: selected.templateKwargs,
|
|
59
|
+
chatSystemAddendum,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Remove a deleted profile's generated template. Missing or locked files are non-fatal. */
|
|
63
|
+
export function removeHostingProfileMaterialization(paths, id) {
|
|
64
|
+
try {
|
|
65
|
+
rmSync(materializedTemplatePath(paths, id), { force: true });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Profile deletion must not fail because a previous launch left a file locked.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=hosting-profiles.js.map
|
package/dist/config/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export { resolveBrainPaths, packageRoot, type BrainPaths } from "./paths.js";
|
|
|
5
5
|
export { parseBooleanEnv, applyEnvOverrides } from "./env.js";
|
|
6
6
|
export { loadBrainConfig, loadPersistedConfig, saveBrainConfig, loadProfilesStore, saveProfilesStore, loadCatalog, } from "./store.js";
|
|
7
7
|
export { defaultProfile, forModel, put, calibrationKey, geometryKey, getCalibration, putCalibration, hasStaleCalibration, } from "./profiles.js";
|
|
8
|
+
export { effectiveHostingProfile, resolveHostingProfileForLaunch } from "./hosting-profiles.js";
|
|
8
9
|
export { calibrationInfo, nativeContextLimit, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, formatReasoningBudget, CACHE_TYPE_CYCLE, REASONING_BUDGET_CYCLE, UNRESTRICTED_REASONING_BUDGET, type CalibrationInfo, type CalibrationState, type ProfileFieldDescriptor, type ProfileWarning, } from "./profile-edit.js";
|
|
9
10
|
export * from "./schema.js";
|
|
10
11
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/config/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { resolveBrainPaths, packageRoot } from "./paths.js";
|
|
|
5
5
|
export { parseBooleanEnv, applyEnvOverrides } from "./env.js";
|
|
6
6
|
export { loadBrainConfig, loadPersistedConfig, saveBrainConfig, loadProfilesStore, saveProfilesStore, loadCatalog, } from "./store.js";
|
|
7
7
|
export { defaultProfile, forModel, put, calibrationKey, geometryKey, getCalibration, putCalibration, hasStaleCalibration, } from "./profiles.js";
|
|
8
|
+
export { effectiveHostingProfile, resolveHostingProfileForLaunch } from "./hosting-profiles.js";
|
|
8
9
|
export { calibrationInfo, nativeContextLimit, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, formatReasoningBudget, CACHE_TYPE_CYCLE, REASONING_BUDGET_CYCLE, UNRESTRICTED_REASONING_BUDGET, } from "./profile-edit.js";
|
|
9
10
|
export * from "./schema.js";
|
|
10
11
|
//# sourceMappingURL=index.js.map
|
package/dist/config/paths.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface BrainPaths {
|
|
|
12
12
|
logFile: string;
|
|
13
13
|
logsDir: string;
|
|
14
14
|
resultsDir: string;
|
|
15
|
+
/** Brain-owned Jinja files materialized from named hosting profiles. */
|
|
16
|
+
templatesDir: string;
|
|
15
17
|
}
|
|
16
18
|
export declare function resolveBrainPaths(env?: NodeJS.ProcessEnv): BrainPaths;
|
|
17
19
|
/**
|
package/dist/config/paths.js
CHANGED
|
@@ -45,10 +45,12 @@ export declare const MAX_PARALLEL_SLOTS = 16;
|
|
|
45
45
|
export declare const MAX_GPU_LAYERS = 999;
|
|
46
46
|
export declare const MIN_CONTEXT_SIZE = 1024;
|
|
47
47
|
export declare const CONTEXT_STEP = 8192;
|
|
48
|
+
export declare const CONTEXT_MULTIPLIERS: number[];
|
|
48
49
|
/** The context ceiling: the model's native window, or a generous bound if unknown. */
|
|
49
50
|
export declare function nativeContextLimit(model: Model | null): number;
|
|
51
|
+
export declare function contextLimit(model: Model | null, multiplier?: number): number;
|
|
50
52
|
/** The editable fields, resolved against one model's capabilities. */
|
|
51
|
-
export declare function profileFieldDescriptors(model: Model | null): ProfileFieldDescriptor[];
|
|
53
|
+
export declare function profileFieldDescriptors(model: Model | null, profile?: Profile | null): ProfileFieldDescriptor[];
|
|
52
54
|
/** A note attached to a field, or to the profile as a whole when `field` is null. */
|
|
53
55
|
export interface ProfileWarning {
|
|
54
56
|
field: string | null;
|
|
@@ -83,12 +85,12 @@ export interface SanitizeResult {
|
|
|
83
85
|
* Apply an editable patch to a profile, clamping every field to its range and
|
|
84
86
|
* dropping anything the model cannot use.
|
|
85
87
|
*
|
|
86
|
-
* Only the
|
|
88
|
+
* Only the supported editable keys are honoured. `modelPath`/`mmprojPath`/`modelId`
|
|
87
89
|
* are re-derived from the model on every read (`profiles.forModel`), so letting
|
|
88
90
|
* a caller set them would be a lie at best and a path-traversal at worst.
|
|
89
91
|
* `reasoningBudgetMessage`, `batchSize`, `ubatchSize` and `extraArgs` stay
|
|
90
92
|
* CLI-only: they have no measured effect worth exposing and `extraArgs` is
|
|
91
93
|
* arbitrary process arguments.
|
|
92
94
|
*/
|
|
93
|
-
export declare function sanitizeProfilePatch(current: Profile, patch: unknown, model: Model | null): SanitizeResult;
|
|
95
|
+
export declare function sanitizeProfilePatch(current: Profile, patch: unknown, model: Model | null, runtimeBuild?: number | null): SanitizeResult;
|
|
94
96
|
//# sourceMappingURL=profile-edit.d.ts.map
|
|
@@ -47,22 +47,38 @@ export const MAX_PARALLEL_SLOTS = 16;
|
|
|
47
47
|
export const MAX_GPU_LAYERS = 999;
|
|
48
48
|
export const MIN_CONTEXT_SIZE = 1024;
|
|
49
49
|
export const CONTEXT_STEP = 8192;
|
|
50
|
+
export const CONTEXT_MULTIPLIERS = [1, 2, 4];
|
|
50
51
|
/** The context ceiling: the model's native window, or a generous bound if unknown. */
|
|
51
52
|
export function nativeContextLimit(model) {
|
|
52
53
|
const native = model?.metadata?.contextLength;
|
|
53
54
|
return typeof native === "number" && native > 0 ? native : 1000000;
|
|
54
55
|
}
|
|
56
|
+
export function contextLimit(model, multiplier = 1) {
|
|
57
|
+
return nativeContextLimit(model) * multiplier;
|
|
58
|
+
}
|
|
55
59
|
/** The editable fields, resolved against one model's capabilities. */
|
|
56
|
-
export function profileFieldDescriptors(model) {
|
|
57
|
-
const
|
|
58
|
-
|
|
60
|
+
export function profileFieldDescriptors(model, profile) {
|
|
61
|
+
const projector = model?.components?.find((component) => component.role === "vision_projector");
|
|
62
|
+
const hasProjector = model?.components
|
|
63
|
+
? Boolean(projector?.available)
|
|
64
|
+
: Boolean(model?.mmprojPath);
|
|
65
|
+
const fields = [
|
|
66
|
+
{
|
|
67
|
+
key: "contextMultiplier",
|
|
68
|
+
label: "Context multiplier",
|
|
69
|
+
kind: "cycle",
|
|
70
|
+
options: CONTEXT_MULTIPLIERS,
|
|
71
|
+
optionLabels: ["Off", "2× (YaRN)", "4× (YaRN)"],
|
|
72
|
+
available: Boolean(model?.metadata?.contextLength),
|
|
73
|
+
...(model?.metadata?.contextLength ? {} : { unavailableReason: "native context unknown" }),
|
|
74
|
+
},
|
|
59
75
|
{
|
|
60
76
|
key: "contextSize",
|
|
61
77
|
label: "Context",
|
|
62
78
|
kind: "number",
|
|
63
79
|
step: CONTEXT_STEP,
|
|
64
80
|
min: MIN_CONTEXT_SIZE,
|
|
65
|
-
max:
|
|
81
|
+
max: contextLimit(model, profile?.contextMultiplier ?? 1),
|
|
66
82
|
available: true,
|
|
67
83
|
},
|
|
68
84
|
{
|
|
@@ -80,13 +96,6 @@ export function profileFieldDescriptors(model) {
|
|
|
80
96
|
available: true,
|
|
81
97
|
},
|
|
82
98
|
{ key: "flashAttention", label: "Flash attention", kind: "toggle", available: true },
|
|
83
|
-
{
|
|
84
|
-
key: "vision",
|
|
85
|
-
label: "Vision",
|
|
86
|
-
kind: "toggle",
|
|
87
|
-
available: hasProjector,
|
|
88
|
-
...(hasProjector ? {} : { unavailableReason: "no projector" }),
|
|
89
|
-
},
|
|
90
99
|
{
|
|
91
100
|
key: "reasoningBudget",
|
|
92
101
|
label: "Reasoning budget",
|
|
@@ -115,6 +124,23 @@ export function profileFieldDescriptors(model) {
|
|
|
115
124
|
available: true,
|
|
116
125
|
},
|
|
117
126
|
];
|
|
127
|
+
// Bundle models expose the projector in the component section below. Keep
|
|
128
|
+
// the legacy profile field for hand-scanned single-file models, but do not
|
|
129
|
+
// render two controls that write the same vision setting for bundles.
|
|
130
|
+
if (!model?.components) {
|
|
131
|
+
fields.splice(4, 0, {
|
|
132
|
+
key: "vision",
|
|
133
|
+
label: "Vision",
|
|
134
|
+
kind: "toggle",
|
|
135
|
+
available: hasProjector,
|
|
136
|
+
...(hasProjector
|
|
137
|
+
? {}
|
|
138
|
+
: {
|
|
139
|
+
unavailableReason: projector ? "download the vision component first" : "no projector",
|
|
140
|
+
}),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return fields;
|
|
118
144
|
}
|
|
119
145
|
/** Whether a KV cache type is quantised (anything that is not a float type). */
|
|
120
146
|
function isQuantised(cacheType) {
|
|
@@ -147,6 +173,17 @@ export function profileWarnings(profile, model, store) {
|
|
|
147
173
|
blocksStart: false,
|
|
148
174
|
});
|
|
149
175
|
}
|
|
176
|
+
if (profile.contextMultiplier > 1) {
|
|
177
|
+
warnings.push({
|
|
178
|
+
field: "contextMultiplier",
|
|
179
|
+
severity: "warn",
|
|
180
|
+
message: `YaRN ×${profile.contextMultiplier} extrapolates beyond the native context. Recalibrate before relying on this profile.`,
|
|
181
|
+
blocksStart: false,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
// Distinct from `calibrationRequired`, which says this profile has never been
|
|
185
|
+
// measured in its current shape. This says a measurement exists but was taken
|
|
186
|
+
// for other cache types, so it names the reason rather than just the verdict.
|
|
150
187
|
if (model && store && hasStaleCalibration(store, model, profile)) {
|
|
151
188
|
warnings.push({
|
|
152
189
|
field: "cacheTypeK",
|
|
@@ -163,6 +200,17 @@ export function profileWarnings(profile, model, store) {
|
|
|
163
200
|
* model's layer count, which the UI must never present as measured on this file.
|
|
164
201
|
*/
|
|
165
202
|
export function calibrationInfo(store, model, profile) {
|
|
203
|
+
// A historical measurement is invalid as soon as any VRAM-affecting setting
|
|
204
|
+
// changes. Keep the data for comparison, but do not present or use it as the
|
|
205
|
+
// current model budget until a calibration commits the new profile.
|
|
206
|
+
if (profile.calibrationRequired) {
|
|
207
|
+
return {
|
|
208
|
+
state: "theoretical",
|
|
209
|
+
kvBytesPerToken: null,
|
|
210
|
+
measuredAt: null,
|
|
211
|
+
measuredOn: null,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
166
214
|
const calibration = getCalibration(store, model, profile);
|
|
167
215
|
if (!calibration) {
|
|
168
216
|
return {
|
|
@@ -182,18 +230,37 @@ export function calibrationInfo(store, model, profile) {
|
|
|
182
230
|
function clamp(value, min, max) {
|
|
183
231
|
return Math.max(min, Math.min(max, value));
|
|
184
232
|
}
|
|
233
|
+
/** The settings whose value changes what a calibration would measure. */
|
|
234
|
+
const CALIBRATION_INPUTS = [
|
|
235
|
+
"contextMultiplier",
|
|
236
|
+
"contextSize",
|
|
237
|
+
"cacheTypeK",
|
|
238
|
+
"cacheTypeV",
|
|
239
|
+
"flashAttention",
|
|
240
|
+
"gpuLayers",
|
|
241
|
+
"parallelSlots",
|
|
242
|
+
"vision",
|
|
243
|
+
"enabledComponents",
|
|
244
|
+
];
|
|
245
|
+
/** Every calibration input is a scalar except the component id list, which is a set. */
|
|
246
|
+
function sameCalibrationInput(before, after) {
|
|
247
|
+
if (Array.isArray(before) && Array.isArray(after)) {
|
|
248
|
+
return [...before].sort().join("\0") === [...after].sort().join("\0");
|
|
249
|
+
}
|
|
250
|
+
return before === after;
|
|
251
|
+
}
|
|
185
252
|
/**
|
|
186
253
|
* Apply an editable patch to a profile, clamping every field to its range and
|
|
187
254
|
* dropping anything the model cannot use.
|
|
188
255
|
*
|
|
189
|
-
* Only the
|
|
256
|
+
* Only the supported editable keys are honoured. `modelPath`/`mmprojPath`/`modelId`
|
|
190
257
|
* are re-derived from the model on every read (`profiles.forModel`), so letting
|
|
191
258
|
* a caller set them would be a lie at best and a path-traversal at worst.
|
|
192
259
|
* `reasoningBudgetMessage`, `batchSize`, `ubatchSize` and `extraArgs` stay
|
|
193
260
|
* CLI-only: they have no measured effect worth exposing and `extraArgs` is
|
|
194
261
|
* arbitrary process arguments.
|
|
195
262
|
*/
|
|
196
|
-
export function sanitizeProfilePatch(current, patch, model) {
|
|
263
|
+
export function sanitizeProfilePatch(current, patch, model, runtimeBuild = null) {
|
|
197
264
|
const adjustments = [];
|
|
198
265
|
const next = { ...current };
|
|
199
266
|
if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
|
|
@@ -232,7 +299,14 @@ export function sanitizeProfilePatch(current, patch, model) {
|
|
|
232
299
|
}
|
|
233
300
|
next[key] = value;
|
|
234
301
|
};
|
|
235
|
-
|
|
302
|
+
if ("contextMultiplier" in p) {
|
|
303
|
+
const raw = p.contextMultiplier;
|
|
304
|
+
if (typeof raw !== "number" || !CONTEXT_MULTIPLIERS.includes(raw)) {
|
|
305
|
+
throw new Error("contextMultiplier must be one of 1, 2, or 4");
|
|
306
|
+
}
|
|
307
|
+
next.contextMultiplier = raw;
|
|
308
|
+
}
|
|
309
|
+
takeNumber("contextSize", MIN_CONTEXT_SIZE, contextLimit(model, next.contextMultiplier));
|
|
236
310
|
takeCacheType("cacheTypeK");
|
|
237
311
|
takeCacheType("cacheTypeV");
|
|
238
312
|
takeBoolean("flashAttention");
|
|
@@ -264,6 +338,52 @@ export function sanitizeProfilePatch(current, patch, model) {
|
|
|
264
338
|
next.vision = p.vision;
|
|
265
339
|
}
|
|
266
340
|
}
|
|
341
|
+
if ("enabledComponents" in p) {
|
|
342
|
+
if (!Array.isArray(p.enabledComponents) ||
|
|
343
|
+
!p.enabledComponents.every((id) => typeof id === "string")) {
|
|
344
|
+
throw new Error("enabledComponents must be an array of component ids");
|
|
345
|
+
}
|
|
346
|
+
const requested = [...new Set(p.enabledComponents)];
|
|
347
|
+
const requiredBuild = requested
|
|
348
|
+
.map((id) => model?.components?.find((component) => component.id === id))
|
|
349
|
+
.find((component) => component?.minRuntimeBuild !== undefined &&
|
|
350
|
+
(runtimeBuild === null || runtimeBuild < component.minRuntimeBuild))?.minRuntimeBuild;
|
|
351
|
+
if (requiredBuild !== undefined) {
|
|
352
|
+
const active = runtimeBuild === null ? "unknown" : `b${runtimeBuild}`;
|
|
353
|
+
throw new Error(`components require llama.cpp build b${requiredBuild} or newer (active build: ${active})`);
|
|
354
|
+
}
|
|
355
|
+
const available = new Set(model?.components
|
|
356
|
+
?.filter((component) => component.available)
|
|
357
|
+
.map((component) => component.id) ?? []);
|
|
358
|
+
const unavailable = requested.filter((id) => !available.has(id));
|
|
359
|
+
if (unavailable.length)
|
|
360
|
+
throw new Error(`components are not downloaded: ${unavailable.join(", ")}`);
|
|
361
|
+
next.enabledComponents = requested;
|
|
362
|
+
const projector = model?.components?.find((component) => component.role === "vision_projector" && requested.includes(component.id));
|
|
363
|
+
if (model?.components)
|
|
364
|
+
next.vision = Boolean(projector);
|
|
365
|
+
}
|
|
366
|
+
// A partial patch may lower the multiplier without naming contextSize. Clamp
|
|
367
|
+
// the saved value after every field has settled so launch args can never keep
|
|
368
|
+
// an extended context after YaRN has been turned off.
|
|
369
|
+
const contextSize = clamp(next.contextSize, MIN_CONTEXT_SIZE, contextLimit(model, next.contextMultiplier));
|
|
370
|
+
if (contextSize !== next.contextSize) {
|
|
371
|
+
next.contextSize = contextSize;
|
|
372
|
+
adjustments.push(`contextSize clamped to ${contextSize}`);
|
|
373
|
+
}
|
|
374
|
+
// The saved profile carries the calibration verdict. Do not infer it from
|
|
375
|
+
// whether an old measurement happens to exist: a person who changes settings,
|
|
376
|
+
// leaves, and returns must still be told to calibrate this new configuration.
|
|
377
|
+
//
|
|
378
|
+
// Compare values, never key presence. The editor autosaves the whole draft on
|
|
379
|
+
// every edit, so every one of these keys is in `p` every time; keying off
|
|
380
|
+
// presence threw away a real measurement whenever any unrelated field - or a
|
|
381
|
+
// hosting-profile choice, which does not touch VRAM at all - was saved.
|
|
382
|
+
const before = current;
|
|
383
|
+
const after = next;
|
|
384
|
+
if (CALIBRATION_INPUTS.some((key) => !sameCalibrationInput(before[key], after[key]))) {
|
|
385
|
+
next.calibrationRequired = true;
|
|
386
|
+
}
|
|
267
387
|
return { profile: next, adjustments };
|
|
268
388
|
}
|
|
269
389
|
//# sourceMappingURL=profile-edit.js.map
|
package/dist/config/profiles.js
CHANGED
|
@@ -2,6 +2,14 @@ import { DEFAULT_REASONING_MESSAGE, } from "./schema.js";
|
|
|
2
2
|
export function defaultProfile(model, defaults) {
|
|
3
3
|
const nativeContext = model?.metadata?.contextLength || 32768;
|
|
4
4
|
const contextCap = defaults?.contextCap ?? 225000;
|
|
5
|
+
const enabledComponents = model?.components
|
|
6
|
+
? model.components
|
|
7
|
+
.filter((component) => component.available && component.defaultLoad)
|
|
8
|
+
.map((component) => component.id)
|
|
9
|
+
: [];
|
|
10
|
+
const componentPaths = Object.fromEntries((model?.components ?? [])
|
|
11
|
+
.filter((component) => enabledComponents.includes(component.id) && component.path)
|
|
12
|
+
.map((component) => [component.role, component.path]));
|
|
5
13
|
return {
|
|
6
14
|
modelId: model?.id ?? null,
|
|
7
15
|
modelPath: model?.modelPath ?? null,
|
|
@@ -9,17 +17,32 @@ export function defaultProfile(model, defaults) {
|
|
|
9
17
|
// Long context is the point of this hardware; start at the native limit and
|
|
10
18
|
// let the VRAM budget pull it down.
|
|
11
19
|
contextSize: Math.min(nativeContext, contextCap),
|
|
20
|
+
contextMultiplier: 1,
|
|
21
|
+
calibrationRequired: true,
|
|
12
22
|
cacheTypeK: defaults?.cacheTypeK ?? "q8_0",
|
|
13
23
|
cacheTypeV: defaults?.cacheTypeV ?? "q8_0",
|
|
14
24
|
flashAttention: defaults?.flashAttention ?? true, // required for a quantised V cache
|
|
15
25
|
gpuLayers: 999, // everything on GPU; the budget check guards this
|
|
16
|
-
vision
|
|
26
|
+
// Existing hand-scanned models keep the historical vision default. Bundles
|
|
27
|
+
// instead opt in only to installed components whose manifest says so.
|
|
28
|
+
enabledComponents,
|
|
29
|
+
componentPaths,
|
|
30
|
+
vision: model?.components
|
|
31
|
+
? model.components.some((component) => component.role === "vision_projector" && component.available && component.defaultLoad)
|
|
32
|
+
: Boolean(model?.mmprojPath),
|
|
17
33
|
reasoningBudget: defaults?.reasoningBudget ?? 1536,
|
|
18
34
|
reasoningBudgetMessage: DEFAULT_REASONING_MESSAGE,
|
|
19
35
|
parallelSlots: defaults?.parallelSlots ?? 1, // one agent at a time: max context per request
|
|
20
36
|
batchSize: null,
|
|
21
37
|
ubatchSize: null,
|
|
22
38
|
extraArgs: [],
|
|
39
|
+
hostingProfileId: null,
|
|
40
|
+
// Inherit, not off: a new model in a family that has a default should use
|
|
41
|
+
// it. With no family default configured this resolves to nothing anyway.
|
|
42
|
+
hostingProfileMode: "inherit",
|
|
43
|
+
chatTemplateFile: null,
|
|
44
|
+
chatTemplateKwargs: {},
|
|
45
|
+
chatSystemAddendum: null,
|
|
23
46
|
};
|
|
24
47
|
}
|
|
25
48
|
/** Stored profile for a model, falling back to computed defaults. */
|
|
@@ -29,7 +52,36 @@ export function forModel(store, model, defaults) {
|
|
|
29
52
|
if (!stored)
|
|
30
53
|
return base;
|
|
31
54
|
// Paths are re-derived so a moved model library does not break the profile.
|
|
32
|
-
|
|
55
|
+
const enabledComponents = (stored.enabledComponents ?? []).filter((id) => model.components?.some((component) => component.id === id && component.available));
|
|
56
|
+
// COMPAT(bundleProfiles): added in v0.8.7, remove after 2027-02-11.
|
|
57
|
+
// Old vision profiles become the manifest's vision component when it exists.
|
|
58
|
+
if (model.components && enabledComponents.length === 0 && stored.vision) {
|
|
59
|
+
enabledComponents.push(...model.components
|
|
60
|
+
.filter((component) => component.role === "vision_projector" && component.available)
|
|
61
|
+
.map((component) => component.id));
|
|
62
|
+
}
|
|
63
|
+
const mmproj = model.components?.find((component) => component.role === "vision_projector" && enabledComponents.includes(component.id));
|
|
64
|
+
const componentPaths = Object.fromEntries((model.components ?? [])
|
|
65
|
+
.filter((component) => enabledComponents.includes(component.id) && component.path)
|
|
66
|
+
.map((component) => [component.role, component.path]));
|
|
67
|
+
return {
|
|
68
|
+
...base,
|
|
69
|
+
...stored,
|
|
70
|
+
// COMPAT(hostingProfileMode): added in v0.8.8, remove after 2027-02-12.
|
|
71
|
+
// The first hosting-profile store only had an id, and a non-null id there
|
|
72
|
+
// was an explicit custom choice. Such a profile parses as `inherit` (the
|
|
73
|
+
// schema default), so promote it. Writers hold the inverse invariant - the
|
|
74
|
+
// id is nulled whenever the mode is not `custom` - so a stored id can only
|
|
75
|
+
// mean a legacy record, never a stale leftover of a newer explicit choice.
|
|
76
|
+
hostingProfileMode: stored.hostingProfileId && stored.hostingProfileMode === "inherit"
|
|
77
|
+
? "custom"
|
|
78
|
+
: stored.hostingProfileMode,
|
|
79
|
+
enabledComponents,
|
|
80
|
+
modelPath: model.modelPath,
|
|
81
|
+
mmprojPath: mmproj?.path ?? (model.components ? null : model.mmprojPath),
|
|
82
|
+
componentPaths,
|
|
83
|
+
vision: model.components ? Boolean(mmproj) : stored.vision,
|
|
84
|
+
};
|
|
33
85
|
}
|
|
34
86
|
export function put(store, model, profile) {
|
|
35
87
|
store.profiles[model.id] = { ...profile, modelId: model.id };
|
|
@@ -37,7 +89,14 @@ export function put(store, model, profile) {
|
|
|
37
89
|
}
|
|
38
90
|
/** Calibration is keyed by cache types, since those change bytes/token. */
|
|
39
91
|
export function calibrationKey(profile) {
|
|
40
|
-
|
|
92
|
+
const components = [...(profile.enabledComponents ?? [])].sort();
|
|
93
|
+
const multiplier = profile.contextMultiplier > 1 ? `:contextMultiplier=${profile.contextMultiplier}` : "";
|
|
94
|
+
// COMPAT(bundleCalibrationKey): added in v0.8.7, remove after 2027-02-11.
|
|
95
|
+
// A main-model-only load remains the historical identity; any enabled bundle
|
|
96
|
+
// artifact gets a distinct key and therefore cannot claim that measurement.
|
|
97
|
+
return components.length
|
|
98
|
+
? `${profile.cacheTypeK}:${profile.cacheTypeV}${multiplier}:components=${components.join(",")}`
|
|
99
|
+
: `${profile.cacheTypeK}:${profile.cacheTypeV}${multiplier}`;
|
|
41
100
|
}
|
|
42
101
|
/**
|
|
43
102
|
* True when this model has a stored calibration, but for different cache types
|
|
@@ -127,6 +186,10 @@ export function putCalibration(store, model, profile, measurement) {
|
|
|
127
186
|
writable: true,
|
|
128
187
|
});
|
|
129
188
|
}
|
|
189
|
+
// Calibration is a durable verdict about the persisted model profile, not a
|
|
190
|
+
// transient UI hint. Every completion path (CLI, TUI, and host job) shares
|
|
191
|
+
// this helper, so clearing it here keeps the state consistent everywhere.
|
|
192
|
+
put(store, model, { ...profile, calibrationRequired: false });
|
|
130
193
|
return store;
|
|
131
194
|
}
|
|
132
195
|
//# sourceMappingURL=profiles.js.map
|