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,358 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import si from 'systeminformation';
4
+ import { run, which, exists, readJson, writeJsonWithBackup, fetchJson, PLATFORM, homeDir, sleep } from '../util.js';
5
+ import { modelMetaFromFile } from '../gguf.js';
6
+ import { runLlamaServerBenchmark } from '../llama-server.js';
7
+ import { runApiBenchmark } from '../benchmark.js';
8
+ import { TunerError, benchError } from '../errors.js';
9
+
10
+ // LM Studio internals used here were verified against LM Studio 0.4.x.
11
+ // The per-model config and hardware-config formats are not public API.
12
+
13
+ async function lmsHome() {
14
+ const pointer = path.join(homeDir(), '.lmstudio-home-pointer');
15
+ try {
16
+ const p = (await fs.readFile(pointer, 'utf8')).trim();
17
+ if (p && (await exists(p))) return p;
18
+ } catch { /* no pointer */ }
19
+ return path.join(homeDir(), '.lmstudio');
20
+ }
21
+
22
+ async function lmsBin(home) {
23
+ const exe = PLATFORM === 'win32' ? 'lms.exe' : 'lms';
24
+ return which('lms', [path.join(home, 'bin', exe)]);
25
+ }
26
+
27
+ const APP_PROCESS = /^(lm studio|lm-studio|lmstudio)(\.exe)?$/i;
28
+
29
+ function apiUrl() {
30
+ const h = process.env.LM_STUDIO_HOST;
31
+ if (!h) return 'http://127.0.0.1:1234';
32
+ return /^https?:\/\//.test(h) ? h.replace(/\/$/, '') : `http://${h}`.replace(/\/$/, '');
33
+ }
34
+
35
+ /** Variants of one `lms ls --json` entry: [{ key, quant, selected }]. */
36
+ export function lmsVariants(entry) {
37
+ if (!entry.variants?.length) {
38
+ return [{ key: entry.modelKey || entry.path, quant: entry.quantization?.name ?? null, selected: true }];
39
+ }
40
+ const variants = entry.variants.map((id) => {
41
+ const at = id.lastIndexOf('@');
42
+ return {
43
+ key: id,
44
+ quant: at === -1 ? null : id.slice(at + 1).toUpperCase(),
45
+ selected: id === entry.selectedVariant
46
+ };
47
+ });
48
+ if (!variants.some((v) => v.selected)) variants[0].selected = true;
49
+ return variants;
50
+ }
51
+
52
+ export const lmstudio = {
53
+ id: 'lmstudio',
54
+ name: 'LM Studio',
55
+ capabilities: { kvTypes: ['f16', 'q8_0', 'q4_0'], perModelKv: true, gpuOrder: true, cpuMoe: true, exactBenchmark: true, variantSelect: false },
56
+
57
+ async detect() {
58
+ const home = await lmsHome();
59
+ const hasHome = await exists(path.join(home, 'settings.json'));
60
+ const bin = await lmsBin(home);
61
+ const installed = hasHome || !!bin;
62
+ let version = null;
63
+ const hist = await readJson(path.join(home, '.internal', 'historical-version-info.json'));
64
+ if (hist) version = hist.lastUsedVersion || hist.version || JSON.stringify(hist).match(/\d+\.\d+\.\d+(?:[+-]\d+)?/)?.[0] || null;
65
+ const procs = await si.processes().catch(() => ({ list: [] }));
66
+ const appRunning = procs.list.some((p) => APP_PROCESS.test(p.name) && !/--type=/.test(p.params || ''));
67
+ const url = apiUrl();
68
+ const server = await fetchJson(`${url}/api/v0/models`, { timeout: 1500 });
69
+ return { installed, home, bin, version, appRunning, serverRunning: !!server?.ok, apiUrl: url };
70
+ },
71
+
72
+ async listModels(ctx) {
73
+ const { bin } = ctx.detection;
74
+ if (!bin) return [];
75
+ const r = await run(bin, ['ls', '--json']);
76
+ if (r.code !== 0) return [];
77
+ let list;
78
+ try { list = JSON.parse(r.stdout); } catch { return []; }
79
+ return list
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 || '') }));
82
+ },
83
+
84
+ /** Resolve the GGUF file behind a model key using LM Studio's model index. */
85
+ async resolveModelFile(ctx, key) {
86
+ const home = ctx.detection.home;
87
+ const idx = await readJson(path.join(home, '.internal', 'model-index-cache.json'), { models: [] });
88
+ const entries = idx.models || [];
89
+ const byId = (id) => entries.find((e) => e.indexedModelIdentifier === id && !e.virtual);
90
+ let entry = entries.find((e) => e.indexedModelIdentifier === key || e.defaultIdentifier === key || e.virtual?.baseChain?.[0] === key);
91
+ const concrete = entry?.virtual?.concreteModelIndexedModelIdentifier || entry?.virtual?.baseChain?.at?.(-1);
92
+ if (concrete) entry = byId(concrete) || entry;
93
+ if (entry?.containingDirAbsolutePath && /\.gguf$/i.test(entry.indexedModelIdentifier)) {
94
+ const f = path.join(entry.containingDirAbsolutePath, path.basename(entry.indexedModelIdentifier));
95
+ if (await exists(f)) return f;
96
+ }
97
+ // Fallback: search the downloads folder for a matching .gguf.
98
+ const settings = await readJson(path.join(home, 'settings.json'), {});
99
+ const root = settings.downloadsFolder || path.join(home, 'models');
100
+ const want = path.basename(concrete || key).toLowerCase();
101
+ const found = await findFile(root, (n) => n.toLowerCase().endsWith('.gguf') && (n.toLowerCase() === want || n.toLowerCase().includes(want.replace(/\.gguf$/, ''))), 4);
102
+ return found;
103
+ },
104
+
105
+ /** Downloaded variants of a model: [{ key, quant, sizeBytes, selected, file }]. */
106
+ async listVariants(ctx, key) {
107
+ const { bin } = ctx.detection;
108
+ if (!bin) return [];
109
+ const r = await run(bin, ['ls', '--json']);
110
+ if (r.code !== 0) return [];
111
+ let list;
112
+ try { list = JSON.parse(r.stdout); } catch { return []; }
113
+ // Valid JSON that is not a list of models (an object, null) means there is nothing to list.
114
+ if (!Array.isArray(list)) return [];
115
+ const entry = list.find((m) => m && typeof m === 'object' && m.type === 'llm' && m.format === 'gguf' && (m.modelKey || m.path) === key);
116
+ if (!entry) return [];
117
+ const results = [];
118
+ for (const v of lmsVariants(entry)) {
119
+ const file = await this.resolveModelFile(ctx, v.key);
120
+ const size = file ? (await fs.stat(file).catch(() => null))?.size : null;
121
+ if (size != null) {
122
+ results.push({ key: v.key, quant: v.quant, sizeBytes: size, selected: v.selected, file });
123
+ } else if (v.selected) {
124
+ results.push({ key: v.key, quant: v.quant, sizeBytes: entry.sizeBytes, selected: true, file: null });
125
+ }
126
+ }
127
+ return results;
128
+ },
129
+
130
+ async modelMeta(ctx, key) {
131
+ const file = await this.resolveModelFile(ctx, key);
132
+ if (!file) throw new TunerError('errors.ggufNotFound', { key });
133
+ return { file, ...(await modelMetaFromFile(file)) };
134
+ },
135
+
136
+ /** The llama-server binary of LM Studio's selected llama.cpp backend. */
137
+ async backend(ctx) {
138
+ const home = ctx.detection.home;
139
+ const prefs = await readJson(path.join(home, '.internal', 'backend-preferences-v1.json'), []);
140
+ const pref = prefs.find((p) => p.model_format === 'gguf');
141
+ const backendsDir = path.join(home, 'extensions', 'backends');
142
+ let dirName = pref ? `${pref.name}-${pref.version}` : null;
143
+ if (!dirName || !(await exists(path.join(backendsDir, dirName)))) {
144
+ const all = (await fs.readdir(backendsDir).catch(() => [])).filter((d) => d.startsWith('llama.cpp-')).sort();
145
+ dirName = all.at(-1) || null;
146
+ }
147
+ if (!dirName) return null;
148
+ const dir = path.join(backendsDir, dirName);
149
+ const server = path.join(dir, PLATFORM === 'win32' ? 'llama-server.exe' : 'llama-server');
150
+ const vendor = path.join(backendsDir, 'vendor');
151
+ const libDirs = [dir, ...(await fs.readdir(vendor).catch(() => [])).map((d) => path.join(vendor, d))];
152
+ return { name: pref?.name || dirName.replace(/-\d[\d.]*$/, ''), dir, server: (await exists(server)) ? server : null, libDirs };
153
+ },
154
+
155
+ /** Free VRAM before benchmarking: unload every model LM Studio has loaded. */
156
+ async prepare(ctx) {
157
+ if (ctx.detection.bin) await run(ctx.detection.bin, ['unload', '--all']);
158
+ await sleep(1500);
159
+ },
160
+
161
+ async benchmark(ctx, model, candidate, opts) {
162
+ const be = await this.backend(ctx);
163
+ if (be?.server) {
164
+ return runLlamaServerBenchmark({ server: be.server, libDirs: be.libDirs, cwd: be.dir, modelFile: model.meta.file, candidate, ...opts });
165
+ }
166
+ // Older LM Studio builds without a llama-server binary: load through lms.
167
+ return this.benchmarkViaLms(ctx, model, candidate, opts);
168
+ },
169
+
170
+ async benchmarkViaLms(ctx, model, candidate, opts) {
171
+ const bin = ctx.detection.bin;
172
+ const restore = await this.writeModelConfig(ctx, model.key, candidate);
173
+ try {
174
+ const args = ['load', model.key, '-y', '--identifier', 'llm-tuner-bench', '-c', String(candidate.ctx), '--gpu', candidate.fullOffload ? 'max' : String(candidate.gpuLayers / candidate.totalLayers)];
175
+ const r = await run(bin, args, { timeout: 600_000 });
176
+ if (r.code !== 0) return { ok: false, error: (r.stderr || r.stdout).slice(-400) };
177
+ return await runApiBenchmark({ kind: 'lmstudio', baseUrl: 'http://127.0.0.1:1234', model: 'llm-tuner-bench', ...opts });
178
+ } finally {
179
+ await run(bin, ['unload', 'llm-tuner-bench']);
180
+ if (restore.backup) await fs.copyFile(restore.backup, restore.file); else await fs.rm(restore.file, { force: true });
181
+ }
182
+ },
183
+
184
+ configPaths(ctx, key) {
185
+ const internal = path.join(ctx.detection.home, '.internal');
186
+ return {
187
+ modelConfig: path.join(internal, 'user-concrete-model-default-config', `${key}.json`),
188
+ hardwareConfig: path.join(internal, 'hardware-config.json'),
189
+ };
190
+ },
191
+
192
+ async writeModelConfig(ctx, key, c) {
193
+ const file = this.configPaths(ctx, key).modelConfig;
194
+ const cur = (await readJson(file)) || { preset: '', operation: { fields: [] }, load: { fields: [] } };
195
+ const fields = new Map((cur.load?.fields || []).map((f) => [f.key, f.value]));
196
+ const set = (k, v) => fields.set(k, v);
197
+ set('llm.load.contextLength', c.ctx);
198
+ set('llm.load.llama.acceleration.offloadRatio', c.fullOffload ? 1 : +(c.gpuLayers / c.totalLayers).toFixed(3));
199
+ set('llm.load.llama.flashAttention', c.flashAttention);
200
+ set('llm.load.llama.cpuThreadPoolSize', c.threads);
201
+ set('llm.load.llama.evalBatchSize', c.batch);
202
+ set('llm.load.llama.physicalBatchSize', c.ubatch);
203
+ set('llm.load.numParallelSessions', c.parallel);
204
+ set('llm.load.useUnifiedKvCache', false);
205
+ set('llm.load.offloadKVCacheToGpu', true);
206
+ set('llm.load.llama.kCacheQuantizationType', { checked: c.kvType !== 'f16', value: c.kvType });
207
+ set('llm.load.llama.vCacheQuantizationType', { checked: c.kvType !== 'f16', value: c.kvType });
208
+ if (c.cpuMoeLayers) set('llm.load.numCpuExpertLayersRatio', +(c.cpuMoeLayers / (c.totalLayers - 1)).toFixed(3));
209
+ const next = { ...cur, load: { ...(cur.load || {}), fields: [...fields].map(([key, value]) => ({ key, value })) } };
210
+ if (JSON.stringify(next) === JSON.stringify(cur)) return { file, backup: null, unchanged: true };
211
+ const backup = await writeJsonWithBackup(file, next);
212
+ return { file, backup };
213
+ },
214
+
215
+ /** hardware-config.json is superjson: {"json":[[backendName,{fields:[...]}]],"meta":{...}} */
216
+ async writeHardwareConfig(ctx, c) {
217
+ const file = this.configPaths(ctx, '').hardwareConfig;
218
+ const be = await this.backend(ctx);
219
+ const backendName = be?.name || 'llama.cpp';
220
+ const cur = (await readJson(file)) || { json: [], meta: { values: ['map'] } };
221
+ const entries = Array.isArray(cur.json) ? cur.json : [];
222
+ let entry = entries.find(([name]) => name === backendName);
223
+ if (!entry) { entry = [backendName, { fields: [] }]; entries.push(entry); }
224
+ const fields = new Map((entry[1].fields || []).map((f) => [f.key, f.value]));
225
+ // Only a benchmark-verified full offload may bypass LM Studio's conservative VRAM cap.
226
+ fields.set('load.gpuStrictVramCap', !c.fullOffload);
227
+ fields.set('llm.load.offloadKVCacheToGpu', true);
228
+ if (c.gpuOrder?.length > 1) {
229
+ const prev = fields.get('load.gpuSplitConfig') || {};
230
+ fields.set('load.gpuSplitConfig', { strategy: 'priorityOrder', disabledGpus: prev.disabledGpus || [], priority: c.gpuOrder, customRatio: prev.customRatio || [] });
231
+ }
232
+ entry[1] = { ...entry[1], fields: [...fields].map(([key, value]) => ({ key, value })) };
233
+ const backup = await writeJsonWithBackup(file, { json: entries, meta: cur.meta || { values: ['map'] } }, { indent: 0 });
234
+ return { file, backup };
235
+ },
236
+
237
+ async closeApp() {
238
+ const procs = await si.processes().catch(() => ({ list: [] }));
239
+ const main = procs.list.filter((p) => APP_PROCESS.test(p.name) && !/--type=/.test(p.params || ''));
240
+ if (PLATFORM === 'darwin') await run('osascript', ['-e', 'quit app "LM Studio"']);
241
+ else if (PLATFORM === 'win32') await run('taskkill', ['/IM', 'LM Studio.exe', '/T']);
242
+ else for (const p of main) { try { process.kill(p.pid, 'SIGINT'); } catch { /* gone */ } }
243
+ for (let i = 0; i < 15; i++) {
244
+ await sleep(1000);
245
+ const now = await si.processes().catch(() => ({ list: [] }));
246
+ if (!now.list.some((p) => APP_PROCESS.test(p.name))) return true;
247
+ }
248
+ // Electron may stay in the tray; a hard kill cannot save (and thus overwrite) settings.
249
+ if (PLATFORM === 'win32') await run('taskkill', ['/F', '/IM', 'LM Studio.exe', '/T']);
250
+ else for (const p of main) { try { process.kill(p.pid, 'SIGKILL'); } catch { /* gone */ } }
251
+ await sleep(2000);
252
+ return true;
253
+ },
254
+
255
+ async launchApp(ctx) {
256
+ const loc = await readJson(path.join(ctx.detection.home, '.internal', 'app-install-location.json'));
257
+ if (PLATFORM === 'darwin') return run('open', ['-a', 'LM Studio']);
258
+ if (!loc?.path) return null;
259
+ const { spawn } = await import('node:child_process');
260
+ const child = spawn(loc.path, loc.argv?.slice(1) || [], { cwd: loc.cwd, detached: true, stdio: 'ignore' });
261
+ child.unref();
262
+ for (let i = 0; i < 30; i++) {
263
+ await sleep(1000);
264
+ if (ctx.detection.bin && (await run(ctx.detection.bin, ['status'])).code === 0) break;
265
+ }
266
+ return true;
267
+ },
268
+
269
+ /** Does hardware-config.json already match what this candidate needs? */
270
+ async isHardwareApplied(ctx, c) {
271
+ const file = this.configPaths(ctx, '').hardwareConfig;
272
+ const be = await this.backend(ctx);
273
+ const cur = await readJson(file);
274
+ const entry = (cur?.json || []).find(([name]) => name === (be?.name || 'llama.cpp'));
275
+ const fields = new Map((entry?.[1]?.fields || []).map((f) => [f.key, f.value]));
276
+ if (fields.get('load.gpuStrictVramCap') !== !c.fullOffload) return false;
277
+ if (c.gpuOrder?.length > 1) {
278
+ const split = fields.get('load.gpuSplitConfig');
279
+ if (split?.strategy !== 'priorityOrder' || JSON.stringify(split.priority) !== JSON.stringify(c.gpuOrder)) return false;
280
+ }
281
+ return true;
282
+ },
283
+
284
+ /**
285
+ * Persist the chosen configuration. The per-model config is re-read by LM Studio
286
+ * on every load, so it can be written live. hardware-config.json can be
287
+ * overwritten by the app on exit, so the app is closed while it changes.
288
+ */
289
+ async apply(ctx, model, c, { closeApp = true, relaunch = true, dryRun = false } = {}) {
290
+ const paths = this.configPaths(ctx, model.key);
291
+ const hardwareOk = await this.isHardwareApplied(ctx, c);
292
+ const det = await this.detect();
293
+ const plan = { files: hardwareOk ? [paths.modelConfig] : [paths.modelConfig, paths.hardwareConfig], restartsApp: !hardwareOk && det.appRunning, candidate: c };
294
+ if (dryRun) return { dryRun: true, ...plan };
295
+ const backups = [];
296
+ const changes = [];
297
+ if (!hardwareOk) {
298
+ if (det.appRunning) {
299
+ if (!closeApp) throw new TunerError('errors.lmstudioOpen');
300
+ await this.closeApp();
301
+ }
302
+ const b = await this.writeHardwareConfig(ctx, c);
303
+ if (b.backup) backups.push(b.backup);
304
+ changes.push({ code: 'changes.lmsHardware' });
305
+ }
306
+ const a = await this.writeModelConfig(ctx, model.key, c);
307
+ if (a.backup) backups.push(a.backup);
308
+ if (!a.unchanged) changes.push(c.fullOffload
309
+ ? { code: 'changes.lmsModelFull', params: { ctx: c.ctx, kvType: c.kvType, threads: c.threads } }
310
+ : { code: 'changes.lmsModelPartial', params: { ctx: c.ctx, kvType: c.kvType, gpuLayers: c.gpuLayers, totalLayers: c.totalLayers, threads: c.threads } });
311
+ if (!hardwareOk && relaunch && det.appRunning) {
312
+ await this.launchApp(ctx);
313
+ changes.push({ code: 'changes.lmsRestarted' });
314
+ }
315
+ return { ...plan, backups, changes };
316
+ },
317
+
318
+ /** Load the model the normal way (using the saved config) and optionally measure it. */
319
+ async load(ctx, model, { depthTokens = 0, genTokens = 200, onProgress } = {}) {
320
+ const bin = ctx.detection.bin;
321
+ if (!bin) return benchError('bench.lmsUnavailable');
322
+ await run(bin, ['server', 'start']);
323
+ await run(bin, ['unload', '--all']);
324
+ onProgress?.({ phase: 'load' });
325
+ const r = await run(bin, ['load', model.key, '-y'], { timeout: 600_000 });
326
+ if (r.code !== 0) return { ok: false, error: (r.stderr || r.stdout).slice(-400) };
327
+ const url = ctx.detection?.apiUrl || apiUrl();
328
+ return runApiBenchmark({ kind: 'lmstudio', baseUrl: url, model: model.key, depthTokens, genTokens, onProgress });
329
+ },
330
+
331
+ async chat(ctx, model, messages) {
332
+ const url = ctx.detection?.apiUrl || apiUrl();
333
+ const r = await fetchJson(`${url}/v1/chat/completions`, {
334
+ method: 'POST',
335
+ body: { model, messages, stream: false },
336
+ timeout: 120_000,
337
+ });
338
+ if (!r?.ok) throw new Error(r?.json?.error?.message || r?.json?.error || 'Failed to chat with LM Studio');
339
+ const msg = r.json.choices?.[0]?.message;
340
+ if (!msg) throw new Error('No response message from LM Studio');
341
+ return { message: msg };
342
+ },
343
+ };
344
+
345
+
346
+ async function findFile(root, pred, depth) {
347
+ if (depth < 0) return null;
348
+ let entries;
349
+ try { entries = await fs.readdir(root, { withFileTypes: true }); } catch { return null; }
350
+ for (const e of entries) if (e.isFile() && pred(e.name)) return path.join(root, e.name);
351
+ for (const e of entries) {
352
+ if (e.isDirectory()) {
353
+ const f = await findFile(path.join(root, e.name), pred, depth - 1);
354
+ if (f) return f;
355
+ }
356
+ }
357
+ return null;
358
+ }