llm-tuner 0.1.0 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-tuner",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Detects your hardware and finds the fastest configuration to run local LLMs with LM Studio or Ollama.",
5
5
  "keywords": [
6
6
  "llm",
package/src/cli/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { select, input, confirm, search } from '@inquirer/prompts';
3
- import { detectHardware, detectEngines, Tuner, installPlans, runInstall, listPresets, presetsDir, fmtBytes, readSettings, writeSettings, THEMES, PROFILES, DEFAULT_PROFILE, TunerError } from '../core/index.js';
3
+ import { detectHardware, detectEngines, Tuner, installPlans, runInstall, listPresets, presetsDir, fmtBytes, readSettings, writeSettings, THEMES, PROFILES, DEFAULT_PROFILE, TunerError, checkUpdate, CURRENT_VERSION } from '../core/index.js';
4
4
  import { t as translate, LOCALES, errorText } from '../i18n/index.js';
5
- import { profileChoices, profileNotes, profileSwitchNote, profileEventLines, shouldAskProfile } from './profile.js';
5
+ import { profileChoices, profileNotes, profileSwitchNote, profileEventLines, shouldAskProfile, parseContext } from './profile.js';
6
6
 
7
7
  const args = parseArgs(process.argv.slice(2));
8
8
  let { lang, theme } = await readSettings();
@@ -34,12 +34,18 @@ const isLight = () => theme === 'light';
34
34
  const c = {
35
35
  b: (s) => `\x1b[1m${s}\x1b[0m`,
36
36
  dim: (s) => isLight() ? `\x1b[90m${s}\x1b[0m` : `\x1b[2m${s}\x1b[0m`,
37
- g: (s) => isLight() ? `\x1b[32m${s}\x1b[0m` : `\x1b[92m${s}\x1b[0m`,
37
+ g: (s) => isLight() ? `\x1b[38;2;40;138;0m${s}\x1b[0m` : `\x1b[38;2;101;255;0m${s}\x1b[0m`,
38
38
  y: (s) => isLight() ? `\x1b[33m${s}\x1b[0m` : `\x1b[93m${s}\x1b[0m`,
39
- r: (s) => isLight() ? `\x1b[31m${s}\x1b[0m` : `\x1b[91m${s}\x1b[0m`,
40
- cyan: (s) => isLight() ? `\x1b[36m${s}\x1b[0m` : `\x1b[96m${s}\x1b[0m`,
39
+ r: (s) => isLight() ? `\x1b[38;2;214;21;0m${s}\x1b[0m` : `\x1b[38;2;255;26;0m${s}\x1b[0m`,
40
+ cyan: (s) => isLight() ? `\x1b[38;2;0;124;153m${s}\x1b[0m` : `\x1b[38;2;0;229;255m${s}\x1b[0m`,
41
+ purple: (s) => isLight() ? `\x1b[38;2;120;0;199m${s}\x1b[0m` : `\x1b[38;2;154;0;255m${s}\x1b[0m`,
41
42
  };
42
43
 
44
+ if (args.version) {
45
+ console.log(CURRENT_VERSION);
46
+ process.exit(0);
47
+ }
48
+
43
49
  if (args.help) {
44
50
  console.log(t('cli.help', { presetsDir: presetsDir() }));
45
51
  process.exit(0);
@@ -114,7 +120,46 @@ async function settingsFlow() {
114
120
  }
115
121
 
116
122
  async function main() {
117
- const out = (...a) => { if (!args.json) console.log(...a); };
123
+ const updatePromise = checkUpdate().catch(() => null);
124
+ const isTTY = Boolean(process.stdout.isTTY && !args.json);
125
+ let lastProgress = null;
126
+ let lastTelemetry = null;
127
+ let liveActive = false;
128
+
129
+ const clearLive = () => {
130
+ if (isTTY && liveActive) {
131
+ process.stdout.write('\r\x1b[K');
132
+ liveActive = false;
133
+ }
134
+ };
135
+
136
+ const renderLive = () => {
137
+ if (!isTTY || !lastProgress) return;
138
+ const pct = Math.min(100, Math.max(0, lastProgress.pct ?? 0));
139
+ const barLen = 16;
140
+ const filled = Math.round((pct / 100) * barLen);
141
+ const bar = c.cyan('━'.repeat(filled)) + c.dim('─'.repeat(barLen - filled));
142
+
143
+ let telem = '';
144
+ if (lastTelemetry) {
145
+ if (lastTelemetry.cpu) telem += ` · CPU ${lastTelemetry.cpu.percent}%`;
146
+ if (lastTelemetry.ram) {
147
+ telem += ` · RAM ${(lastTelemetry.ram.usedBytes / 1024 ** 3).toFixed(1)}/${(lastTelemetry.ram.totalBytes / 1024 ** 3).toFixed(1)} GB`;
148
+ }
149
+ if (lastTelemetry.gpus?.length) {
150
+ telem += ' · ' + lastTelemetry.gpus.map((g) => `GPU${g.index} ${(g.usedBytes / 1024 ** 3).toFixed(1)}/${(g.totalBytes / 1024 ** 3).toFixed(1)} GB`).join(' ');
151
+ }
152
+ }
153
+
154
+ process.stdout.write(`\r\x1b[K [${bar}] ${String(pct).padStart(3)}%${telem}`);
155
+ liveActive = true;
156
+ };
157
+
158
+ const out = (...a) => {
159
+ if (args.json) return;
160
+ clearLive();
161
+ console.log(...a);
162
+ };
118
163
  out([
119
164
  '',
120
165
  ` ${c.cyan(' ╭───')}${c.dim('╷ ╷ ╷')}${c.cyan('───╮')}`,
@@ -125,6 +170,12 @@ async function main() {
125
170
  '',
126
171
  ].join('\n'));
127
172
 
173
+ const updateInfo = await updatePromise;
174
+ if (updateInfo?.updateAvailable) {
175
+ out(c.y(` ▲ ${t('cli.updateAvailable', { current: updateInfo.currentVersion, latest: updateInfo.latestVersion })}`));
176
+ out(c.dim(` ${t('cli.updateCommand', { cmd: 'npm install -g llm-tuner' })}\n`));
177
+ }
178
+
128
179
  const hw = await detectHardware();
129
180
  out(c.dim(` ${hw.cpu.brand} · ${fmtBytes(hw.ram.totalBytes)} RAM`));
130
181
  for (const g of hw.gpus) out(c.dim(` GPU${g.index} ${g.name} · ${fmtBytes(g.totalBytes)}${g.pcieGen ? ` · PCIe ${g.pcieGen}.0 x${g.pcieWidth}` : ''}${g.displayAttached ? t('cli.display') : ''}`));
@@ -168,7 +219,7 @@ async function main() {
168
219
  });
169
220
 
170
221
  // 3. Context
171
- let ctx = Number(args.ctx) || null;
222
+ let ctx = parseContext(args.ctx);
172
223
  if (!ctx) {
173
224
  const presets = await listPresets({ engine: engineId, modelKey });
174
225
  const preview = await tuner.plan(modelKey, 8192);
@@ -176,11 +227,12 @@ async function main() {
176
227
  const list = Object.entries(preview.maxContext).map(([k, v]) => `KV ${k} ${v ? Math.floor(v / 1024) + 'K' : '—'}`).join(' · ');
177
228
  out(c.dim(` ${t('cli.maxFullGpu', { list, train: Math.floor(limit / 1024) })}`));
178
229
  if (presets.length) out(c.dim(` ${t('cli.tunedContexts', { list: presets.map((p) => `${p.ctx / 1024}K (${p.shortTps} t/s)`).join(', ') })}`));
179
- ctx = Number(await input({
230
+ const answer = await input({
180
231
  message: t('cli.context'),
181
232
  default: String(presets.at(-1)?.ctx || Math.min(16384, limit)),
182
- validate: (v) => (Number(v) >= 512 && Number(v) <= limit) || t('cli.contextRange', { limit }),
183
- }));
233
+ validate: (v) => parseContext(v, limit) !== null || t('cli.contextRange', { limit }),
234
+ });
235
+ ctx = parseContext(answer, limit);
184
236
  }
185
237
 
186
238
  // 4. Profile
@@ -214,6 +266,14 @@ async function main() {
214
266
  let previous = null;
215
267
  tuner.on('progress', (e) => {
216
268
  if (args.json) return;
269
+ if (e.type === 'telemetry') {
270
+ lastTelemetry = e;
271
+ renderLive();
272
+ return;
273
+ }
274
+ if (e.progress) {
275
+ lastProgress = e.progress;
276
+ }
217
277
  if (e.type === 'variant-picked' || e.type === 'profile-fallback') {
218
278
  for (const line of profileEventLines(e, previous, t, advisedByWizard ? null : tuner.engine.name)) {
219
279
  out((line.level === 'warn' ? c.y : c.dim)(` ${line.text}`));
@@ -229,26 +289,34 @@ async function main() {
229
289
  out(b.ok ? ` ${c.g('✓')} ${t('cli.benchShort', { tps: c.b(b.short.genTps + ' t/s') })}${b.deep ? t('cli.benchDeep', { tps: c.b(b.deep.genTps + ' t/s'), tokens: b.deep.promptTokens }) : ''} · CPU ${b.cpu.avg}% · VRAM ${vramStr(b.vramPeakBytes)}` : c.r(` ✗ ${benchErrorText(b)}`));
230
290
  }
231
291
  if (e.type === 'preset-saved') out(c.g(`\n✓ ${t('common.presetSaved', { file: e.file })}`));
232
- });
233
292
 
234
- const result = await tuner.load(modelKey, ctx, {
235
- profile,
236
- force: !!args.force,
237
- dryRun: !!args['dry-run'],
238
- maxCandidates: Number(args.candidates) || 3,
239
- confirmApply: async (preview) => {
240
- out(c.b(`\n${t('cli.changes')}`));
241
- if (engineId === 'lmstudio') {
242
- preview.files.forEach((f) => out(` ${f}`));
243
- if (preview.restartsApp) out(c.y(` ${t('cli.lmsRestart')}`));
244
- } else {
245
- out(` ${t('cli.ollamaNewModel', { name: c.b(preview.tunedModel) })}`);
246
- out(` ${t('cli.ollamaServer', { env: Object.entries(preview.env).map(([k, v]) => `${k}=${v}`).join(' ') })}`);
247
- }
248
- return args.yes || confirm({ message: t('cli.confirmApply'), default: true });
249
- },
293
+ renderLive();
250
294
  });
251
295
 
296
+ let result;
297
+ try {
298
+ result = await tuner.load(modelKey, ctx, {
299
+ profile,
300
+ force: !!args.force,
301
+ dryRun: !!args['dry-run'],
302
+ maxCandidates: Number(args.candidates) || 3,
303
+ confirmApply: async (preview) => {
304
+ clearLive();
305
+ out(c.b(`\n${t('cli.changes')}`));
306
+ if (engineId === 'lmstudio') {
307
+ preview.files.forEach((f) => out(` ${f}`));
308
+ if (preview.restartsApp) out(c.y(` ${t('cli.lmsRestart')}`));
309
+ } else {
310
+ out(` ${t('cli.ollamaNewModel', { name: c.b(preview.tunedModel) })}`);
311
+ out(` ${t('cli.ollamaServer', { env: Object.entries(preview.env).map(([k, v]) => `${k}=${v}`).join(' ') })}`);
312
+ }
313
+ return args.yes || confirm({ message: t('cli.confirmApply'), default: true });
314
+ },
315
+ });
316
+ } finally {
317
+ clearLive();
318
+ }
319
+
252
320
  if (args['dry-run']) out(c.y(`\n${t('cli.dryRun')}`));
253
321
  if (result.applied?.backups?.length) out(c.dim(` ${t('common.backups', { list: result.applied.backups.join(', ') })}`));
254
322
  if (result.applied?.pendingCommands?.length) {
@@ -288,11 +356,12 @@ function vramStr(peaks = {}) {
288
356
  }
289
357
 
290
358
  function parseArgs(argv) {
291
- const flags = new Set(['help', 'yes', 'dry-run', 'json', 'force', 'presets', 'settings']);
359
+ const flags = new Set(['help', 'version', 'yes', 'dry-run', 'json', 'force', 'presets', 'settings']);
292
360
  const o = {};
293
361
  for (let i = 0; i < argv.length; i++) {
294
362
  const a = argv[i];
295
363
  if (a === '-h') o.help = true;
364
+ if (a === '-v') o.version = true;
296
365
  if (a === '/settings' || a === '/setting' || a === 'settings') o.settings = true;
297
366
  if (!a.startsWith('--')) continue;
298
367
  const k = a.slice(2);
@@ -4,11 +4,24 @@ import { PROFILES, fmtBytes } from '../core/index.js';
4
4
  // Reasons of a `variant-picked` event that mean the profile could not be honoured: the core flags them with a `profile-fallback` event right after.
5
5
  export const FALLBACK_REASONS = new Set(['profile.variant.noFullGpu']);
6
6
 
7
+ /** Parses a context string (e.g. "8192", "8k", "16K") into tokens, or null if invalid or outside [512, limit]. */
8
+ export function parseContext(v, limit = Infinity) {
9
+ if (v == null) return null;
10
+ const str = String(v).trim().toLowerCase();
11
+ const m = str.match(/^(\d+(?:\.\d+)?)\s*(k|m)?$/);
12
+ if (!m) return null;
13
+ const num = Number(m[1]);
14
+ if (!Number.isFinite(num)) return null;
15
+ const multiplier = m[2] === 'k' ? 1024 : m[2] === 'm' ? 1024 * 1024 : 1;
16
+ const val = Math.round(num * multiplier);
17
+ return val >= 512 && val <= limit ? val : null;
18
+ }
19
+
7
20
  /** True when the CLI has to ask for the load profile; `args` are the parsed flags. */
8
21
  export function shouldAskProfile(args) {
9
22
  if (args.profile || args.yes || args.json) return false;
10
23
  // Engine, model and context all from flags: a scripted run that asked nothing before the profiles existed, so it stays prompt-free.
11
- const flagged = Boolean(args.engine && args.model && Number(args.ctx));
24
+ const flagged = Boolean(args.engine && args.model && parseContext(args.ctx ?? args.context));
12
25
  return !flagged;
13
26
  }
14
27
 
@@ -78,7 +78,8 @@ export const lmstudio = {
78
78
  try { list = JSON.parse(r.stdout); } catch { return []; }
79
79
  return list
80
80
  .filter((m) => m.type === 'llm' && m.format === 'gguf')
81
- .map((m) => ({ key: m.modelKey || m.path, name: m.displayName, sizeBytes: m.sizeBytes, arch: m.architecture, quant: m.quantization?.name, maxContext: m.maxContextLength, moe: /A\d+B/i.test(m.paramsString || '') }));
81
+ .map((m) => ({ key: m.modelKey || m.path, name: m.displayName, sizeBytes: m.sizeBytes, arch: m.architecture, quant: m.quantization?.name, maxContext: m.maxContextLength, moe: /A\d+B/i.test(m.paramsString || '') }))
82
+ .sort((a, b) => (a.sizeBytes ?? 0) - (b.sizeBytes ?? 0) || (a.name || '').localeCompare(b.name || ''));
82
83
  },
83
84
 
84
85
  /** Resolve the GGUF file behind a model key using LM Studio's model index. */
@@ -149,7 +149,8 @@ export const ollama = {
149
149
  const r = await fetchJson(`${ctx.detection.apiUrl}/api/tags`);
150
150
  return (r?.json?.models || [])
151
151
  .filter((m) => !/embed/i.test(m.name) && !/bert/i.test(m.details?.family || ''))
152
- .map((m) => ({ key: m.name, name: m.name, sizeBytes: m.size, arch: m.details?.family, quant: m.details?.quantization_level, maxContext: null }));
152
+ .map((m) => ({ key: m.name, name: m.name, sizeBytes: m.size, arch: m.details?.family, quant: m.details?.quantization_level, maxContext: null }))
153
+ .sort((a, b) => (a.sizeBytes ?? 0) - (b.sizeBytes ?? 0) || (a.name || '').localeCompare(b.name || ''));
153
154
  },
154
155
 
155
156
  /** Downloaded variants (tags) of a model: [{ key, quant, sizeBytes, selected }]. */
package/src/core/index.js CHANGED
@@ -8,3 +8,5 @@ export { fmtBytes, configDir } from './util.js';
8
8
  export { findPreset, savePreset, listPresets, presetsDir, presetPath, variantsSignature, hardwareFingerprint } from './presets.js';
9
9
  export { readSettings, writeSettings, settingsPath, THEMES, DEFAULT_THEME, normalizeTheme } from './settings.js';
10
10
  export { TunerError, benchError } from './errors.js';
11
+ export { sampleRam, sampleGpus, startTelemetrySampler } from './telemetry.js';
12
+ export { checkUpdate, isNewerVersion, CURRENT_VERSION } from './updater.js';
@@ -0,0 +1,73 @@
1
+ import os from 'node:os';
2
+ import { nvidiaGpus } from './hardware.js';
3
+ import { cpuSnapshot, cpuBusyPercent } from './util.js';
4
+
5
+ /**
6
+ * Sample RAM usage information.
7
+ * @returns {{ totalBytes: number, freeBytes: number, usedBytes: number, percent: number }}
8
+ */
9
+ export function sampleRam() {
10
+ const totalBytes = os.totalmem();
11
+ const freeBytes = os.freemem();
12
+ const usedBytes = totalBytes - freeBytes;
13
+ const percent = totalBytes > 0 ? +(100 * (1 - freeBytes / totalBytes)).toFixed(1) : 0;
14
+ return { totalBytes, freeBytes, usedBytes, percent };
15
+ }
16
+
17
+ /**
18
+ * Sample GPU usage information.
19
+ * @returns {Promise<Array<{ index: number, name: string, totalBytes: number, usedBytes: number, freeBytes: number, percent: number }>>}
20
+ */
21
+ export async function sampleGpus() {
22
+ const gpus = await nvidiaGpus();
23
+ return gpus.map((g) => ({
24
+ index: g.index,
25
+ name: g.name,
26
+ totalBytes: g.totalBytes,
27
+ usedBytes: g.usedBytes,
28
+ freeBytes: g.freeBytes,
29
+ percent: g.totalBytes > 0 ? +(100 * (g.usedBytes / g.totalBytes)).toFixed(1) : 0,
30
+ }));
31
+ }
32
+
33
+ /**
34
+ * Start telemetry sampling at regular intervals.
35
+ * @param {Function} onSample - Callback function to receive sample data
36
+ * @param {number} intervalMs - Sampling interval in milliseconds (default: 500)
37
+ * @returns {{ stop: () => void }} Object with stop method to cancel the sampler
38
+ */
39
+ export function startTelemetrySampler(onSample, intervalMs = 500) {
40
+ let timerId = null;
41
+ let prevCpu = cpuSnapshot();
42
+ let sampling = false;
43
+ let stopped = false;
44
+
45
+ const tick = async () => {
46
+ if (sampling || stopped) return;
47
+ sampling = true;
48
+ try {
49
+ const nowCpu = cpuSnapshot();
50
+ const cpuPct = +(cpuBusyPercent(prevCpu, nowCpu)).toFixed(1);
51
+ prevCpu = nowCpu;
52
+ const ram = sampleRam();
53
+ const gpus = await sampleGpus();
54
+ if (!stopped) onSample({ cpu: { percent: cpuPct }, ram, gpus });
55
+ } catch {
56
+ // Ignore transient sampling errors
57
+ } finally {
58
+ sampling = false;
59
+ }
60
+ };
61
+
62
+ timerId = setInterval(tick, intervalMs);
63
+
64
+ return {
65
+ stop() {
66
+ stopped = true;
67
+ if (timerId) {
68
+ clearInterval(timerId);
69
+ timerId = null;
70
+ }
71
+ },
72
+ };
73
+ }
package/src/core/tuner.js CHANGED
@@ -6,6 +6,7 @@ import { PROFILES, DEFAULT_PROFILE, normalizeProfile, kvTypesFor, meetsQuality,
6
6
  import { lmstudio } from './engines/lmstudio.js';
7
7
  import { ollama } from './engines/ollama.js';
8
8
  import { TunerError, benchError } from './errors.js';
9
+ import { startTelemetrySampler } from './telemetry.js';
9
10
 
10
11
  export { pickBest };
11
12
  export const ENGINES = { lmstudio, ollama };
@@ -57,7 +58,23 @@ export class Tuner extends EventEmitter {
57
58
 
58
59
  log(type, data = {}) { this.emit('progress', { type, ...data, at: Date.now() }); }
59
60
 
60
- listModels() { return this.engine.listModels(this.ctx); }
61
+ withTelemetry(fn) {
62
+ if (this._telemetryActive) return fn();
63
+ this._telemetryActive = true;
64
+ const sampler = startTelemetrySampler((sample) => this.log('telemetry', sample));
65
+ return Promise.resolve()
66
+ .then(fn)
67
+ .finally(() => {
68
+ this._telemetryActive = false;
69
+ sampler.stop();
70
+ });
71
+ }
72
+
73
+
74
+ async listModels() {
75
+ const list = (await this.engine.listModels?.(this.ctx)) || [];
76
+ return list.slice().sort((a, b) => (a.sizeBytes ?? 0) - (b.sizeBytes ?? 0) || (a.name || '').localeCompare(b.name || ''));
77
+ }
61
78
 
62
79
  async modelInfo(key) {
63
80
  const meta = await this.engine.modelMeta(this.ctx, key);
@@ -200,34 +217,50 @@ export class Tuner extends EventEmitter {
200
217
 
201
218
  /** Estimate, benchmark the top candidates for real, and return a report. The profile decides the winner. */
202
219
  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 });
220
+ return this.withTelemetry(async () => {
221
+ const profile = normalizeProfile(requested);
222
+ this.log('status', { code: 'status.engine', params: { name: this.engine.name }, progress: { pct: 5, phase: 'plan' } });
223
+ const { model, candidates, maxContext } = await this.plan(key, ctx, { unload: true, profile });
224
+ const model2 = { key, meta: model.meta };
225
+ const toTest = candidates.slice(0, maxCandidates);
226
+ this.log('plan', { candidates: toTest.map(slim), maxContext, progress: { pct: 10, phase: 'plan' } });
209
227
 
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 }),
228
+ const depthTokens = Math.floor(ctx * depthFraction);
229
+ const results = [];
230
+ for (const [i, c] of toTest.entries()) {
231
+ this.log('candidate-start', {
232
+ index: i,
233
+ total: toTest.length,
234
+ candidate: slim(c),
235
+ progress: { pct: 10 + Math.round((i / toTest.length) * 70), phase: 'bench', current: i + 1, total: toTest.length },
236
+ });
237
+ let bench;
238
+ try {
239
+ bench = await this.engine.benchmark(this.ctx, model2, c, {
240
+ depthTokens, genTokens,
241
+ onProgress: (p) => this.log('bench-progress', {
242
+ index: i,
243
+ ...p,
244
+ progress: { pct: 10 + Math.round(((i + (p.phase === 'deep' ? 0.6 : 0.2)) / toTest.length) * 70), phase: 'bench', current: i + 1, total: toTest.length },
245
+ }),
246
+ });
247
+ } catch (err) {
248
+ bench = err?.name === 'TunerError' ? benchError(err.code, err.params) : { ok: false, error: String(err.message || err) };
249
+ }
250
+ results.push({ candidate: c, bench });
251
+ this.log('candidate-done', {
252
+ index: i,
253
+ candidate: slim(c),
254
+ bench,
255
+ progress: { pct: 10 + Math.round(((i + 1) / toTest.length) * 70), phase: 'bench', current: i + 1, total: toTest.length },
219
256
  });
220
- } catch (err) {
221
- bench = err?.name === 'TunerError' ? benchError(err.code, err.params) : { ok: false, error: String(err.message || err) };
222
257
  }
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;
258
+ const best = pickBest(results, profile);
259
+ 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 };
260
+ this.log('done', { best: report.best && { candidate: slim(best.candidate), bench: best.bench }, progress: { pct: 82, phase: 'bench' } });
261
+ this.lastReport = { ...report, modelObj: model2 };
262
+ return report;
263
+ });
231
264
  }
232
265
 
233
266
  /**
@@ -238,63 +271,65 @@ export class Tuner extends EventEmitter {
238
271
  * the preset, or pick one once the VRAM is free.
239
272
  */
240
273
  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 });
274
+ return this.withTelemetry(async () => {
275
+ const profile = normalizeProfile(requested);
276
+ const model = await this.modelInfo(key);
277
+ const modelBytes = model.meta.fileBytes;
278
+ // Listing the variants of a non-balanced profile needs no hardware; picking one does, so the pick waits.
279
+ const variants = profile === DEFAULT_PROFILE ? null : await this.variants(key);
280
+ const listed = variants?.map((v) => ({ key: v.key, sizeBytes: v.sizeBytes, selected: v.selected }));
281
+ // 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.
282
+ const keyed = variants ? { profile, variants: listed } : {};
283
+ let target = null;
284
+ let source = 'preset';
285
+ let candidate;
286
+ let report = null;
287
+ let found = force ? { preset: null, reason: 'forced' } : await findPreset({ engine: this.engine.id, modelKey: key, ctx, hw: this.hw, modelBytes, ...keyed });
254
288
 
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);
289
+ if (found.preset) {
290
+ candidate = found.preset.candidate;
291
+ this.log('preset-hit', { preset: found.preset, file: presetPath(this.engine.id, key, ctx, profile), progress: { pct: 30, phase: 'preset' } });
292
+ // The preset remembers which variant it measured: nothing is picked and no VRAM is freed.
293
+ if (variants) target = this.presetTarget(key, profile, model, found.preset);
294
+ } else {
295
+ source = 'benchmark';
296
+ const code = { none: 'status.searchNone', hardware: 'status.searchHardware', model: 'status.searchModel', forced: 'status.searchForced', variants: 'status.searchVariants' }[found.reason];
297
+ this.log('status', { code, progress: { pct: 5, phase: 'plan' } });
298
+ if (variants) {
299
+ // The pick depends on the free VRAM, so the engine's models are unloaded before it.
300
+ await this.freeVram();
301
+ target = this.profileTarget(key, ctx, profile, model, variants);
302
+ }
303
+ report = await this.run(target?.key ?? key, ctx, { maxCandidates, depthFraction, profile });
304
+ if (!report.best) throw new TunerError('errors.noConfigWorked');
305
+ candidate = report.best.candidate;
306
+ // Quality found no configuration that keeps its promise: say so once.
307
+ if (profile === 'quality' && !target.fallback && !meetsQuality(candidate)) this.log('profile-fallback', { profile, reasonCode: 'profile.fallback.noFullGpuConfig', variant: target.picked });
308
+ if (!dryRun) {
309
+ 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 } : {}) });
310
+ this.log('preset-saved', { file: saved.file, preset: saved.preset, progress: { pct: 85, phase: 'save' } });
311
+ }
268
312
  }
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
313
 
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 };
314
+ // The variant a profile picked is applied and loaded with its own metadata.
315
+ const loadKey = target?.key ?? key;
316
+ const meta = loadKey === key ? model.meta : variants?.find((v) => v.key === loadKey)?.meta ?? (await this.modelInfo(loadKey)).meta;
317
+ const modelObj = { key: loadKey, meta };
318
+ const preview = await this.engine.apply(this.ctx, modelObj, candidate, { dryRun: true });
319
+ if (dryRun) return { source, candidate: slim(candidate), report, preview, profile, variant: loadKey };
320
+ if (!(await confirmApply(preview))) return { source, candidate: slim(candidate), report, preview, applied: false, profile, variant: loadKey };
287
321
 
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 };
322
+ this.log('status', { code: 'status.applying', progress: { pct: 88, phase: 'apply' } });
323
+ const applied = await this.engine.apply(this.ctx, modelObj, candidate, { useSudo: true, skipIfApplied: source === 'preset' });
324
+ this.log('applied', { result: applied, progress: { pct: 92, phase: 'apply' } });
325
+ if (applied.pendingCommands?.length) return { source, candidate: slim(candidate), report, applied, loaded: null, profile, variant: loadKey };
292
326
 
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 };
327
+ this.log('status', { code: 'status.loading', progress: { pct: 95, phase: 'load' } });
328
+ this.ctx.detection = await this.engine.detect();
329
+ const loaded = await this.engine.load(this.ctx, modelObj, { candidate, depthTokens: 0, genTokens: measure ? 200 : 1, onProgress: (p) => this.log('bench-progress', { ...p, progress: { pct: 98, phase: 'load' } }) });
330
+ this.log('loaded', { bench: loaded, progress: { pct: 100, phase: 'done' } });
331
+ return { source, candidate: slim(candidate), report, applied, loaded, profile, variant: loadKey };
332
+ });
298
333
  }
299
334
  }
300
335