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.
- package/LICENSE +21 -0
- package/README.es.md +123 -0
- package/README.md +123 -0
- package/electron/icon.ico +0 -0
- package/electron/icon.png +0 -0
- package/electron/launch.js +85 -0
- package/electron/main.js +63 -0
- package/package.json +48 -0
- package/src/cli/index.js +303 -0
- package/src/cli/profile.js +74 -0
- package/src/core/benchmark.js +101 -0
- package/src/core/engines/lmstudio.js +358 -0
- package/src/core/engines/ollama.js +335 -0
- package/src/core/errors.js +16 -0
- package/src/core/estimator.js +237 -0
- package/src/core/gguf.js +187 -0
- package/src/core/hardware.js +162 -0
- package/src/core/index.js +10 -0
- package/src/core/installer.js +59 -0
- package/src/core/llama-server.js +107 -0
- package/src/core/presets.js +112 -0
- package/src/core/profiles.js +137 -0
- package/src/core/settings.js +44 -0
- package/src/core/tuner.js +305 -0
- package/src/core/util.js +148 -0
- package/src/i18n/core.js +11 -0
- package/src/i18n/en.js +243 -0
- package/src/i18n/es.js +243 -0
- package/src/i18n/index.js +23 -0
- package/src/web/public/app.js +551 -0
- package/src/web/public/favicon.ico +0 -0
- package/src/web/public/icon.png +0 -0
- package/src/web/public/index.html +131 -0
- package/src/web/public/profile.js +100 -0
- package/src/web/public/style.css +330 -0
- package/src/web/server.js +205 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { detectHardware, detectEngines, Tuner, ENGINES, installPlans, runInstall, slim, listPresets, readSettings, writeSettings, TunerError, PROFILES } from '../core/index.js';
|
|
7
|
+
import { THEMES } from '../core/settings.js';
|
|
8
|
+
import { LOCALES, messagesFor } from '../i18n/index.js';
|
|
9
|
+
import { openUrl } from '../core/installer.js';
|
|
10
|
+
|
|
11
|
+
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), 'public');
|
|
12
|
+
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml' };
|
|
13
|
+
const I18N_CORE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'i18n', 'core.js');
|
|
14
|
+
|
|
15
|
+
/** JSON body for an error; TunerErrors carry a code the browser translates. */
|
|
16
|
+
function errorBody(e) {
|
|
17
|
+
return e instanceof TunerError ? { error: e.message, code: e.code, params: e.params } : { error: String(e?.message || e) };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// A job is a long-running task (install, tune, apply) whose events are streamed over SSE.
|
|
21
|
+
const jobs = new Map();
|
|
22
|
+
function createJob() {
|
|
23
|
+
const job = { id: randomUUID(), events: [], listeners: new Set(), done: false };
|
|
24
|
+
job.emit = (event) => {
|
|
25
|
+
job.events.push(event);
|
|
26
|
+
for (const l of job.listeners) l(event);
|
|
27
|
+
};
|
|
28
|
+
job.finish = (event) => { job.emit({ type: 'finished', ...event }); job.done = true; };
|
|
29
|
+
jobs.set(job.id, job);
|
|
30
|
+
return job;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let busy = false;
|
|
34
|
+
|
|
35
|
+
/** The load profile a request names, or a TunerError the browser translates. */
|
|
36
|
+
function knownProfile(value) {
|
|
37
|
+
if (!PROFILES.includes(value)) throw new TunerError('errors.unknownProfile', { profile: String(value ?? ''), list: PROFILES.join(', ') });
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function body(req) {
|
|
42
|
+
let data = '';
|
|
43
|
+
for await (const chunk of req) data += chunk;
|
|
44
|
+
return data ? JSON.parse(data) : {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function send(res, status, obj) {
|
|
48
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
49
|
+
res.end(JSON.stringify(obj));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const routes = {
|
|
53
|
+
'GET /api/settings': async () => ({ ...(await readSettings()), locales: LOCALES, themes: THEMES, profiles: PROFILES }),
|
|
54
|
+
|
|
55
|
+
'POST /api/settings': async (req) => {
|
|
56
|
+
const payload = await body(req);
|
|
57
|
+
if (payload?.lang === undefined && payload?.theme === undefined && payload?.profile === undefined) throw new TunerError('errors.unknownLocale', { lang: '', list: LOCALES.join(', ') });
|
|
58
|
+
const patch = {};
|
|
59
|
+
if (payload.lang !== undefined) {
|
|
60
|
+
if (!LOCALES.includes(payload.lang)) throw new TunerError('errors.unknownLocale', { lang: String(payload.lang ?? ''), list: LOCALES.join(', ') });
|
|
61
|
+
patch.lang = payload.lang;
|
|
62
|
+
}
|
|
63
|
+
if (payload.theme !== undefined) {
|
|
64
|
+
if (!THEMES.includes(payload.theme)) throw new TunerError('errors.unknownTheme', { theme: String(payload.theme ?? ''), list: THEMES.join(', ') });
|
|
65
|
+
patch.theme = payload.theme;
|
|
66
|
+
}
|
|
67
|
+
if (payload.profile !== undefined) patch.profile = knownProfile(payload.profile);
|
|
68
|
+
return { ...(await writeSettings(patch)), locales: LOCALES, themes: THEMES, profiles: PROFILES };
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
'GET /api/state': async () => {
|
|
72
|
+
const hw = await detectHardware();
|
|
73
|
+
const engines = await detectEngines(hw);
|
|
74
|
+
return { hw, ...engines, busy };
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
'GET /api/install-plans': async (req, url) => ({ plans: await installPlans(url.searchParams.get('engine')) }),
|
|
78
|
+
|
|
79
|
+
'POST /api/install': async (req) => {
|
|
80
|
+
const { engine, planId } = await body(req);
|
|
81
|
+
const plan = (await installPlans(engine)).find((p) => p.id === planId);
|
|
82
|
+
if (!plan) throw new TunerError('errors.unknownInstallPlan');
|
|
83
|
+
if (plan.needsSudo && process.platform !== 'win32') {
|
|
84
|
+
// No TTY for a sudo password here: the user runs it in a terminal.
|
|
85
|
+
return { manualCommand: plan.shell };
|
|
86
|
+
}
|
|
87
|
+
const job = createJob();
|
|
88
|
+
runInstall(plan, (line) => job.emit({ type: 'log', line }))
|
|
89
|
+
.then((r) => job.finish({ ok: r.code === 0, manual: r.manual }))
|
|
90
|
+
.catch((e) => job.finish({ ok: false, ...errorBody(e) }));
|
|
91
|
+
return { jobId: job.id };
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
'GET /api/models': async (req, url) => {
|
|
95
|
+
const engine = url.searchParams.get('engine');
|
|
96
|
+
const tuner = await Tuner.create(engine);
|
|
97
|
+
return { models: await tuner.listModels() };
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
'POST /api/plan': async (req) => {
|
|
101
|
+
const { engine, model, ctx } = await body(req);
|
|
102
|
+
const tuner = await Tuner.create(engine);
|
|
103
|
+
const context = Number(ctx) || 8192;
|
|
104
|
+
const p = await tuner.plan(model, context);
|
|
105
|
+
const { layerBytes, layerExpertBytes, kvHeadsPerLayer, swaLayers, ...meta } = p.model.meta;
|
|
106
|
+
// What each load profile would do: a preview that never unloads or measures. The plan is still useful without it.
|
|
107
|
+
const profiles = await tuner.profilePlan(model, context).catch(() => null);
|
|
108
|
+
return { meta, maxContext: p.maxContext, candidates: p.candidates.map(slim), profiles };
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
'GET /api/presets': async (req, url) => ({ presets: await listPresets({ engine: url.searchParams.get('engine'), modelKey: url.searchParams.get('model') }) }),
|
|
112
|
+
|
|
113
|
+
// The "Load" button: preset -> apply + load, or benchmark -> preset -> apply + load.
|
|
114
|
+
'POST /api/load': async (req) => {
|
|
115
|
+
if (busy) throw new TunerError('errors.loadInProgress');
|
|
116
|
+
const { engine, model, ctx, force = false, candidates = 3, profile } = await body(req);
|
|
117
|
+
// An unknown profile is rejected before any job exists; without one the tuner uses its default.
|
|
118
|
+
const options = { force, maxCandidates: Number(candidates) };
|
|
119
|
+
if (profile !== undefined) options.profile = knownProfile(profile);
|
|
120
|
+
const tuner = await Tuner.create(engine);
|
|
121
|
+
const job = createJob();
|
|
122
|
+
tuner.on('progress', (e) => job.emit(e));
|
|
123
|
+
busy = true;
|
|
124
|
+
tuner.load(model, Number(ctx), options)
|
|
125
|
+
.then((r) => job.finish({ ok: !!r.loaded?.ok || !!r.applied?.pendingCommands?.length, result: { ...r, report: r.report && { ...r.report, best: r.report.best && { candidate: slim(r.report.best.candidate), bench: r.report.best.bench } } } }))
|
|
126
|
+
.catch((e) => job.finish({ ok: false, ...errorBody(e) }))
|
|
127
|
+
.finally(() => { busy = false; });
|
|
128
|
+
return { jobId: job.id };
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
'POST /api/chat': async (req) => {
|
|
132
|
+
const { engine, model, messages } = await body(req);
|
|
133
|
+
if (!engine || !model || !Array.isArray(messages) || messages.length === 0) {
|
|
134
|
+
throw new TunerError('errors.invalidChatRequest');
|
|
135
|
+
}
|
|
136
|
+
const e = ENGINES[engine];
|
|
137
|
+
if (!e) throw new TunerError('errors.unknownEngine', { engine });
|
|
138
|
+
const detection = await e.detect();
|
|
139
|
+
const tuner = new Tuner(engine, detection);
|
|
140
|
+
return tuner.chat(model, messages);
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async function handler(req, res) {
|
|
146
|
+
const url = new URL(req.url, 'http://localhost');
|
|
147
|
+
try {
|
|
148
|
+
// Server-Sent Events for job progress.
|
|
149
|
+
const ev = url.pathname.match(/^\/api\/jobs\/([\w-]+)\/events$/);
|
|
150
|
+
if (ev) {
|
|
151
|
+
const job = jobs.get(ev[1]);
|
|
152
|
+
if (!job) return send(res, 404, { error: 'job not found' });
|
|
153
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
154
|
+
const write = (e) => res.write(`data: ${JSON.stringify(e)}\n\n`);
|
|
155
|
+
job.events.forEach(write);
|
|
156
|
+
if (job.done) return res.end();
|
|
157
|
+
const l = (e) => { write(e); if (e.type === 'finished') res.end(); };
|
|
158
|
+
job.listeners.add(l);
|
|
159
|
+
req.on('close', () => job.listeners.delete(l));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const route = routes[`${req.method} ${url.pathname}`];
|
|
163
|
+
if (route) return send(res, 200, await route(req, url));
|
|
164
|
+
|
|
165
|
+
const i18n = url.pathname.match(/^\/api\/i18n\/([\w-]+)$/);
|
|
166
|
+
if (i18n && req.method === 'GET') {
|
|
167
|
+
if (!LOCALES.includes(i18n[1])) return send(res, 404, { error: 'not found' });
|
|
168
|
+
return send(res, 200, { lang: i18n[1], messages: messagesFor(i18n[1]) });
|
|
169
|
+
}
|
|
170
|
+
// The browser shares t()'s formatting with Node; nothing else under src/i18n is exposed.
|
|
171
|
+
if (url.pathname === '/i18n/core.js') {
|
|
172
|
+
res.writeHead(200, { 'Content-Type': MIME['.js'] });
|
|
173
|
+
return res.end(await fs.readFile(I18N_CORE));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Static files.
|
|
177
|
+
const rel = url.pathname === '/' ? 'index.html' : url.pathname.slice(1);
|
|
178
|
+
const file = path.normalize(path.join(PUBLIC, rel));
|
|
179
|
+
if (!file.startsWith(PUBLIC)) return send(res, 403, { error: 'forbidden' });
|
|
180
|
+
const data = await fs.readFile(file).catch(() => null);
|
|
181
|
+
if (!data) return send(res, 404, { error: 'not found' });
|
|
182
|
+
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
|
|
183
|
+
res.end(data);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
send(res, e instanceof TunerError ? 400 : 500, errorBody(e));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Start the local UI server (bound to localhost only). */
|
|
190
|
+
export function startServer({ port = Number(process.env.PORT) || 7860, open = false } = {}) {
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
const server = http.createServer(handler);
|
|
193
|
+
server.on('error', reject);
|
|
194
|
+
server.listen(port, '127.0.0.1', () => {
|
|
195
|
+
const url = `http://127.0.0.1:${server.address().port}`;
|
|
196
|
+
console.log(`LLM Tuner available at ${url}`);
|
|
197
|
+
if (open) openUrl(url);
|
|
198
|
+
resolve({ server, url });
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
|
204
|
+
startServer({ open: process.argv.includes('--open') });
|
|
205
|
+
}
|