llm-tuner 0.1.0 → 0.2.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.2.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
@@ -2,7 +2,7 @@
2
2
  import { select, input, confirm, search } from '@inquirer/prompts';
3
3
  import { detectHardware, detectEngines, Tuner, installPlans, runInstall, listPresets, presetsDir, fmtBytes, readSettings, writeSettings, THEMES, PROFILES, DEFAULT_PROFILE, TunerError } 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();
@@ -114,7 +114,45 @@ async function settingsFlow() {
114
114
  }
115
115
 
116
116
  async function main() {
117
- const out = (...a) => { if (!args.json) console.log(...a); };
117
+ const isTTY = Boolean(process.stdout.isTTY && !args.json);
118
+ let lastProgress = null;
119
+ let lastTelemetry = null;
120
+ let liveActive = false;
121
+
122
+ const clearLive = () => {
123
+ if (isTTY && liveActive) {
124
+ process.stdout.write('\r\x1b[K');
125
+ liveActive = false;
126
+ }
127
+ };
128
+
129
+ const renderLive = () => {
130
+ if (!isTTY || !lastProgress) return;
131
+ const pct = Math.min(100, Math.max(0, lastProgress.pct ?? 0));
132
+ const barLen = 16;
133
+ const filled = Math.round((pct / 100) * barLen);
134
+ const bar = c.cyan('━'.repeat(filled)) + c.dim('─'.repeat(barLen - filled));
135
+
136
+ let telem = '';
137
+ if (lastTelemetry) {
138
+ if (lastTelemetry.cpu) telem += ` · CPU ${lastTelemetry.cpu.percent}%`;
139
+ if (lastTelemetry.ram) {
140
+ telem += ` · RAM ${(lastTelemetry.ram.usedBytes / 1024 ** 3).toFixed(1)}/${(lastTelemetry.ram.totalBytes / 1024 ** 3).toFixed(1)} GB`;
141
+ }
142
+ if (lastTelemetry.gpus?.length) {
143
+ telem += ' · ' + lastTelemetry.gpus.map((g) => `GPU${g.index} ${(g.usedBytes / 1024 ** 3).toFixed(1)}/${(g.totalBytes / 1024 ** 3).toFixed(1)} GB`).join(' ');
144
+ }
145
+ }
146
+
147
+ process.stdout.write(`\r\x1b[K [${bar}] ${String(pct).padStart(3)}%${telem}`);
148
+ liveActive = true;
149
+ };
150
+
151
+ const out = (...a) => {
152
+ if (args.json) return;
153
+ clearLive();
154
+ console.log(...a);
155
+ };
118
156
  out([
119
157
  '',
120
158
  ` ${c.cyan(' ╭───')}${c.dim('╷ ╷ ╷')}${c.cyan('───╮')}`,
@@ -168,7 +206,7 @@ async function main() {
168
206
  });
169
207
 
170
208
  // 3. Context
171
- let ctx = Number(args.ctx) || null;
209
+ let ctx = parseContext(args.ctx);
172
210
  if (!ctx) {
173
211
  const presets = await listPresets({ engine: engineId, modelKey });
174
212
  const preview = await tuner.plan(modelKey, 8192);
@@ -176,11 +214,12 @@ async function main() {
176
214
  const list = Object.entries(preview.maxContext).map(([k, v]) => `KV ${k} ${v ? Math.floor(v / 1024) + 'K' : '—'}`).join(' · ');
177
215
  out(c.dim(` ${t('cli.maxFullGpu', { list, train: Math.floor(limit / 1024) })}`));
178
216
  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({
217
+ const answer = await input({
180
218
  message: t('cli.context'),
181
219
  default: String(presets.at(-1)?.ctx || Math.min(16384, limit)),
182
- validate: (v) => (Number(v) >= 512 && Number(v) <= limit) || t('cli.contextRange', { limit }),
183
- }));
220
+ validate: (v) => parseContext(v, limit) !== null || t('cli.contextRange', { limit }),
221
+ });
222
+ ctx = parseContext(answer, limit);
184
223
  }
185
224
 
186
225
  // 4. Profile
@@ -214,6 +253,14 @@ async function main() {
214
253
  let previous = null;
215
254
  tuner.on('progress', (e) => {
216
255
  if (args.json) return;
256
+ if (e.type === 'telemetry') {
257
+ lastTelemetry = e;
258
+ renderLive();
259
+ return;
260
+ }
261
+ if (e.progress) {
262
+ lastProgress = e.progress;
263
+ }
217
264
  if (e.type === 'variant-picked' || e.type === 'profile-fallback') {
218
265
  for (const line of profileEventLines(e, previous, t, advisedByWizard ? null : tuner.engine.name)) {
219
266
  out((line.level === 'warn' ? c.y : c.dim)(` ${line.text}`));
@@ -229,26 +276,34 @@ async function main() {
229
276
  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
277
  }
231
278
  if (e.type === 'preset-saved') out(c.g(`\n✓ ${t('common.presetSaved', { file: e.file })}`));
232
- });
233
279
 
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
- },
280
+ renderLive();
250
281
  });
251
282
 
283
+ let result;
284
+ try {
285
+ result = await tuner.load(modelKey, ctx, {
286
+ profile,
287
+ force: !!args.force,
288
+ dryRun: !!args['dry-run'],
289
+ maxCandidates: Number(args.candidates) || 3,
290
+ confirmApply: async (preview) => {
291
+ clearLive();
292
+ out(c.b(`\n${t('cli.changes')}`));
293
+ if (engineId === 'lmstudio') {
294
+ preview.files.forEach((f) => out(` ${f}`));
295
+ if (preview.restartsApp) out(c.y(` ${t('cli.lmsRestart')}`));
296
+ } else {
297
+ out(` ${t('cli.ollamaNewModel', { name: c.b(preview.tunedModel) })}`);
298
+ out(` ${t('cli.ollamaServer', { env: Object.entries(preview.env).map(([k, v]) => `${k}=${v}`).join(' ') })}`);
299
+ }
300
+ return args.yes || confirm({ message: t('cli.confirmApply'), default: true });
301
+ },
302
+ });
303
+ } finally {
304
+ clearLive();
305
+ }
306
+
252
307
  if (args['dry-run']) out(c.y(`\n${t('cli.dryRun')}`));
253
308
  if (result.applied?.backups?.length) out(c.dim(` ${t('common.backups', { list: result.applied.backups.join(', ') })}`));
254
309
  if (result.applied?.pendingCommands?.length) {
@@ -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
+
@@ -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
 
package/src/i18n/en.js CHANGED
@@ -92,7 +92,7 @@ export default {
92
92
  // Shared by CLI and web
93
93
  'common.error': 'Error: {message}',
94
94
  'common.na': 'n/a',
95
- 'common.longPrompt': 'long prompt (~{tokens} tokens)…',
95
+ 'common.longPrompt': 'long prompt (~{tokens} tokens, 50% context)…',
96
96
  'common.presetSaved': 'Preset saved: {file}',
97
97
  'common.backups': 'Backups: {list}',
98
98
 
@@ -154,7 +154,8 @@ Presets are saved in {presetsDir}`,
154
154
  'cli.confirmApply': 'Apply and load the model?',
155
155
  'cli.dryRun': '--dry-run: nothing was applied and no preset was saved.',
156
156
  'cli.pending': 'To finish, run (asks for the administrator password):',
157
- 'cli.loaded': 'Model loaded in {engine}: {tps}',
157
+ 'cli.tuningModel': 'Tuning {model} with {ctx} context ({profile})…',
158
+ 'cli.loaded': 'Model loaded in {engine} with {ctx} context: {tps}',
158
159
  'cli.usesModel': ' (uses the model {model})',
159
160
  'cli.loadFailed': 'Could not load: {error}',
160
161
  'cli.installMethod': 'Install method:',
@@ -239,5 +240,9 @@ Presets are saved in {presetsDir}`,
239
240
  'web.themeDark': 'Dark',
240
241
  'web.settings': 'Settings',
241
242
  'web.close': 'Close',
243
+ 'web.telemetryCpu': 'CPU',
244
+ 'web.telemetryRam': 'RAM',
245
+ 'web.telemetryVram': 'VRAM',
246
+ 'web.telemetryGpu': 'GPU {index}',
242
247
  };
243
248
 
package/src/i18n/es.js CHANGED
@@ -92,7 +92,7 @@ export default {
92
92
  // Shared by CLI and web
93
93
  'common.error': 'Error: {message}',
94
94
  'common.na': 'n/d',
95
- 'common.longPrompt': 'prompt largo (~{tokens} tokens)…',
95
+ 'common.longPrompt': 'prompt largo (~{tokens} tokens, 50% del contexto)…',
96
96
  'common.presetSaved': 'Preset guardado: {file}',
97
97
  'common.backups': 'Copias de seguridad: {list}',
98
98
 
@@ -154,7 +154,8 @@ Los presets se guardan en {presetsDir}`,
154
154
  'cli.confirmApply': '¿Aplicar y cargar el modelo?',
155
155
  'cli.dryRun': '--dry-run: no se aplicó nada ni se guardó preset.',
156
156
  'cli.pending': 'Para terminar ejecuta (pide contraseña de administrador):',
157
- 'cli.loaded': 'Modelo cargado en {engine}: {tps}',
157
+ 'cli.tuningModel': 'Ajustando {model} con {ctx} de contexto ({profile})…',
158
+ 'cli.loaded': 'Modelo cargado en {engine} con {ctx} de contexto: {tps}',
158
159
  'cli.usesModel': ' (usa el modelo {model})',
159
160
  'cli.loadFailed': 'No se pudo cargar: {error}',
160
161
  'cli.installMethod': 'Método de instalación:',
@@ -239,5 +240,9 @@ Los presets se guardan en {presetsDir}`,
239
240
  'web.themeDark': 'Oscuro',
240
241
  'web.settings': 'Configuración',
241
242
  'web.close': 'Cerrar',
243
+ 'web.telemetryCpu': 'CPU',
244
+ 'web.telemetryRam': 'RAM',
245
+ 'web.telemetryVram': 'VRAM',
246
+ 'web.telemetryGpu': 'GPU {index}',
242
247
  };
243
248