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,303 @@
1
+ #!/usr/bin/env node
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';
4
+ import { t as translate, LOCALES, errorText } from '../i18n/index.js';
5
+ import { profileChoices, profileNotes, profileSwitchNote, profileEventLines, shouldAskProfile } from './profile.js';
6
+
7
+ const args = parseArgs(process.argv.slice(2));
8
+ let { lang, theme } = await readSettings();
9
+ const t = (key, params) => translate(lang, key, params);
10
+ if ('lang' in args) {
11
+ if (!LOCALES.includes(args.lang)) {
12
+ console.error(translate('en', 'errors.unknownLocale', { lang: args.lang ?? '', list: LOCALES.join(', ') }));
13
+ process.exit(1);
14
+ }
15
+ lang = args.lang;
16
+ try { await writeSettings({ lang }); } catch (e) { console.error(c.y(t('cli.langNotSaved', { error: e.message }))); }
17
+ }
18
+ if ('theme' in args) {
19
+ if (!THEMES.includes(args.theme)) {
20
+ console.error(translate(lang, 'errors.unknownTheme', { theme: args.theme ?? '', list: THEMES.join(', ') }));
21
+ process.exit(1);
22
+ }
23
+ theme = args.theme;
24
+ try { await writeSettings({ theme }); } catch (e) { console.error(c.y(t('cli.themeNotSaved', { error: e.message }))); }
25
+ }
26
+ if ('profile' in args) {
27
+ if (!PROFILES.includes(args.profile)) {
28
+ console.error(translate(lang, 'errors.unknownProfile', { profile: args.profile ?? '', list: PROFILES.join(', ') }));
29
+ process.exit(1);
30
+ }
31
+ }
32
+
33
+ const isLight = () => theme === 'light';
34
+ const c = {
35
+ b: (s) => `\x1b[1m${s}\x1b[0m`,
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`,
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`,
41
+ };
42
+
43
+ if (args.help) {
44
+ console.log(t('cli.help', { presetsDir: presetsDir() }));
45
+ process.exit(0);
46
+ }
47
+
48
+ if (args.settings) {
49
+ try {
50
+ await settingsFlow();
51
+ process.exit(0);
52
+ } catch (e) {
53
+ if (e?.name === 'ExitPromptError') process.exit(130);
54
+ console.error(c.r(`\n${t('common.error', { message: errorText(lang, e) })}`));
55
+ process.exit(1);
56
+ }
57
+ } else if (args.presets) {
58
+ const list = await listPresets({ engine: args.engine, modelKey: args.model });
59
+ if (!list.length) console.log(t('cli.noPresets'));
60
+ for (const p of list) console.log(`${p.engine.padEnd(9)} ${p.model} ${p.ctx / 1024}K${p.profile ? ` ${p.profile}${p.variant && p.variant !== p.model ? ` (${p.variant})` : ''}` : ''} KV ${p.kvType} ${p.fullOffload ? t('candidate.allGpu') : t('cli.partial')} ${p.shortTps} t/s ${c.dim(p.createdAt.slice(0, 16).replace('T', ' '))}`);
61
+ } else {
62
+ main().catch((e) => {
63
+ if (e?.name === 'ExitPromptError') process.exit(130);
64
+ console.error(c.r(`\n${t('common.error', { message: errorText(lang, e) })}`));
65
+ process.exit(1);
66
+ });
67
+ }
68
+
69
+ async function settingsFlow() {
70
+ while (true) {
71
+ const action = await select({
72
+ message: t('cli.settingsMenu'),
73
+ choices: [
74
+ { name: `${t('cli.settingsLanguage')}: ${lang === 'es' ? 'Español' : 'English'} (${lang})`, value: 'lang' },
75
+ { name: `${t('cli.settingsTheme')}: ${theme.charAt(0).toUpperCase() + theme.slice(1)}`, value: 'theme' },
76
+ { name: t('cli.settingsExit'), value: 'exit' },
77
+ ],
78
+ });
79
+
80
+ if (action === 'lang') {
81
+ lang = await select({
82
+ message: t('cli.settingsLanguage'),
83
+ default: lang,
84
+ choices: [
85
+ { name: 'English', value: 'en' },
86
+ { name: 'Español', value: 'es' },
87
+ ],
88
+ });
89
+ try {
90
+ await writeSettings({ lang });
91
+ } catch (e) {
92
+ console.error(c.y(t('cli.langNotSaved', { error: e.message })));
93
+ }
94
+ } else if (action === 'theme') {
95
+ theme = await select({
96
+ message: t('cli.settingsTheme'),
97
+ default: theme,
98
+ choices: [
99
+ { name: 'System', value: 'system' },
100
+ { name: 'Light', value: 'light' },
101
+ { name: 'Dark', value: 'dark' },
102
+ ],
103
+ });
104
+ try {
105
+ await writeSettings({ theme });
106
+ } catch (e) {
107
+ console.error(c.y(t('cli.themeNotSaved', { error: e.message })));
108
+ }
109
+ } else if (action === 'exit') {
110
+ console.log(c.g(t('cli.settingsSaved')));
111
+ break;
112
+ }
113
+ }
114
+ }
115
+
116
+ async function main() {
117
+ const out = (...a) => { if (!args.json) console.log(...a); };
118
+ out([
119
+ '',
120
+ ` ${c.cyan(' ╭───')}${c.dim('╷ ╷ ╷')}${c.cyan('───╮')}`,
121
+ ` ${c.cyan('╭─┤ ')}${c.cyan('┌───┐')}${c.cyan(' ├─╮ ')}${c.b('LLM Tuner')}`,
122
+ ` ${c.cyan('│ ')}${c.dim('╶┤')}${c.b(' │ █ │ ')}${c.dim('├╴')}${c.cyan('▲ │ ')}${c.dim(t('web.tagline'))}`,
123
+ ` ${c.cyan('╰─┤ ')}${c.cyan('└───┘')}${c.cyan(' ├──╯')}`,
124
+ ` ${c.cyan(' ╰───')}${c.dim('╵ ╵ ╵')}${c.cyan('───╯')}`,
125
+ '',
126
+ ].join('\n'));
127
+
128
+ const hw = await detectHardware();
129
+ out(c.dim(` ${hw.cpu.brand} · ${fmtBytes(hw.ram.totalBytes)} RAM`));
130
+ 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') : ''}`));
131
+ if (!hw.gpus.length) out(c.y(` ${t('cli.noGpu')}`));
132
+
133
+ // 1. Engine
134
+ let det = await detectEngines(hw);
135
+ let engineId = args.engine;
136
+ if (!engineId) {
137
+ engineId = await select({
138
+ message: t('cli.whichEngine'),
139
+ default: det.recommended,
140
+ choices: det.engines.map((e) => ({
141
+ name: `${e.name} ${e.detection.installed ? c.g(t('cli.installed')) + (e.detection.version ? c.dim(' v' + e.detection.version) : '') : c.dim(t('cli.notInstalled'))}${e.id === det.recommended ? c.y(` ${t('cli.recommended')}`) : ''}`,
142
+ value: e.id,
143
+ })),
144
+ });
145
+ }
146
+ if (!det.engines.find((e) => e.id === engineId)?.detection.installed) {
147
+ await installFlow(engineId);
148
+ det = await detectEngines(hw);
149
+ if (!det.engines.find((e) => e.id === engineId).detection.installed) throw new TunerError('errors.installNotFinished');
150
+ }
151
+ const tuner = await Tuner.create(engineId);
152
+
153
+ // 2. Model
154
+ let models = await tuner.listModels();
155
+ if (!models.length) {
156
+ out(c.y(`\n${t('cli.noModels', { engine: tuner.engine.name })}`));
157
+ const name = await input({ message: engineId === 'ollama' ? t('cli.downloadOllama') : t('cli.downloadLms') });
158
+ const bin = tuner.ctx.detection.bin;
159
+ const r = await runInstall(engineId === 'ollama' ? { cmd: bin, args: ['pull', name] } : { cmd: bin, args: ['get', name, '-y'] }, (l) => out(c.dim(' ' + l)));
160
+ if (r.code !== 0) throw new TunerError('errors.modelDownloadFailed');
161
+ models = await tuner.listModels();
162
+ }
163
+ const modelKey = args.model || await search({
164
+ message: t('cli.model'),
165
+ source: (term) => models
166
+ .filter((m) => !term || `${m.key} ${m.name}`.toLowerCase().includes(term.toLowerCase()))
167
+ .map((m) => ({ name: `${m.name} ${c.dim(`${fmtBytes(m.sizeBytes)}${m.quant ? ' · ' + m.quant : ''}`)}`, value: m.key })),
168
+ });
169
+
170
+ // 3. Context
171
+ let ctx = Number(args.ctx) || null;
172
+ if (!ctx) {
173
+ const presets = await listPresets({ engine: engineId, modelKey });
174
+ const preview = await tuner.plan(modelKey, 8192);
175
+ const limit = preview.model.meta.trainContext || 131072;
176
+ const list = Object.entries(preview.maxContext).map(([k, v]) => `KV ${k} ${v ? Math.floor(v / 1024) + 'K' : '—'}`).join(' · ');
177
+ out(c.dim(` ${t('cli.maxFullGpu', { list, train: Math.floor(limit / 1024) })}`));
178
+ 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({
180
+ message: t('cli.context'),
181
+ default: String(presets.at(-1)?.ctx || Math.min(16384, limit)),
182
+ validate: (v) => (Number(v) >= 512 && Number(v) <= limit) || t('cli.contextRange', { limit }),
183
+ }));
184
+ }
185
+
186
+ // 4. Profile
187
+ let profile = args.profile;
188
+ if (!profile && !shouldAskProfile(args)) {
189
+ profile = DEFAULT_PROFILE; // Non-interactive and fully flagged runs keep the behaviour scripts already rely on.
190
+ }
191
+ // The wizard gives the switch advice with its prompt; a profile that comes from a flag gets it with the load events.
192
+ const advisedByWizard = !profile;
193
+ if (!profile) {
194
+ const plan = await tuner.profilePlan(modelKey, ctx);
195
+ const { profile: stored } = await readSettings();
196
+ for (const line of profileNotes(plan, t)) {
197
+ out((line.level === 'warn' ? c.y : c.dim)(` ${line.text}`));
198
+ }
199
+ profile = await select({
200
+ message: t('cli.profile'),
201
+ ...profileChoices(plan, t, { stored, mark: c.y, dim: c.dim }),
202
+ });
203
+ try {
204
+ await writeSettings({ profile });
205
+ } catch (e) {
206
+ console.error(c.y(t('cli.profileNotSaved', { error: e.message })));
207
+ }
208
+ const switchNote = profileSwitchNote(plan, profile, t, tuner.engine.name);
209
+ if (switchNote) out(c.dim(` ${switchNote}`));
210
+ }
211
+
212
+ // 5. Load (preset or benchmark)
213
+ // Progress event before the current one: a fallback that repeats its reason is not printed twice.
214
+ let previous = null;
215
+ tuner.on('progress', (e) => {
216
+ if (args.json) return;
217
+ if (e.type === 'variant-picked' || e.type === 'profile-fallback') {
218
+ for (const line of profileEventLines(e, previous, t, advisedByWizard ? null : tuner.engine.name)) {
219
+ out((line.level === 'warn' ? c.y : c.dim)(` ${line.text}`));
220
+ }
221
+ }
222
+ previous = e;
223
+ if (e.type === 'status') out(c.dim(`\n${t(e.code, e.params)}`));
224
+ if (e.type === 'preset-hit') out(c.g(`\n✓ ${t('cli.presetHit', { date: e.preset.createdAt.slice(0, 10), config: describe(e.preset.candidate), tps: e.preset.bench.short.genTps })}`));
225
+ if (e.type === 'candidate-start') out(` [${e.index + 1}/${e.total}] ${describe(e.candidate)}`);
226
+ if (e.type === 'bench-progress' && e.phase === 'deep') out(c.dim(` ${t('common.longPrompt', { tokens: e.promptTokens })}`));
227
+ if (e.type === 'candidate-done') {
228
+ const b = e.bench;
229
+ 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
+ }
231
+ if (e.type === 'preset-saved') out(c.g(`\n✓ ${t('common.presetSaved', { file: e.file })}`));
232
+ });
233
+
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
+ },
250
+ });
251
+
252
+ if (args['dry-run']) out(c.y(`\n${t('cli.dryRun')}`));
253
+ if (result.applied?.backups?.length) out(c.dim(` ${t('common.backups', { list: result.applied.backups.join(', ') })}`));
254
+ if (result.applied?.pendingCommands?.length) {
255
+ out(c.y(`\n${t('cli.pending')}`));
256
+ result.applied.pendingCommands.forEach((cmd) => out(` ${cmd}`));
257
+ }
258
+ const l = result.loaded;
259
+ if (l) out(l.ok
260
+ ? c.g(`\n✓ ${t('cli.loaded', { engine: tuner.engine.name, tps: c.b(l.short.genTps + ' t/s') })}${l.model ? t('cli.usesModel', { model: l.model }) : ''}`)
261
+ : c.r(`\n✗ ${t('cli.loadFailed', { error: benchErrorText(l) })}`));
262
+ if (args.json) console.log(JSON.stringify(result, null, 2));
263
+ }
264
+
265
+ async function installFlow(engineId) {
266
+ const plans = await installPlans(engineId);
267
+ if (!plans.length) throw new TunerError('errors.noInstaller');
268
+ const plan = plans.length === 1 ? plans[0] : await select({ message: t('cli.installMethod'), choices: plans.map((p) => ({ name: t(p.labelCode), value: p })) });
269
+ console.log(c.dim(` ${plan.shell || (plan.cmd ? [plan.cmd, ...plan.args].join(' ') : plan.url)}`));
270
+ if (!args.yes && !(await confirm({ message: t('cli.installNow'), default: true }))) process.exit(0);
271
+ const r = await runInstall(plan, (l) => console.log(c.dim(' ' + l)));
272
+ if (r.manual) await input({ message: t('cli.installManual') });
273
+ else if (r.code !== 0) throw new TunerError('errors.installerExit', { code: r.code });
274
+ }
275
+
276
+ function describe(cand) {
277
+ const where = cand.fullOffload ? t('candidate.allGpu') : t('candidate.layersGpu', { gpu: cand.gpuLayers, total: cand.totalLayers });
278
+ return `KV ${cand.kvType} · ${where}${cand.cpuMoeLayers ? t('candidate.expertsRam', { layers: cand.cpuMoeLayers }) : ''}${t('candidate.threads', { n: cand.threads })}${cand.probe ? t('candidate.probe') : ''}`;
279
+ }
280
+
281
+ function benchErrorText(b) {
282
+ return b.errorCode ? t(b.errorCode, b.errorParams) : b.error;
283
+ }
284
+
285
+ function vramStr(peaks = {}) {
286
+ const e = Object.entries(peaks || {});
287
+ return e.length ? e.map(([i, b]) => `GPU${i} ${(b / 1024 ** 3).toFixed(1)} GB`).join(' / ') : t('common.na');
288
+ }
289
+
290
+ function parseArgs(argv) {
291
+ const flags = new Set(['help', 'yes', 'dry-run', 'json', 'force', 'presets', 'settings']);
292
+ const o = {};
293
+ for (let i = 0; i < argv.length; i++) {
294
+ const a = argv[i];
295
+ if (a === '-h') o.help = true;
296
+ if (a === '/settings' || a === '/setting' || a === 'settings') o.settings = true;
297
+ if (!a.startsWith('--')) continue;
298
+ const k = a.slice(2);
299
+ if (k === 'setting') { o.settings = true; continue; }
300
+ o[k] = flags.has(k) ? true : argv[++i];
301
+ }
302
+ return o;
303
+ }
@@ -0,0 +1,74 @@
1
+ // Pure helpers of the CLI profile step: no top-level side effects, so the tests import them without starting the wizard.
2
+ import { PROFILES, fmtBytes } from '../core/index.js';
3
+
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
+ export const FALLBACK_REASONS = new Set(['profile.variant.noFullGpu']);
6
+
7
+ /** True when the CLI has to ask for the load profile; `args` are the parsed flags. */
8
+ export function shouldAskProfile(args) {
9
+ if (args.profile || args.yes || args.json) return false;
10
+ // 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));
12
+ return !flagged;
13
+ }
14
+
15
+ /** Choices and preselection for the profile prompt, from a `Tuner.profilePlan()` result. */
16
+ export function profileChoices(plan, t, { stored = null, mark = (s) => s, dim = (s) => s } = {}) {
17
+ const choices = PROFILES.map((profile) => {
18
+ const info = plan.profiles[profile];
19
+ // What the engine will load: the selected variant when it cannot switch to the profile's pick.
20
+ const variant = plan.variants.find((v) => v.key === info.loads);
21
+ const quant = variant?.quant ?? null;
22
+ const uses = quant ? `${quant} · KV ${info.kvTypes.join(', ')}` : `KV ${info.kvTypes.join(', ')}`;
23
+ const name = `${t(`profile.name.${profile}`)} ${dim(`${t(`profile.desc.${profile}`)} · ${uses}`)}`;
24
+ const isRecommended = profile === plan.recommended.profile;
25
+ return {
26
+ name: isRecommended ? `${name}${mark(` ${t('cli.recommended')}`)}` : name,
27
+ value: profile,
28
+ short: t(`profile.name.${profile}`),
29
+ };
30
+ });
31
+ return { choices, default: stored ?? plan.recommended.profile };
32
+ }
33
+
34
+ /** Lines shown with the profile prompt, as `{ text, level }` with level `note` or `warn`: why a profile is recommended, a warning when the preview is conservative and which heavier variants would fit. */
35
+ export function profileNotes(plan, t) {
36
+ const notes = [{ text: t(plan.recommended.reasonCode), level: 'note' }];
37
+ if (plan.vramBusy) {
38
+ // The plan was made with the VRAM that is free now; a real load frees it first.
39
+ notes.push({ text: t('profile.preview.vramBusy'), level: 'warn' });
40
+ }
41
+ for (const h of plan.hints) {
42
+ notes.push({ text: t('cli.profileHint', { quant: h.quant, size: fmtBytes(h.estimatedBytes) }), level: 'note' });
43
+ }
44
+ return notes;
45
+ }
46
+
47
+ /** Advice to select another variant in the engine, or null when the profile loads its own pick. */
48
+ export function profileSwitchNote(plan, profile, t, engine) {
49
+ const info = plan.profiles[profile];
50
+ if (!info?.recommendSwitch) return null;
51
+ return t('cli.profileSwitch', { quant: info.quant ?? info.variant, engine });
52
+ }
53
+
54
+ /** Lines to print for a `variant-picked` or `profile-fallback` event, as `{ text, level }` with level `note` or `warn`; `previous` is the event before it and `engine` the engine name, or null to leave the switch advice out. */
55
+ export function profileEventLines(event, previous, t, engine = null) {
56
+ const { variant } = event;
57
+ // Nothing to say without the variant; throwing here would reject the load from inside the progress listener.
58
+ if (!variant) return [];
59
+ const isFallback = event.type === 'profile-fallback';
60
+ const lines = [];
61
+
62
+ // A fallback that repeats the reason of the line before it is not printed again.
63
+ if (!(isFallback && event.reasonCode === previous?.reasonCode)) {
64
+ const level = isFallback || FALLBACK_REASONS.has(event.reasonCode) ? 'warn' : 'note';
65
+ lines.push({ text: t(event.reasonCode, { quant: variant.quant ?? '?', key: variant.key }), level });
66
+ }
67
+
68
+ // The engine cannot load the pick itself: tell the user which variant to select there.
69
+ if (event.type === 'variant-picked' && event.recommendSwitch && engine) {
70
+ lines.push({ text: t('cli.profileSwitch', { quant: variant.quant ?? variant.key, engine }), level: 'note' });
71
+ }
72
+
73
+ return lines;
74
+ }
@@ -0,0 +1,101 @@
1
+ import { fetchJson, cpuSnapshot, cpuBusyPercent } from './util.js';
2
+ import { startVramSampler } from './hardware.js';
3
+
4
+ // Deterministic pseudo-random generator so every run gets the same prompt.
5
+ function rng(seed) {
6
+ let s = seed >>> 0;
7
+ return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32);
8
+ }
9
+
10
+ const WORDS = ['user', 'order', 'cache', 'item', 'queue', 'token', 'config', 'buffer', 'session', 'payload', 'record', 'index', 'stream', 'result', 'handler', 'worker', 'price', 'account', 'event', 'metric'];
11
+ const VERBS = ['load', 'parse', 'build', 'update', 'validate', 'compute', 'merge', 'flush', 'resolve', 'render', 'fetch', 'encode'];
12
+
13
+ /**
14
+ * Synthetic but realistic-looking source code, roughly `tokens` long.
15
+ * Code tokenizes at ~3.3 chars/token on modern BPE vocabularies.
16
+ */
17
+ export function makeCodePrompt(tokens, charsPerToken = 3.3, seed = 42) {
18
+ const r = rng(seed);
19
+ const pick = (a) => a[Math.floor(r() * a.length)];
20
+ const target = Math.max(64, Math.floor(tokens * charsPerToken));
21
+ let out = '// Module under review. Read it carefully; questions follow at the end.\n\n';
22
+ let n = 0;
23
+ while (out.length < target) {
24
+ const a = pick(WORDS), b = pick(WORDS), v = pick(VERBS);
25
+ const fn = `${v}${a[0].toUpperCase()}${a.slice(1)}${n++}`;
26
+ out += `export function ${fn}(${a}s, options = {}) {\n` +
27
+ ` const ${b}Map = new Map();\n` +
28
+ ` for (const ${a} of ${a}s) {\n` +
29
+ ` if (!${a} || ${a}.${b}Id == null) continue;\n` +
30
+ ` const key = \`\${${a}.${b}Id}:\${options.${pick(WORDS)} ?? ${Math.floor(r() * 1000)}}\`;\n` +
31
+ ` const prev = ${b}Map.get(key) ?? { count: 0, total: 0 };\n` +
32
+ ` ${b}Map.set(key, { count: prev.count + 1, total: prev.total + (${a}.${pick(WORDS)} || 0) * ${(r() * 10).toFixed(2)} });\n` +
33
+ ` }\n return [...${b}Map.entries()].filter(([, v]) => v.count > ${Math.floor(r() * 5)});\n}\n\n`;
34
+ }
35
+ return out.slice(0, target) + '\n}\n\n// Task: explain what the last function does, then write an optimized version of it with comments.\n';
36
+ }
37
+
38
+ /** Sample CPU and VRAM while `fn` runs. */
39
+ export async function withSampling(fn) {
40
+ const vram = startVramSampler(500);
41
+ const samples = [];
42
+ let prev = cpuSnapshot();
43
+ const timer = setInterval(() => { const now = cpuSnapshot(); samples.push(cpuBusyPercent(prev, now)); prev = now; }, 500);
44
+ try {
45
+ const result = await fn();
46
+ return { result, cpu: summarize(samples), vramPeakBytes: vram.stop() };
47
+ } finally {
48
+ clearInterval(timer);
49
+ vram.stop();
50
+ }
51
+ }
52
+
53
+ const summarize = (s) => (s.length ? { avg: +(s.reduce((a, b) => a + b, 0) / s.length).toFixed(1), max: +Math.max(...s).toFixed(1) } : { avg: null, max: null });
54
+
55
+ /**
56
+ * Benchmark a model already served by LM Studio or Ollama through its HTTP API.
57
+ * Runs a short prompt (pure decode speed) and a deep one (speed with a full context).
58
+ */
59
+ export async function runApiBenchmark({ kind, baseUrl, model, depthTokens = 4000, genTokens = 200, ollamaOptions = {}, onProgress = () => {} }) {
60
+ const one = async (label, promptTokens) => {
61
+ onProgress({ phase: label, promptTokens });
62
+ const prompt = makeCodePrompt(promptTokens);
63
+ if (kind === 'ollama') {
64
+ const res = await fetchJson(`${baseUrl}/api/generate`, {
65
+ method: 'POST', timeout: 1_800_000,
66
+ body: { model, prompt, raw: true, stream: false, options: { ...ollamaOptions, num_predict: genTokens, temperature: 0.2 } },
67
+ });
68
+ const d = res?.json;
69
+ if (!res?.ok || !d?.eval_count) return { ok: false, error: d?.error || res?.text?.slice(0, 300) || 'no response' };
70
+ return {
71
+ ok: true,
72
+ promptTokens: d.prompt_eval_count,
73
+ genTokens: d.eval_count,
74
+ genTps: +(d.eval_count / (d.eval_duration / 1e9)).toFixed(1),
75
+ ppTps: d.prompt_eval_duration ? +(d.prompt_eval_count / (d.prompt_eval_duration / 1e9)).toFixed(0) : null,
76
+ };
77
+ }
78
+ const res = await fetchJson(`${baseUrl}/api/v0/chat/completions`, {
79
+ method: 'POST', timeout: 1_800_000,
80
+ body: { model, messages: [{ role: 'user', content: prompt }], max_tokens: genTokens, temperature: 0.2, stream: false },
81
+ });
82
+ const d = res?.json;
83
+ if (!res?.ok || !d?.stats) return { ok: false, error: d?.error?.message || d?.error || res?.text?.slice(0, 300) || 'no response' };
84
+ return {
85
+ ok: true,
86
+ promptTokens: d.usage?.prompt_tokens,
87
+ genTokens: d.usage?.completion_tokens,
88
+ genTps: +d.stats.tokens_per_second.toFixed(1),
89
+ ppTps: d.stats.time_to_first_token ? +(d.usage.prompt_tokens / d.stats.time_to_first_token).toFixed(0) : null,
90
+ };
91
+ };
92
+
93
+ const { result, cpu, vramPeakBytes } = await withSampling(async () => {
94
+ const short = await one('short', 200);
95
+ if (!short.ok) return { short };
96
+ const deep = depthTokens > 400 ? await one('deep', depthTokens) : null;
97
+ return { short, deep };
98
+ });
99
+ const ok = result.short.ok && (!result.deep || result.deep.ok);
100
+ return { ok, error: ok ? null : (result.deep?.error || result.short.error), short: result.short, deep: result.deep, cpu, vramPeakBytes };
101
+ }