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/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, { 'Content-Type': 'application/json; charset=utf-8' });
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) throw new TunerError('errors.unknownLocale', { lang: '', list: LOCALES.join(', ') });
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
- return { hw, ...engines, busy };
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 || !!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 } } } }))
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 handler(req, res) {
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 UI server (bound to localhost only). */
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, '127.0.0.1', () => {
216
- const url = `http://127.0.0.1:${server.address().port}`;
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
  }