llm-tuner 0.4.3 → 0.5.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/README.es.md +202 -125
- package/README.md +197 -120
- package/electron/launch.js +1 -0
- package/electron/main.js +50 -3
- package/electron/preload.cjs +8 -0
- package/electron/server-controls.js +69 -0
- package/package.json +1 -1
- package/src/cli/commands/help.js +1 -0
- package/src/cli/commands/load.js +82 -32
- package/src/cli/commands/mcp.js +16 -2
- package/src/cli/commands/presets.js +12 -2
- package/src/cli/commands/serve.js +59 -0
- package/src/cli/commands/settings.js +60 -5
- package/src/cli/router.js +32 -0
- package/src/cli/server-login.js +47 -0
- package/src/core/client.js +127 -0
- package/src/core/engines/ollama-service.js +158 -0
- package/src/core/engines/ollama.js +16 -23
- package/src/core/index.js +3 -2
- package/src/core/settings.js +50 -2
- package/src/core/tuner.js +2 -2
- package/src/core/util.js +26 -0
- package/src/i18n/en.js +59 -1
- package/src/i18n/es.js +59 -1
- package/src/platform/ollama-authorize.js +79 -0
- package/src/platform/server-autostart.js +187 -0
- package/src/web/public/app.js +149 -3
- package/src/web/public/index.html +57 -0
- package/src/web/public/server-settings.js +127 -0
- package/src/web/server.js +207 -17
package/src/web/server.js
CHANGED
|
@@ -2,12 +2,72 @@ import http from 'node:http';
|
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
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, checkUpdate, detectAgents, installMcpServer } from '../core/index.js';
|
|
5
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
import { detectHardware, detectEngines, Tuner, ENGINES, installPlans, runInstall, slim, listPresets, readSettings, writeSettings, TunerError, PROFILES, checkUpdate, detectAgents, installMcpServer, resolveConnectableHost } from '../core/index.js';
|
|
7
7
|
import { THEMES } from '../core/settings.js';
|
|
8
8
|
import { LOCALES, messagesFor } from '../i18n/index.js';
|
|
9
9
|
import { openUrl } from '../core/installer.js';
|
|
10
10
|
|
|
11
|
+
export function sanitizeHardware(hw) {
|
|
12
|
+
if (!hw || typeof hw !== 'object') return hw;
|
|
13
|
+
return {
|
|
14
|
+
os: {
|
|
15
|
+
platform: hw.os?.platform,
|
|
16
|
+
arch: hw.os?.arch,
|
|
17
|
+
},
|
|
18
|
+
cpu: {
|
|
19
|
+
physicalCores: hw.cpu?.physicalCores,
|
|
20
|
+
threads: hw.cpu?.threads,
|
|
21
|
+
avx2: hw.cpu?.avx2,
|
|
22
|
+
},
|
|
23
|
+
gpus: (hw.gpus || []).map((gpu) => ({
|
|
24
|
+
vendor: gpu.vendor,
|
|
25
|
+
backend: gpu.backend,
|
|
26
|
+
index: gpu.index,
|
|
27
|
+
name: gpu.name,
|
|
28
|
+
totalBytes: gpu.totalBytes,
|
|
29
|
+
usedBytes: gpu.usedBytes,
|
|
30
|
+
freeBytes: gpu.freeBytes,
|
|
31
|
+
bandwidthGBps: gpu.bandwidthGBps,
|
|
32
|
+
displayAttached: gpu.displayAttached,
|
|
33
|
+
unifiedMemory: gpu.unifiedMemory,
|
|
34
|
+
})),
|
|
35
|
+
ram: {
|
|
36
|
+
totalBytes: hw.ram?.totalBytes,
|
|
37
|
+
availableBytes: hw.ram?.availableBytes,
|
|
38
|
+
},
|
|
39
|
+
ramBandwidthGBps: hw.ramBandwidthGBps,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isLoopback(ip) {
|
|
44
|
+
return ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(ip);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function verifyToken(req, expectedToken) {
|
|
48
|
+
const ip = req.socket?.remoteAddress;
|
|
49
|
+
const loopback = isLoopback(ip);
|
|
50
|
+
|
|
51
|
+
if (expectedToken === null || expectedToken === undefined) {
|
|
52
|
+
return loopback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const authHeader = req.headers?.['authorization'];
|
|
56
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const token = authHeader.slice(7);
|
|
61
|
+
const tokenBuf = Buffer.from(token);
|
|
62
|
+
const expectedBuf = Buffer.from(expectedToken);
|
|
63
|
+
|
|
64
|
+
if (tokenBuf.length !== expectedBuf.length) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return timingSafeEqual(tokenBuf, expectedBuf);
|
|
69
|
+
}
|
|
70
|
+
|
|
11
71
|
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), 'public');
|
|
12
72
|
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
73
|
const I18N_CORE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'i18n', 'core.js');
|
|
@@ -45,7 +105,12 @@ async function body(req) {
|
|
|
45
105
|
}
|
|
46
106
|
|
|
47
107
|
function send(res, status, obj) {
|
|
48
|
-
res.writeHead(status, {
|
|
108
|
+
res.writeHead(status, {
|
|
109
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
110
|
+
'Access-Control-Allow-Origin': '*',
|
|
111
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
112
|
+
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
|
|
113
|
+
});
|
|
49
114
|
res.end(JSON.stringify(obj));
|
|
50
115
|
}
|
|
51
116
|
|
|
@@ -56,7 +121,9 @@ const routes = {
|
|
|
56
121
|
|
|
57
122
|
'POST /api/settings': async (req) => {
|
|
58
123
|
const payload = await body(req);
|
|
59
|
-
if (payload?.lang === undefined && payload?.theme === undefined && payload?.profile === undefined
|
|
124
|
+
if (payload?.lang === undefined && payload?.theme === undefined && payload?.profile === undefined && payload?.servers === undefined && payload?.activeServerId === undefined) {
|
|
125
|
+
throw new TunerError('errors.unknownLocale', { lang: '', list: LOCALES.join(', ') });
|
|
126
|
+
}
|
|
60
127
|
const patch = {};
|
|
61
128
|
if (payload.lang !== undefined) {
|
|
62
129
|
if (!LOCALES.includes(payload.lang)) throw new TunerError('errors.unknownLocale', { lang: String(payload.lang ?? ''), list: LOCALES.join(', ') });
|
|
@@ -67,13 +134,16 @@ const routes = {
|
|
|
67
134
|
patch.theme = payload.theme;
|
|
68
135
|
}
|
|
69
136
|
if (payload.profile !== undefined) patch.profile = knownProfile(payload.profile);
|
|
137
|
+
if (payload.servers !== undefined) patch.servers = payload.servers;
|
|
138
|
+
if (payload.activeServerId !== undefined) patch.activeServerId = payload.activeServerId;
|
|
70
139
|
return { ...(await writeSettings(patch)), locales: LOCALES, themes: THEMES, profiles: PROFILES };
|
|
71
140
|
},
|
|
72
141
|
|
|
73
|
-
'GET /api/state': async () => {
|
|
142
|
+
'GET /api/state': async (req, url, options) => {
|
|
74
143
|
const hw = await detectHardware();
|
|
75
144
|
const engines = await detectEngines(hw);
|
|
76
|
-
|
|
145
|
+
const safeHw = (options?.isRemote || options?.serverMode) ? sanitizeHardware(hw) : hw;
|
|
146
|
+
return { hw: safeHw, ...engines, busy };
|
|
77
147
|
},
|
|
78
148
|
|
|
79
149
|
'GET /api/install-plans': async (req, url) => ({ plans: await installPlans(url.searchParams.get('engine')) }),
|
|
@@ -113,18 +183,19 @@ const routes = {
|
|
|
113
183
|
'GET /api/presets': async (req, url) => ({ presets: await listPresets({ engine: url.searchParams.get('engine'), modelKey: url.searchParams.get('model') }) }),
|
|
114
184
|
|
|
115
185
|
// The "Load" button: preset -> apply + load, or benchmark -> preset -> apply + load.
|
|
116
|
-
'POST /api/load': async (req) => {
|
|
186
|
+
'POST /api/load': async (req, url, { authorizeOllama } = {}) => {
|
|
117
187
|
if (busy) throw new TunerError('errors.loadInProgress');
|
|
118
188
|
const { engine, model, ctx, force = false, candidates = 3, profile } = await body(req);
|
|
119
189
|
// An unknown profile is rejected before any job exists; without one the tuner uses its default.
|
|
120
190
|
const options = { force, maxCandidates: Number(candidates) };
|
|
191
|
+
if (authorizeOllama) options.authorizeOllama = authorizeOllama;
|
|
121
192
|
if (profile !== undefined) options.profile = knownProfile(profile);
|
|
122
193
|
const tuner = await Tuner.create(engine);
|
|
123
194
|
const job = createJob();
|
|
124
195
|
tuner.on('progress', (e) => job.emit(e));
|
|
125
196
|
busy = true;
|
|
126
197
|
tuner.load(model, Number(ctx), options)
|
|
127
|
-
.then((r) => job.finish({ ok: !!r.loaded?.ok
|
|
198
|
+
.then((r) => job.finish({ ok: !!r.loaded?.ok, result: { ...r, report: r.report && { ...r.report, best: r.report.best && { candidate: slim(r.report.best.candidate), bench: r.report.best.bench } } } }))
|
|
128
199
|
.catch((e) => job.finish({ ok: false, ...errorBody(e) }))
|
|
129
200
|
.finally(() => { busy = false; });
|
|
130
201
|
return { jobId: job.id };
|
|
@@ -163,15 +234,131 @@ const routes = {
|
|
|
163
234
|
};
|
|
164
235
|
|
|
165
236
|
|
|
166
|
-
async function
|
|
237
|
+
export async function getActiveRemoteServer({ options } = {}) {
|
|
238
|
+
if (options?.serverMode === true) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const settings = await readSettings().catch(() => null);
|
|
243
|
+
if (!settings || !settings.activeServerId || settings.activeServerId === 'local') {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const server = settings.servers?.find((s) => s.id === settings.activeServerId);
|
|
248
|
+
return (server && server.url) ? server : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function proxyToRemote(remoteServer, req, url, res) {
|
|
252
|
+
const targetUrl = `${remoteServer.url.replace(/\/+$/, '')}${url.pathname}${url.search}`;
|
|
253
|
+
const headers = {
|
|
254
|
+
'Content-Type': req.headers['content-type'] || 'application/json',
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
if (remoteServer.token) {
|
|
258
|
+
headers['Authorization'] = `Bearer ${remoteServer.token}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let body = null;
|
|
262
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
263
|
+
const chunks = [];
|
|
264
|
+
for await (const chunk of req) {
|
|
265
|
+
chunks.push(chunk);
|
|
266
|
+
}
|
|
267
|
+
body = Buffer.concat(chunks);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const remoteRes = await fetch(targetUrl, {
|
|
272
|
+
method: req.method,
|
|
273
|
+
headers,
|
|
274
|
+
body: body && body.length > 0 ? body : undefined,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const data = await remoteRes.json().catch(() => ({}));
|
|
278
|
+
return send(res, remoteRes.status, data);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
return send(res, 502, { error: err.message, code: 'web.serverConnectionFailed' });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function handler(req, res, options = {}) {
|
|
167
285
|
const url = new URL(req.url, 'http://localhost');
|
|
168
286
|
try {
|
|
287
|
+
if (req.method === 'OPTIONS') {
|
|
288
|
+
res.writeHead(204, {
|
|
289
|
+
'Access-Control-Allow-Origin': '*',
|
|
290
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
291
|
+
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
|
|
292
|
+
'Access-Control-Max-Age': '86400',
|
|
293
|
+
});
|
|
294
|
+
return res.end();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const ip = req.socket?.remoteAddress;
|
|
298
|
+
const loopback = isLoopback(ip);
|
|
299
|
+
const isRemote = !loopback || !!options.serverMode;
|
|
300
|
+
|
|
301
|
+
if (options.token || isRemote) {
|
|
302
|
+
if (!verifyToken(req, options.token)) {
|
|
303
|
+
return send(res, 401, { error: 'Unauthorized', code: 'errors.unauthorized' });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (isRemote && req.method === 'POST' && url.pathname === '/api/install') {
|
|
308
|
+
return send(res, 403, { error: 'Forbidden', code: 'errors.forbiddenRemote' });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// A foreign website must not trigger the desktop's privileged authorizer.
|
|
312
|
+
if (options.authorizeOllama && req.method === 'POST' && req.headers.origin && req.headers.origin !== `http://127.0.0.1:${req.socket.localPort}`) return send(res, 403, { error: 'Forbidden origin' });
|
|
313
|
+
|
|
314
|
+
const activeRemote = await getActiveRemoteServer({ options });
|
|
315
|
+
if (activeRemote) {
|
|
316
|
+
const ev = url.pathname.match(/^\/api\/jobs\/([\w-]+)\/events$/);
|
|
317
|
+
if (ev) {
|
|
318
|
+
try {
|
|
319
|
+
const targetUrl = `${activeRemote.url.replace(/\/+$/, '')}${url.pathname}${url.search}`;
|
|
320
|
+
const headers = {};
|
|
321
|
+
if (activeRemote.token) {
|
|
322
|
+
headers['Authorization'] = `Bearer ${activeRemote.token}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const remoteRes = await fetch(targetUrl, { method: 'GET', headers });
|
|
326
|
+
res.writeHead(remoteRes.status, {
|
|
327
|
+
'Content-Type': 'text/event-stream',
|
|
328
|
+
'Cache-Control': 'no-cache',
|
|
329
|
+
Connection: 'keep-alive',
|
|
330
|
+
'Access-Control-Allow-Origin': '*',
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const reader = remoteRes.body.getReader();
|
|
334
|
+
req.on('close', () => reader.cancel().catch(() => {}));
|
|
335
|
+
try {
|
|
336
|
+
while (true) {
|
|
337
|
+
const { done, value } = await reader.read();
|
|
338
|
+
if (done) break;
|
|
339
|
+
res.write(value);
|
|
340
|
+
}
|
|
341
|
+
} finally {
|
|
342
|
+
res.end();
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
} catch (err) {
|
|
346
|
+
return send(res, 502, { error: err.message, code: 'web.serverConnectionFailed' });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const PROXIED_PATHS = ['/api/state', '/api/models', '/api/plan', '/api/presets', '/api/load', '/api/chat'];
|
|
351
|
+
if (PROXIED_PATHS.includes(url.pathname)) {
|
|
352
|
+
return await proxyToRemote(activeRemote, req, url, res);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
169
356
|
// Server-Sent Events for job progress.
|
|
170
357
|
const ev = url.pathname.match(/^\/api\/jobs\/([\w-]+)\/events$/);
|
|
171
358
|
if (ev) {
|
|
172
359
|
const job = jobs.get(ev[1]);
|
|
173
360
|
if (!job) return send(res, 404, { error: 'job not found' });
|
|
174
|
-
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
361
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*' });
|
|
175
362
|
const write = (e) => res.write(`data: ${JSON.stringify(e)}\n\n`);
|
|
176
363
|
job.events.forEach(write);
|
|
177
364
|
if (job.done) return res.end();
|
|
@@ -181,7 +368,7 @@ async function handler(req, res) {
|
|
|
181
368
|
return;
|
|
182
369
|
}
|
|
183
370
|
const route = routes[`${req.method} ${url.pathname}`];
|
|
184
|
-
if (route) return send(res, 200, await route(req, url));
|
|
371
|
+
if (route) return send(res, 200, await route(req, url, { ...options, isRemote }));
|
|
185
372
|
|
|
186
373
|
const i18n = url.pathname.match(/^\/api\/i18n\/([\w-]+)$/);
|
|
187
374
|
if (i18n && req.method === 'GET') {
|
|
@@ -207,16 +394,19 @@ async function handler(req, res) {
|
|
|
207
394
|
}
|
|
208
395
|
}
|
|
209
396
|
|
|
210
|
-
/** Start the local
|
|
211
|
-
export function startServer({ port = Number(process.env.PORT) || 7860, open = false } = {}) {
|
|
397
|
+
/** Start the local or remote server. */
|
|
398
|
+
export function startServer({ port = Number(process.env.PORT) || 7860, host = '127.0.0.1', token = null, serverMode = false, open = false, authorizeOllama } = {}) {
|
|
212
399
|
return new Promise((resolve, reject) => {
|
|
213
|
-
const server = http.createServer(handler);
|
|
400
|
+
const server = http.createServer((req, res) => handler(req, res, { authorizeOllama, token, serverMode, host }));
|
|
214
401
|
server.on('error', reject);
|
|
215
|
-
server.listen(port,
|
|
216
|
-
const
|
|
402
|
+
server.listen(port, host, () => {
|
|
403
|
+
const addr = server.address();
|
|
404
|
+
const resolvedPort = typeof addr === 'string' ? addr : addr.port;
|
|
405
|
+
const connectableHost = resolveConnectableHost(host);
|
|
406
|
+
const url = `http://${connectableHost}:${resolvedPort}`;
|
|
217
407
|
console.log(`LLM Tuner available at ${url}`);
|
|
218
408
|
if (open) openUrl(url);
|
|
219
|
-
resolve({ server, url });
|
|
409
|
+
resolve({ server, url, port: resolvedPort });
|
|
220
410
|
});
|
|
221
411
|
});
|
|
222
412
|
}
|