llm-tuner 0.1.0

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.
@@ -0,0 +1,137 @@
1
+ import { KV_TYPES, placeLayers } from './estimator.js';
2
+
3
+ // Load profiles: what the tuner optimizes for. Lossless choices (layer placement, GPU order,
4
+ // threads) apply to every profile; a profile only governs the lossy ones (weight quantization
5
+ // and KV cache type).
6
+ export const PROFILES = Object.freeze(['speed', 'balanced', 'quality']);
7
+ export const DEFAULT_PROFILE = 'balanced';
8
+
9
+ /** Returns the profile or the default if invalid. */
10
+ export function normalizeProfile(p) {
11
+ return PROFILES.includes(p) ? p : DEFAULT_PROFILE;
12
+ }
13
+
14
+ // KV cache types precise enough for the quality profile.
15
+ export const QUALITY_KV_TYPES = Object.freeze(['f16', 'q8_0']);
16
+
17
+ /** Returns allowed KV types for a profile. */
18
+ export function kvTypesFor(profile, engineKvTypes) {
19
+ if (profile === 'quality') {
20
+ const allowed = engineKvTypes.filter((k) => QUALITY_KV_TYPES.includes(k));
21
+ if (allowed.length > 0) return allowed;
22
+ }
23
+ return engineKvTypes;
24
+ }
25
+
26
+ /** Checks if a candidate meets the quality profile criteria. */
27
+ export function meetsQuality(candidate) {
28
+ return candidate.fullOffload === true && !candidate.cpuMoeLayers && QUALITY_KV_TYPES.includes(candidate.kvType);
29
+ }
30
+
31
+ const tps = (r) => r.bench.deep?.genTps ?? r.bench.short.genTps;
32
+ const kvQuality = (r) => KV_TYPES[r.candidate.kvType].quality;
33
+ // Best element by a comparator, without touching the input (ties keep input order).
34
+ const top = (list, compare) => [...list].sort(compare)[0] || null;
35
+
36
+ /** Pick the winner of a measurement with the rule of the profile. */
37
+ export function pickBest(results, profile = DEFAULT_PROFILE) {
38
+ const ok = results.filter((r) => r.bench?.ok);
39
+ const balanced = (a, b) => tps(b) * kvQuality(b) - tps(a) * kvQuality(a);
40
+ if (profile === 'speed') return top(ok, (a, b) => tps(b) - tps(a));
41
+ if (profile === 'quality') {
42
+ const precise = ok.filter((r) => meetsQuality(r.candidate));
43
+ if (precise.length > 0) return top(precise, (a, b) => kvQuality(b) - kvQuality(a) || tps(b) - tps(a));
44
+ }
45
+ return top(ok, balanced);
46
+ }
47
+
48
+ /** True when the model fits fully on GPU with at least one of the KV types. */
49
+ export function fitsFullyOnGpu(meta, hw, { ctx, kvTypes }) {
50
+ return kvTypes.some((kvType) => placeLayers(meta, hw.gpus, { ctx, kvType }).fullOffload);
51
+ }
52
+
53
+ const selectedVariant = (variants) => variants.find((v) => v.selected) || variants[0] || null;
54
+
55
+ // Lightest first. Bits per weight orders variants; file size is the fallback when any lacks it.
56
+ const byWeight = (variants) => {
57
+ const fallback = variants.some((v) => v.meta.bitsPerWeight == null);
58
+ return [...variants].sort((a, b) => fallback ? a.meta.fileBytes - b.meta.fileBytes : a.meta.bitsPerWeight - b.meta.bitsPerWeight);
59
+ };
60
+
61
+ /** Pick the model variant a profile loads (or recommends), with the reason as a code. */
62
+ export function pickVariant(profile, variants, hw, { ctx, kvTypes }) {
63
+ if (!variants.length) return { variant: null, reasonCode: 'profile.variant.none', fallback: false };
64
+ if (profile === 'speed') return { variant: byWeight(variants)[0], reasonCode: 'profile.variant.lightest', fallback: false };
65
+ if (profile === 'quality') {
66
+ const allowed = kvTypesFor('quality', kvTypes);
67
+ const heaviestFit = byWeight(variants).reverse().find((v) => fitsFullyOnGpu(v.meta, hw, { ctx, kvTypes: allowed }));
68
+ return heaviestFit
69
+ ? { variant: heaviestFit, reasonCode: 'profile.variant.heaviestFit', fallback: false }
70
+ : { variant: selectedVariant(variants), reasonCode: 'profile.variant.noFullGpu', fallback: true };
71
+ }
72
+ return { variant: selectedVariant(variants), reasonCode: 'profile.variant.selected', fallback: false };
73
+ }
74
+
75
+ /** Recommend the highest-quality profile whose outcome stays fully on GPU. */
76
+ export function recommendProfile(variants, hw, { ctx, kvTypes }) {
77
+ const quality = pickVariant('quality', variants, hw, { ctx, kvTypes });
78
+ if (quality.variant && !quality.fallback) return { profile: 'quality', reasonCode: 'profile.recommend.qualityFits' };
79
+ const selected = selectedVariant(variants);
80
+ if (selected && fitsFullyOnGpu(selected.meta, hw, { ctx, kvTypes })) {
81
+ return { profile: 'balanced', reasonCode: 'profile.recommend.balancedFits' };
82
+ }
83
+ return { profile: 'speed', reasonCode: 'profile.recommend.partialOffload' };
84
+ }
85
+
86
+ // A GPU counts as busy above this fraction of its memory in use; a desktop compositor alone stays below it.
87
+ export const VRAM_BUSY_FRACTION = 0.2;
88
+
89
+ /** True when at least one GPU has more than `fraction` of its memory in use. */
90
+ export function vramInUse(hw, fraction = VRAM_BUSY_FRACTION) {
91
+ if (!hw || !Array.isArray(hw.gpus) || hw.gpus.length === 0) return false;
92
+ return hw.gpus.some((gpu) => {
93
+ if (!gpu || !Number.isFinite(gpu.usedBytes) || !Number.isFinite(gpu.totalBytes)) return false;
94
+ return gpu.usedBytes > gpu.totalBytes * fraction;
95
+ });
96
+ }
97
+
98
+ // Heavier quantizations worth hinting at, with their bits per weight.
99
+ // Source: llama.cpp tools/quantize/README.md, Llama-3.1-8B table.
100
+ export const HINT_TARGETS = Object.freeze([
101
+ { quant: 'Q8_0', bitsPerWeight: 8.5008 },
102
+ { quant: 'Q6_K', bitsPerWeight: 6.5633 },
103
+ { quant: 'Q5_K_M', bitsPerWeight: 5.7036 },
104
+ ]);
105
+
106
+ // A hint must be at least 5% heavier than the file on disk, because measured bits per weight
107
+ // are rounded and the targets come from another model.
108
+ export const HINT_MIN_GAIN = 1.05;
109
+
110
+ /** Heavier quantizations, not downloaded, that would still fit fully on GPU (sizes estimated). */
111
+ export function variantHints(meta, hw, { ctx, kvTypes, downloadedQuants = [] }) {
112
+ if (!meta.bitsPerWeight) return [];
113
+ const allowed = kvTypesFor('quality', kvTypes);
114
+ const downloaded = downloadedQuants.map((q) => String(q).toLowerCase());
115
+ const results = [];
116
+ for (const target of HINT_TARGETS) {
117
+ if (target.bitsPerWeight < meta.bitsPerWeight * HINT_MIN_GAIN) continue;
118
+ if (downloaded.includes(target.quant.toLowerCase())) continue;
119
+ const scale = target.bitsPerWeight / meta.bitsPerWeight;
120
+ const scaled = {
121
+ ...meta,
122
+ layerBytes: meta.layerBytes.map((b) => b * scale),
123
+ layerExpertBytes: meta.layerExpertBytes.map((b) => b * scale),
124
+ outputBytes: meta.outputBytes * scale,
125
+ embdBytes: meta.embdBytes * scale,
126
+ fileBytes: meta.fileBytes * scale,
127
+ };
128
+ if (fitsFullyOnGpu(scaled, hw, { ctx, kvTypes: allowed })) {
129
+ results.push({
130
+ quant: target.quant,
131
+ bitsPerWeight: target.bitsPerWeight,
132
+ estimatedBytes: Math.round(scaled.fileBytes),
133
+ });
134
+ }
135
+ }
136
+ return results;
137
+ }
@@ -0,0 +1,44 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { configDir, readJson } from './util.js';
4
+ import { normalizeLocale } from '../i18n/core.js';
5
+ import { PROFILES } from './profiles.js';
6
+
7
+ // User preferences shared by the CLI, the web UI and Electron.
8
+
9
+ export const THEMES = Object.freeze(['system', 'light', 'dark']);
10
+ export const DEFAULT_THEME = 'system';
11
+
12
+ /** Returns the value if it is a valid profile, otherwise null. */
13
+ function storedProfile(value) {
14
+ return PROFILES.includes(value) ? value : null;
15
+ }
16
+
17
+ export function normalizeTheme(theme) {
18
+ return THEMES.includes(theme) ? theme : DEFAULT_THEME;
19
+ }
20
+
21
+ export const settingsPath = () => path.join(configDir(), 'settings.json');
22
+
23
+ /** Current settings. A missing, corrupt or hand-edited file yields the defaults; never throws. */
24
+ export async function readSettings() {
25
+ const s = await readJson(settingsPath(), null);
26
+ return {
27
+ lang: normalizeLocale(s?.lang),
28
+ theme: normalizeTheme(s?.theme),
29
+ profile: storedProfile(s?.profile),
30
+ };
31
+ }
32
+
33
+ /** Merge `patch` into the saved settings and write them. */
34
+ export async function writeSettings(patch) {
35
+ const current = await readJson(settingsPath(), null);
36
+ const base = current && typeof current === 'object' && !Array.isArray(current) ? current : {};
37
+ const lang = normalizeLocale(patch?.lang !== undefined ? patch.lang : base.lang);
38
+ const theme = normalizeTheme(patch?.theme !== undefined ? patch.theme : base.theme);
39
+ const profile = storedProfile(patch?.profile) ?? base.profile;
40
+ const next = { ...base, ...patch, lang, theme, profile };
41
+ await fs.mkdir(configDir(), { recursive: true });
42
+ await fs.writeFile(settingsPath(), JSON.stringify(next, null, 2));
43
+ return { lang: next.lang, theme: next.theme, profile: storedProfile(next.profile) };
44
+ }
@@ -0,0 +1,305 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { detectHardware } from './hardware.js';
3
+ import { planCandidates, maxFullOffloadContext } from './estimator.js';
4
+ import { findPreset, savePreset, presetPath } from './presets.js';
5
+ import { PROFILES, DEFAULT_PROFILE, normalizeProfile, kvTypesFor, meetsQuality, pickBest, pickVariant, recommendProfile, variantHints, vramInUse } from './profiles.js';
6
+ import { lmstudio } from './engines/lmstudio.js';
7
+ import { ollama } from './engines/ollama.js';
8
+ import { TunerError, benchError } from './errors.js';
9
+
10
+ export { pickBest };
11
+ export const ENGINES = { lmstudio, ollama };
12
+
13
+ /** Detect both engines and recommend one. */
14
+ export async function detectEngines(hw) {
15
+ const out = [];
16
+ for (const e of Object.values(ENGINES)) {
17
+ let detection;
18
+ try { detection = await e.detect(); } catch (err) { detection = { installed: false, error: String(err.message || err) }; }
19
+ out.push({ id: e.id, name: e.name, detection });
20
+ }
21
+ const installed = out.filter((e) => e.detection.installed);
22
+ let recommended = null;
23
+ let reasonCode;
24
+ let reasonParams = {};
25
+ if (installed.length === 1) {
26
+ recommended = installed[0].id;
27
+ reasonCode = 'engine.reason.onlyInstalled';
28
+ reasonParams = { name: installed[0].name };
29
+ } else if (installed.length === 2) {
30
+ // LM Studio lets us fix KV type and GPU order per model and benchmarks the exact
31
+ // binary it will run; Ollama makes those server-wide. Prefer LM Studio for multi-GPU.
32
+ const multiGpu = (hw?.gpus?.length || 0) > 1;
33
+ recommended = multiGpu ? 'lmstudio' : 'ollama';
34
+ reasonCode = multiGpu ? 'engine.reason.multiGpu' : 'engine.reason.singleGpu';
35
+ } else {
36
+ recommended = (hw?.gpus?.length || 0) > 1 ? 'lmstudio' : 'ollama';
37
+ reasonCode = 'engine.reason.noneInstalled';
38
+ }
39
+ return { engines: out, recommended, reasonCode, reasonParams };
40
+ }
41
+
42
+ export class Tuner extends EventEmitter {
43
+ constructor(engineId, detection, hw) {
44
+ super();
45
+ this.engine = ENGINES[engineId];
46
+ if (!this.engine) throw new TunerError('errors.unknownEngine', { engine: engineId });
47
+ this.ctx = { detection, hw };
48
+ this.hw = hw;
49
+ }
50
+
51
+ static async create(engineId) {
52
+ if (!ENGINES[engineId]) throw new TunerError('errors.unknownEngine', { engine: engineId });
53
+ const hw = await detectHardware();
54
+ const detection = await ENGINES[engineId].detect();
55
+ return new Tuner(engineId, detection, hw);
56
+ }
57
+
58
+ log(type, data = {}) { this.emit('progress', { type, ...data, at: Date.now() }); }
59
+
60
+ listModels() { return this.engine.listModels(this.ctx); }
61
+
62
+ async modelInfo(key) {
63
+ const meta = await this.engine.modelMeta(this.ctx, key);
64
+ return { key, meta };
65
+ }
66
+
67
+ chat(model, messages) {
68
+ return this.engine.chat(this.ctx, model, messages);
69
+ }
70
+
71
+ /** Downloaded variants of a model with their metadata; never empty: at least the chosen key itself. */
72
+ async variants(key) {
73
+ const listed = (await this.engine.listVariants?.(this.ctx, key)) ?? [];
74
+ const out = [];
75
+ for (const item of listed) {
76
+ try {
77
+ const meta = await this.engine.modelMeta(this.ctx, item.key);
78
+ out.push({ key: item.key, quant: item.quant, sizeBytes: item.sizeBytes, selected: item.selected, meta });
79
+ } catch {
80
+ // A variant whose file cannot be read is not a candidate.
81
+ }
82
+ }
83
+ if (out.length > 0) {
84
+ return out;
85
+ }
86
+ const { meta } = await this.modelInfo(key);
87
+ return [{ key, quant: null, sizeBytes: meta.fileBytes, selected: true, meta }];
88
+ }
89
+
90
+ /** What each profile would load for a model and context, without measuring or unloading anything. */
91
+ async profilePlan(key, ctx) {
92
+ const variants = await this.variants(key);
93
+ const { kvTypes } = this.engine.capabilities;
94
+ const variantSelect = !!this.engine.capabilities.variantSelect;
95
+ const selected = variants.find((v) => v.selected) ?? variants[0];
96
+ const opts = { ctx, kvTypes };
97
+ const profiles = {};
98
+ // An engine that cannot select variants loads the selected one and only recommends the pick.
99
+ for (const profile of PROFILES) {
100
+ const pick = pickVariant(profile, variants, this.hw, opts);
101
+ profiles[profile] = {
102
+ variant: pick.variant.key,
103
+ quant: pick.variant.quant,
104
+ reasonCode: pick.reasonCode,
105
+ fallback: pick.fallback,
106
+ kvTypes: kvTypesFor(profile, kvTypes),
107
+ loads: variantSelect ? pick.variant.key : selected.key,
108
+ recommendSwitch: !variantSelect && pick.variant.key !== selected.key,
109
+ };
110
+ }
111
+ return {
112
+ variants: variants.map((v) => ({ key: v.key, quant: v.quant, sizeBytes: v.sizeBytes, selected: v.selected, bitsPerWeight: v.meta.bitsPerWeight ?? null })),
113
+ variantSelect,
114
+ recommended: recommendProfile(variants, this.hw, opts),
115
+ profiles,
116
+ hints: variantHints(selected.meta, this.hw, { ...opts, downloadedQuants: variants.map((v) => v.quant).filter(Boolean) }),
117
+ // The plan uses the VRAM that is free now; a real load unloads the engine's models first.
118
+ vramBusy: vramInUse(this.hw),
119
+ };
120
+ }
121
+
122
+ /** Pick the variant a non-balanced profile measures and loads with the hardware as it is now, and announce it. */
123
+ profileTarget(key, ctx, profile, model, variants) {
124
+ const { kvTypes } = this.engine.capabilities;
125
+ const canSelect = !!this.engine.capabilities.variantSelect;
126
+ const pick = pickVariant(profile, variants, this.hw, { ctx, kvTypes });
127
+ const selected = variants.find((v) => v.selected);
128
+
129
+ // An engine that cannot select variants always loads the one selected in the engine, under the chosen key.
130
+ const targetKey = canSelect ? pick.variant.key : key;
131
+ const loaded = canSelect ? pick.variant : (selected ?? { quant: null, sizeBytes: model.meta.fileBytes });
132
+ // The announced variant travels in every profile event, so an interface never has to remember an earlier one.
133
+ const picked = { key: pick.variant.key, quant: pick.variant.quant, sizeBytes: pick.variant.sizeBytes };
134
+
135
+ this.log('variant-picked', {
136
+ profile,
137
+ variant: picked,
138
+ reasonCode: pick.reasonCode,
139
+ loads: targetKey,
140
+ recommendSwitch: !canSelect && pick.variant.key !== (selected?.key ?? key),
141
+ });
142
+
143
+ if (pick.fallback) {
144
+ this.log('profile-fallback', { profile, reasonCode: pick.reasonCode, variant: picked });
145
+ }
146
+
147
+ return {
148
+ key: targetKey,
149
+ fallback: pick.fallback,
150
+ variant: { key: targetKey, quant: loaded.quant, sizeBytes: loaded.sizeBytes },
151
+ picked,
152
+ };
153
+ }
154
+
155
+ /** Announce the variant a profile preset stored: on a preset hit nothing is picked. */
156
+ presetTarget(key, profile, model, preset) {
157
+ const canSelect = !!this.engine.capabilities.variantSelect;
158
+ const stored = { key, quant: null, sizeBytes: model.meta.fileBytes, ...preset.variant };
159
+ // An engine that cannot select variants always loads under the chosen key.
160
+ const targetKey = canSelect ? stored.key : key;
161
+ const variant = { key: targetKey, quant: stored.quant, sizeBytes: stored.sizeBytes };
162
+
163
+ this.log('variant-picked', {
164
+ profile,
165
+ variant,
166
+ reasonCode: 'profile.variant.preset',
167
+ loads: targetKey,
168
+ recommendSwitch: false,
169
+ });
170
+
171
+ return {
172
+ key: targetKey,
173
+ fallback: false,
174
+ variant,
175
+ };
176
+ }
177
+
178
+ /** Unload the engine's loaded models and re-detect the hardware, so the free-VRAM snapshot is realistic. */
179
+ async freeVram() {
180
+ this.log('status', { code: 'status.freeingVram' });
181
+ await this.engine.prepare(this.ctx);
182
+ this.hw = this.ctx.hw = await detectHardware();
183
+ }
184
+
185
+ /**
186
+ * Estimate candidates for a context length. With `unload`, the engine's loaded
187
+ * models are unloaded first so the free-VRAM snapshot is realistic.
188
+ * The profile restricts the KV cache types.
189
+ */
190
+ async plan(key, ctx, { unload = false, profile = DEFAULT_PROFILE } = {}) {
191
+ if (unload) await this.freeVram();
192
+ const model = await this.modelInfo(key);
193
+ const engineKvTypes = this.engine.capabilities.kvTypes;
194
+ // The profile restricts the KV cache types that are planned; the context limits cover every engine type.
195
+ const kvTypes = kvTypesFor(normalizeProfile(profile), engineKvTypes);
196
+ const candidates = planCandidates(model.meta, this.hw, { ctx, kvTypes, allowCpuMoe: this.engine.capabilities.cpuMoe });
197
+ const maxContext = Object.fromEntries(engineKvTypes.map((k) => [k, maxFullOffloadContext(model.meta, this.hw, k)]));
198
+ return { model, candidates, maxContext, hw: this.hw };
199
+ }
200
+
201
+ /** Estimate, benchmark the top candidates for real, and return a report. The profile decides the winner. */
202
+ async run(key, ctx, { maxCandidates = 3, depthFraction = 0.5, genTokens = 200, profile: requested = DEFAULT_PROFILE } = {}) {
203
+ const profile = normalizeProfile(requested);
204
+ this.log('status', { code: 'status.engine', params: { name: this.engine.name } });
205
+ const { model, candidates, maxContext } = await this.plan(key, ctx, { unload: true, profile });
206
+ const model2 = { key, meta: model.meta };
207
+ const toTest = candidates.slice(0, maxCandidates);
208
+ this.log('plan', { candidates: toTest.map(slim), maxContext });
209
+
210
+ const depthTokens = Math.floor(ctx * depthFraction);
211
+ const results = [];
212
+ for (const [i, c] of toTest.entries()) {
213
+ this.log('candidate-start', { index: i, total: toTest.length, candidate: slim(c) });
214
+ let bench;
215
+ try {
216
+ bench = await this.engine.benchmark(this.ctx, model2, c, {
217
+ depthTokens, genTokens,
218
+ onProgress: (p) => this.log('bench-progress', { index: i, ...p }),
219
+ });
220
+ } catch (err) {
221
+ bench = err?.name === 'TunerError' ? benchError(err.code, err.params) : { ok: false, error: String(err.message || err) };
222
+ }
223
+ results.push({ candidate: c, bench });
224
+ this.log('candidate-done', { index: i, candidate: slim(c), bench });
225
+ }
226
+ const best = pickBest(results, profile);
227
+ const report = { engine: this.engine.id, model: key, ctx, profile, hw: this.hw, maxContext, results: results.map((r) => ({ candidate: slim(r.candidate), bench: r.bench })), best: best ? { candidate: best.candidate, bench: best.bench } : null };
228
+ this.log('done', { best: report.best && { candidate: slim(best.candidate), bench: best.bench } });
229
+ this.lastReport = { ...report, modelObj: model2 };
230
+ return report;
231
+ }
232
+
233
+ /**
234
+ * Main flow behind the "Load" button:
235
+ * preset for (model, ctx, hardware, profile)? -> apply + load.
236
+ * Otherwise benchmark -> save preset -> apply + load.
237
+ * Balanced measures and loads the key the user chose; speed and quality reuse the variant stored in
238
+ * the preset, or pick one once the VRAM is free.
239
+ */
240
+ async load(key, ctx, { force = false, dryRun = false, maxCandidates = 3, depthFraction = 0.5, measure = true, confirmApply = async () => true, profile: requested = DEFAULT_PROFILE } = {}) {
241
+ const profile = normalizeProfile(requested);
242
+ const model = await this.modelInfo(key);
243
+ const modelBytes = model.meta.fileBytes;
244
+ // Listing the variants of a non-balanced profile needs no hardware; picking one does, so the pick waits.
245
+ const variants = profile === DEFAULT_PROFILE ? null : await this.variants(key);
246
+ const listed = variants?.map((v) => ({ key: v.key, sizeBytes: v.sizeBytes, selected: v.selected }));
247
+ // A profile preset is keyed by the key the user chose plus the profile, and goes stale with the variant list or its selected entry.
248
+ const keyed = variants ? { profile, variants: listed } : {};
249
+ let target = null;
250
+ let source = 'preset';
251
+ let candidate;
252
+ let report = null;
253
+ let found = force ? { preset: null, reason: 'forced' } : await findPreset({ engine: this.engine.id, modelKey: key, ctx, hw: this.hw, modelBytes, ...keyed });
254
+
255
+ if (found.preset) {
256
+ candidate = found.preset.candidate;
257
+ this.log('preset-hit', { preset: found.preset, file: presetPath(this.engine.id, key, ctx, profile) });
258
+ // The preset remembers which variant it measured: nothing is picked and no VRAM is freed.
259
+ if (variants) target = this.presetTarget(key, profile, model, found.preset);
260
+ } else {
261
+ source = 'benchmark';
262
+ const code = { none: 'status.searchNone', hardware: 'status.searchHardware', model: 'status.searchModel', forced: 'status.searchForced', variants: 'status.searchVariants' }[found.reason];
263
+ this.log('status', { code });
264
+ if (variants) {
265
+ // The pick depends on the free VRAM, so the engine's models are unloaded before it.
266
+ await this.freeVram();
267
+ target = this.profileTarget(key, ctx, profile, model, variants);
268
+ }
269
+ report = await this.run(target?.key ?? key, ctx, { maxCandidates, depthFraction, profile });
270
+ if (!report.best) throw new TunerError('errors.noConfigWorked');
271
+ candidate = report.best.candidate;
272
+ // Quality found no configuration that keeps its promise: say so once.
273
+ if (profile === 'quality' && !target.fallback && !meetsQuality(candidate)) this.log('profile-fallback', { profile, reasonCode: 'profile.fallback.noFullGpuConfig', variant: target.picked });
274
+ if (!dryRun) {
275
+ const saved = await savePreset({ engine: this.engine.id, modelKey: key, ctx, hw: this.hw, modelBytes, best: report.best, results: this.lastReport.results, ...(target ? { profile, variant: target.variant, variants: listed } : {}) });
276
+ this.log('preset-saved', { file: saved.file, preset: saved.preset });
277
+ }
278
+ }
279
+
280
+ // The variant a profile picked is applied and loaded with its own metadata.
281
+ const loadKey = target?.key ?? key;
282
+ const meta = loadKey === key ? model.meta : variants?.find((v) => v.key === loadKey)?.meta ?? (await this.modelInfo(loadKey)).meta;
283
+ const modelObj = { key: loadKey, meta };
284
+ const preview = await this.engine.apply(this.ctx, modelObj, candidate, { dryRun: true });
285
+ if (dryRun) return { source, candidate: slim(candidate), report, preview, profile, variant: loadKey };
286
+ if (!(await confirmApply(preview))) return { source, candidate: slim(candidate), report, preview, applied: false, profile, variant: loadKey };
287
+
288
+ this.log('status', { code: 'status.applying' });
289
+ const applied = await this.engine.apply(this.ctx, modelObj, candidate, { useSudo: true, skipIfApplied: source === 'preset' });
290
+ this.log('applied', { result: applied });
291
+ if (applied.pendingCommands?.length) return { source, candidate: slim(candidate), report, applied, loaded: null, profile, variant: loadKey };
292
+
293
+ this.log('status', { code: 'status.loading' });
294
+ this.ctx.detection = await this.engine.detect();
295
+ const loaded = await this.engine.load(this.ctx, modelObj, { candidate, depthTokens: 0, genTokens: measure ? 200 : 1, onProgress: (p) => this.log('bench-progress', p) });
296
+ this.log('loaded', { bench: loaded });
297
+ return { source, candidate: slim(candidate), report, applied, loaded, profile, variant: loadKey };
298
+ }
299
+ }
300
+
301
+ /** Candidate without the bulky placement internals, for UIs and logs. */
302
+ export function slim(c) {
303
+ const { placement, score, ...rest } = c;
304
+ return { ...rest, kvBytes: placement?.kvBytes, layersPerGpu: placement?.layersPerGpu };
305
+ }
@@ -0,0 +1,148 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import os from 'node:os';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ export const PLATFORM = process.platform; // 'linux' | 'win32' | 'darwin'
7
+ export const MiB = 1024 * 1024;
8
+ export const GiB = 1024 * MiB;
9
+
10
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
11
+
12
+ /** Run a command, resolve {code, stdout, stderr}; never rejects. */
13
+ export function run(cmd, args = [], opts = {}) {
14
+ return new Promise((resolve) => {
15
+ execFile(cmd, args, { timeout: 30_000, maxBuffer: 64 * MiB, windowsHide: true, ...opts }, (err, stdout, stderr) => {
16
+ resolve({ code: err ? (typeof err.code === 'number' ? err.code : 1) : 0, stdout: String(stdout ?? ''), stderr: String(stderr ?? ''), error: err });
17
+ });
18
+ });
19
+ }
20
+
21
+ /** Locate an executable on PATH (plus extra candidate paths). */
22
+ export async function which(name, extra = []) {
23
+ const exts = PLATFORM === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
24
+ const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
25
+ for (const c of extra) if (await exists(c)) return c;
26
+ for (const d of dirs) {
27
+ for (const e of exts) {
28
+ const p = path.join(d, name + e);
29
+ if (await exists(p)) return p;
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+
35
+ export async function exists(p) {
36
+ try { await fs.access(p); return true; } catch { return false; }
37
+ }
38
+
39
+ export async function readJson(p, fallback = null) {
40
+ try { return JSON.parse(await fs.readFile(p, 'utf8')); } catch { return fallback; }
41
+ }
42
+
43
+ /** Write JSON atomically, keeping a timestamped backup of the previous file. */
44
+ export async function writeJsonWithBackup(p, data, { indent = 2 } = {}) {
45
+ let backup = null;
46
+ if (await exists(p)) {
47
+ backup = `${p}.bak-llm-tuner-${timestamp()}`;
48
+ await fs.copyFile(p, backup);
49
+ } else {
50
+ await fs.mkdir(path.dirname(p), { recursive: true });
51
+ }
52
+ const tmp = `${p}.tmp-${process.pid}`;
53
+ await fs.writeFile(tmp, indent ? JSON.stringify(data, null, indent) : JSON.stringify(data));
54
+ await fs.rename(tmp, p);
55
+ return backup;
56
+ }
57
+
58
+ export function timestamp() {
59
+ return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
60
+ }
61
+
62
+ export function fmtBytes(b) {
63
+ if (b == null || Number.isNaN(b)) return '?';
64
+ if (Math.abs(b) >= GiB) return `${(b / GiB).toFixed(2)} GiB`;
65
+ return `${Math.round(b / MiB)} MiB`;
66
+ }
67
+
68
+ export const homeDir = () => os.homedir();
69
+
70
+ /** Per-user config folder (presets, settings). LLM_TUNER_CONFIG_DIR overrides it (used by tests). */
71
+ export function configDir() {
72
+ if (process.env.LLM_TUNER_CONFIG_DIR) return process.env.LLM_TUNER_CONFIG_DIR;
73
+ const base = PLATFORM === 'win32' ? (process.env.APPDATA || path.join(homeDir(), 'AppData', 'Roaming'))
74
+ : PLATFORM === 'darwin' ? path.join(homeDir(), 'Library', 'Application Support')
75
+ : (process.env.XDG_CONFIG_HOME || path.join(homeDir(), '.config'));
76
+ return path.join(base, 'llm-tuner');
77
+ }
78
+
79
+ /** Fetch JSON with timeout; returns null on network failure. */
80
+ export async function fetchJson(url, { method = 'GET', body, timeout = 5000, headers = {} } = {}) {
81
+ const ctl = new AbortController();
82
+ const t = setTimeout(() => ctl.abort(), timeout);
83
+ try {
84
+ const res = await fetch(url, {
85
+ method,
86
+ signal: ctl.signal,
87
+ headers: body ? { 'Content-Type': 'application/json', ...headers } : headers,
88
+ body: body ? JSON.stringify(body) : undefined,
89
+ });
90
+ const text = await res.text();
91
+ let json = null;
92
+ try { json = JSON.parse(text); } catch { /* not json */ }
93
+ return { ok: res.ok, status: res.status, json, text };
94
+ } catch (e) {
95
+ return null;
96
+ } finally {
97
+ clearTimeout(t);
98
+ }
99
+ }
100
+
101
+ /** Find a free TCP port on localhost. */
102
+ export async function freePort() {
103
+ const net = await import('node:net');
104
+ return new Promise((resolve, reject) => {
105
+ const srv = net.createServer();
106
+ srv.unref();
107
+ srv.on('error', reject);
108
+ srv.listen(0, '127.0.0.1', () => {
109
+ const { port } = srv.address();
110
+ srv.close(() => resolve(port));
111
+ });
112
+ });
113
+ }
114
+
115
+ /** Spawn a long-running process, capturing its output into a ring buffer. */
116
+ export function spawnLogged(cmd, args, opts = {}) {
117
+ const child = spawn(cmd, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], ...opts });
118
+ const lines = [];
119
+ const push = (buf) => {
120
+ for (const l of String(buf).split(/\r?\n/)) if (l) { lines.push(l); if (lines.length > 400) lines.shift(); }
121
+ };
122
+ child.stdout?.on('data', push);
123
+ child.stderr?.on('data', push);
124
+ const exited = new Promise((r) => child.on('exit', (code, signal) => r({ code, signal })));
125
+ return { child, lines, exited };
126
+ }
127
+
128
+ export async function killProcess(child, graceMs = 5000) {
129
+ if (!child || child.exitCode !== null || child.signalCode) return;
130
+ child.kill(PLATFORM === 'win32' ? undefined : 'SIGTERM');
131
+ const done = await Promise.race([new Promise((r) => child.once('exit', () => r(true))), sleep(graceMs).then(() => false)]);
132
+ if (!done) child.kill('SIGKILL');
133
+ }
134
+
135
+ /** Sample overall CPU busy % between two os.cpus() snapshots (cross-platform). */
136
+ export function cpuSnapshot() {
137
+ return os.cpus().map((c) => ({ ...c.times }));
138
+ }
139
+ export function cpuBusyPercent(a, b) {
140
+ let idle = 0, total = 0;
141
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
142
+ const ta = a[i], tb = b[i];
143
+ const t = (tb.user - ta.user) + (tb.nice - ta.nice) + (tb.sys - ta.sys) + (tb.idle - ta.idle) + (tb.irq - ta.irq);
144
+ idle += tb.idle - ta.idle;
145
+ total += t;
146
+ }
147
+ return total > 0 ? 100 * (1 - idle / total) : 0;
148
+ }
@@ -0,0 +1,11 @@
1
+ // Locale helpers shared by Node and the browser (served at /i18n/core.js). No imports.
2
+
3
+ export const LOCALES = ['en', 'es'];
4
+ export const DEFAULT_LOCALE = 'en';
5
+
6
+ export const normalizeLocale = (value) => (LOCALES.includes(value) ? value : DEFAULT_LOCALE);
7
+
8
+ /** Replace {name} with params.name. Missing or null params stay visible as {name}. */
9
+ export function format(template, params = {}) {
10
+ return String(template).replace(/\{(\w+)\}/g, (match, name) => (params?.[name] ?? null) === null ? match : String(params[name]));
11
+ }