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.
@@ -0,0 +1,158 @@
1
+ import fs from 'node:fs/promises';
2
+ import { run, fetchJson, sleep } from '../util.js';
3
+ import { TunerError } from '../errors.js';
4
+
5
+ const CONFIG = '/etc/systemd/system/ollama.service.d/llm-tuner.conf';
6
+ const KEYS = ['OLLAMA_FLASH_ATTENTION', 'OLLAMA_KV_CACHE_TYPE', 'OLLAMA_NUM_PARALLEL', 'CUDA_DEVICE_ORDER', 'CUDA_VISIBLE_DEVICES'];
7
+ const fail = () => { throw new TunerError('errors.ollamaServiceConfig'); };
8
+
9
+ export function validateServiceEnv(env) {
10
+ if (!env || typeof env !== 'object' || Array.isArray(env) || Object.keys(env).some((k) => !KEYS.includes(k))) fail();
11
+ if (!['0', '1'].includes(env.OLLAMA_FLASH_ATTENTION) || !['f16', 'q8_0', 'q4_0'].includes(env.OLLAMA_KV_CACHE_TYPE)) fail();
12
+ if (typeof env.OLLAMA_NUM_PARALLEL !== 'string' || !/^[1-9]\d?$/.test(env.OLLAMA_NUM_PARALLEL) || Number(env.OLLAMA_NUM_PARALLEL) > 64) fail();
13
+ if (env.CUDA_DEVICE_ORDER !== undefined || env.CUDA_VISIBLE_DEVICES !== undefined) {
14
+ if (env.CUDA_DEVICE_ORDER !== 'PCI_BUS_ID' || typeof env.CUDA_VISIBLE_DEVICES !== 'string' || !/^(0|[1-9]\d{0,2})(,(0|[1-9]\d{0,2}))*$/.test(env.CUDA_VISIBLE_DEVICES)) fail();
15
+ const ids = env.CUDA_VISIBLE_DEVICES.split(',');
16
+ if (ids.length > 128 || ids.some((v) => Number(v) > 127) || new Set(ids).size !== ids.length) fail();
17
+ }
18
+ return Object.fromEntries(KEYS.filter((k) => env[k] !== undefined).map((k) => [k, env[k]]));
19
+ }
20
+
21
+ const configText = (env) => ['[Service]', ...Object.entries(env).map(([k, v]) => `Environment="${k}=${v}"`), ''].join('\n');
22
+ const matches = (live, env) => live && KEYS.every((k) => live[k] === env[k]);
23
+
24
+ // This operation is passed as literal argv, never read from a writable script.
25
+ // Both validation layers run before touching the fixed root-owned destination.
26
+ const SCRIPT = String.raw`set -eu
27
+ PATH=/usr/sbin:/usr/bin:/sbin:/bin
28
+ export PATH
29
+ umask 077
30
+ tmp=
31
+ trap 'rc=$?; test -z "$tmp" || rm -f -- "$tmp"; if test "$rc" != 0; then exit 70; fi' EXIT
32
+ test "$(id -u)" = 0
33
+ test "$#" = 5
34
+ case "$1" in 0|1) ;; *) exit 1;; esac
35
+ case "$2" in f16|q8_0|q4_0) ;; *) exit 1;; esac
36
+ case "$3" in ''|*[!0-9]*|0*) exit 1;; esac
37
+ test "$3" -ge 1 && test "$3" -le 64
38
+ if test -n "$4$5"; then
39
+ test "$4" = PCI_BUS_ID
40
+ case "$5" in ''|*[!0-9,]*|,*|*,|*,,*) exit 1;; esac
41
+ oldifs=$IFS; IFS=,; seen=,
42
+ for gpu in $5; do
43
+ case "$gpu" in 0) ;; 0*) exit 1;; esac
44
+ test "$gpu" -le 127
45
+ case "$seen" in *,"$gpu",*) exit 1;; esac
46
+ seen=$seen$gpu,
47
+ done
48
+ IFS=$oldifs
49
+ fi
50
+ trusted_dir() {
51
+ test ! -L "$1" && test -d "$1" && test "$(stat -c %u "$1")" = 0 &&
52
+ test -z "$(find "$1" -maxdepth 0 -perm /022 -print)"
53
+ }
54
+ for parent in /etc /etc/systemd /etc/systemd/system /run; do trusted_dir "$parent"; done
55
+ dir=/etc/systemd/system/ollama.service.d
56
+ file=/etc/systemd/system/ollama.service.d/llm-tuner.conf
57
+ lockdir=/run/llm-tuner
58
+ test ! -L "$dir" && test ! -L "$lockdir"
59
+ test -d "$dir" || mkdir -m 755 "$dir"
60
+ test -d "$lockdir" || mkdir -m 700 "$lockdir"
61
+ trusted_dir "$dir" && trusted_dir "$lockdir"
62
+ test ! -L "$lockdir/ollama.lock"
63
+ exec 9>"$lockdir/ollama.lock"
64
+ flock -n 9
65
+ if test -e "$file" || test -L "$file"; then
66
+ test ! -L "$file" && test -f "$file" && test "$(stat -c %u "$file")" = 0
67
+ test -z "$(find "$file" -maxdepth 0 -perm /022 -print)"
68
+ fi
69
+ body=$(printf '[Service]\nEnvironment="OLLAMA_FLASH_ATTENTION=%s"\nEnvironment="OLLAMA_KV_CACHE_TYPE=%s"\nEnvironment="OLLAMA_NUM_PARALLEL=%s"\n' "$1" "$2" "$3")
70
+ if test -n "$4"; then
71
+ body=$(printf '%s\nEnvironment="CUDA_DEVICE_ORDER=%s"\nEnvironment="CUDA_VISIBLE_DEVICES=%s"\n' "$body" "$4" "$5")
72
+ fi
73
+ live_matches() {
74
+ pid=$(systemctl show ollama.service --property=MainPID --value) || return 1
75
+ case "$pid" in ''|*[!0-9]*|0) return 1;; esac
76
+ live=$(tr '\000' '\n' < "/proc/$pid/environ") || return 1
77
+ for pair in "OLLAMA_FLASH_ATTENTION=$1" "OLLAMA_KV_CACHE_TYPE=$2" "OLLAMA_NUM_PARALLEL=$3"; do
78
+ printf '%s\n' "$live" | grep -Fxq -- "$pair" || return 1
79
+ done
80
+ if test -n "$4"; then
81
+ printf '%s\n' "$live" | grep -Fxq -- "CUDA_DEVICE_ORDER=$4" || return 1
82
+ printf '%s\n' "$live" | grep -Fxq -- "CUDA_VISIBLE_DEVICES=$5" || return 1
83
+ elif printf '%s\n' "$live" | grep -Eq '^CUDA_(DEVICE_ORDER|VISIBLE_DEVICES)='; then
84
+ return 1
85
+ fi
86
+ test "$pid" = "$(systemctl show ollama.service --property=MainPID --value)"
87
+ }
88
+ restarted=0
89
+ if ! live_matches "$@"; then restarted=1; fi
90
+ tmp=$(mktemp "$dir/.llm-tuner-XXXXXXXX")
91
+ printf '%s\n' "$body" > "$tmp"
92
+ chmod 644 "$tmp"
93
+ changed=0; backup=
94
+ if ! cmp -s "$file" "$tmp"; then
95
+ if test -f "$file"; then
96
+ backup=$(mktemp "$file.bak-llm-tuner-XXXXXXXX")
97
+ cp -p -- "$file" "$backup"
98
+ fi
99
+ mv -f -- "$tmp" "$file"
100
+ tmp=; changed=1
101
+ fi
102
+ if test "$changed$restarted" != 00; then systemctl daemon-reload; fi
103
+ if test "$restarted" = 1; then systemctl restart ollama.service; fi
104
+ live_matches "$@"
105
+ printf 'changed=%s\nrestarted=%s\nbackup=%s\n' "$changed" "$restarted" "$backup"
106
+ `;
107
+
108
+ export function serviceInvocation(input) {
109
+ const env = validateServiceEnv(input);
110
+ // The elevated watchdog can terminate the privileged process group; its
111
+ // unprivileged parent cannot reliably signal a root shell after pkexec.
112
+ return { command: '/usr/bin/timeout', args: ['--signal=TERM', '--kill-after=5s', '45s', '/bin/sh', '-c', SCRIPT, 'llm-tuner-ollama', ...KEYS.map((k) => env[k] ?? '')] };
113
+ }
114
+
115
+ export const serviceIO = {
116
+ async inspect(env) {
117
+ try {
118
+ const query = () => run('/usr/bin/systemctl', ['show', 'ollama.service', '--property=MainPID', '--value']);
119
+ const p = await query();
120
+ if (p.code || !/^[1-9]\d*$/.test(p.stdout.trim())) return null;
121
+ for (const target of ['/etc', '/etc/systemd', '/etc/systemd/system', '/etc/systemd/system/ollama.service.d', CONFIG]) {
122
+ const st = await fs.lstat(target);
123
+ if (st.isSymbolicLink() || st.uid !== 0 || (st.mode & 0o022)) return null;
124
+ }
125
+ if (await fs.readFile(CONFIG, 'utf8') !== configText(env)) return null;
126
+ const raw = await fs.readFile(`/proc/${p.stdout.trim()}/environ`, 'utf8');
127
+ const again = await query();
128
+ if (again.code || p.stdout !== again.stdout) return null;
129
+ return Object.fromEntries(raw.split('\0').filter((v) => v.includes('=')).map((v) => [v.slice(0, v.indexOf('=')), v.slice(v.indexOf('=') + 1)]));
130
+ } catch { return null; }
131
+ },
132
+ async ready(apiUrl) {
133
+ const deadline = Date.now() + 15000;
134
+ while (Date.now() < deadline) {
135
+ if ((await fetchJson(`${apiUrl}/api/version`, { timeout: 1000 }))?.ok) return true;
136
+ await sleep(250);
137
+ }
138
+ return false;
139
+ },
140
+ };
141
+
142
+ export function validateServiceTarget(apiUrl) {
143
+ let url;
144
+ try { url = new URL(apiUrl); } catch { throw new TunerError('errors.ollamaServiceTarget'); }
145
+ if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.port !== '11434' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new TunerError('errors.ollamaServiceTarget');
146
+ return url.origin;
147
+ }
148
+
149
+ export async function reconcileOllamaService(apiUrl, input, { authorize } = {}) {
150
+ const origin = validateServiceTarget(apiUrl);
151
+ const env = validateServiceEnv(input);
152
+ if (matches(await serviceIO.inspect(env), env)) return { changed: false, restarted: false, backups: [] };
153
+ if (typeof authorize !== 'function') throw new TunerError('errors.ollamaAuthorizationRequired');
154
+ const result = await authorize({ operation: 'ollama-service-env', env });
155
+ if (!result || typeof result.changed !== 'boolean' || typeof result.restarted !== 'boolean' || !Array.isArray(result.backups) || result.backups.some((b) => typeof b !== 'string')) throw new TunerError('errors.ollamaAuthorizationFailed');
156
+ if (!(await serviceIO.ready(origin))) throw new TunerError('errors.ollamaServiceReady');
157
+ return result;
158
+ }
@@ -5,6 +5,7 @@ import { run, which, exists, fetchJson, freePort, spawnLogged, killProcess, slee
5
5
  import { modelMetaFromFile } from '../gguf.js';
6
6
  import { runApiBenchmark } from '../benchmark.js';
7
7
  import { TunerError, benchError } from '../errors.js';
8
+ import { reconcileOllamaService, validateServiceEnv, validateServiceTarget } from './ollama-service.js';
8
9
 
9
10
  const DEFAULT_HOST = '127.0.0.1:11434';
10
11
 
@@ -270,24 +271,29 @@ export const ollama = {
270
271
  * 1) Create a tuned model variant with per-model parameters (num_ctx, num_gpu...).
271
272
  * 2) Set server-wide env (KV type, flash attention, GPU order) for the Ollama service.
272
273
  */
273
- async apply(ctx, model, c, { dryRun = false, useSudo = false, skipIfApplied = false } = {}) {
274
+ async apply(ctx, model, c, { dryRun = false, skipIfApplied = false, authorize } = {}) {
274
275
  const name = this.tunedName(model.key, c);
275
276
  const env = this.serverEnv(c);
276
277
  const result = { tunedModel: name, modelfile: this.modelfile(model.key, c), env, changes: [], pendingCommands: [] };
277
278
  if (dryRun) return { dryRun: true, ...result };
279
+ if (PLATFORM === 'linux' && ctx.detection.systemd) {
280
+ validateServiceTarget(ctx.detection.apiUrl);
281
+ validateServiceEnv(env);
282
+ }
278
283
 
279
284
  const dir = path.join(os.tmpdir(), `llm-tuner-${timestamp()}`);
280
285
  await fs.mkdir(dir, { recursive: true });
281
286
  if (skipIfApplied && (await this.isApplied(ctx, model, c))) {
282
287
  result.changes.push({ code: 'changes.ollamaExists', params: { name } });
283
- return result;
288
+ } else {
289
+ const mf = path.join(dir, 'Modelfile');
290
+ await fs.writeFile(mf, result.modelfile);
291
+ const r = await run(ctx.detection.bin, ['create', name, '-f', mf], { timeout: 300_000 });
292
+ if (r.code !== 0) throw new Error(`ollama create failed: ${(r.stderr || r.stdout).slice(-300)}`);
293
+ result.changes.push({ code: 'changes.ollamaCreated', params: { name } });
284
294
  }
285
- const mf = path.join(dir, 'Modelfile');
286
- await fs.writeFile(mf, result.modelfile);
287
- const r = await run(ctx.detection.bin, ['create', name, '-f', mf], { timeout: 300_000 });
288
- if (r.code !== 0) throw new Error(`ollama create failed: ${(r.stderr || r.stdout).slice(-300)}`);
289
- result.changes.push({ code: 'changes.ollamaCreated', params: { name } });
290
295
 
296
+ // Model parameters cannot carry server-global KV/FA settings. Reapply them even on a preset hit.
291
297
  if (PLATFORM === 'win32') {
292
298
  for (const [k, v] of Object.entries(env)) await run('setx', [k, v]);
293
299
  result.changes.push({ code: 'changes.ollamaSetx' });
@@ -302,22 +308,9 @@ export const ollama = {
302
308
  await sleep(2000);
303
309
  await run('open', ['-a', 'Ollama']);
304
310
  } else if (ctx.detection.systemd) {
305
- const conf = ['[Service]', ...Object.entries(env).map(([k, v]) => `Environment="${k}=${v}"`), ''].join('\n');
306
- const confFile = path.join(dir, 'llm-tuner.conf');
307
- await fs.writeFile(confFile, conf);
308
- const cmds = [
309
- 'sudo mkdir -p /etc/systemd/system/ollama.service.d',
310
- `sudo cp ${confFile} /etc/systemd/system/ollama.service.d/llm-tuner.conf`,
311
- 'sudo systemctl daemon-reload',
312
- 'sudo systemctl restart ollama',
313
- ];
314
- if (useSudo) {
315
- const ok = (await run('sudo', ['-n', 'true'])).code === 0;
316
- if (ok) {
317
- for (const cmd of cmds) { const [, ...rest] = cmd.split(' '); await run('sudo', ['-n', ...rest]); }
318
- result.changes.push({ code: 'changes.ollamaSystemd' });
319
- } else result.pendingCommands.push(...cmds);
320
- } else result.pendingCommands.push(...cmds);
311
+ const service = await reconcileOllamaService(ctx.detection.apiUrl, env, { authorize });
312
+ result.backups = service.backups;
313
+ result.changes.push({ code: service.restarted ? 'changes.ollamaSystemd' : 'changes.ollamaSystemdUnchanged' });
321
314
  } else {
322
315
  result.pendingCommands.push(...Object.entries(env).map(([k, v]) => `export ${k}=${v}`), 'ollama serve');
323
316
  }
package/src/core/index.js CHANGED
@@ -4,10 +4,11 @@ export { planCandidates, placeLayers, maxFullOffloadContext, kvTotalBytes, KV_TY
4
4
  export { Tuner, ENGINES, detectEngines, pickBest, slim } from './tuner.js';
5
5
  export { PROFILES, DEFAULT_PROFILE, normalizeProfile, QUALITY_KV_TYPES, kvTypesFor, meetsQuality, fitsFullyOnGpu, pickVariant, recommendProfile, HINT_TARGETS, variantHints, VRAM_BUSY_FRACTION, vramInUse } from './profiles.js';
6
6
  export { installPlans, runInstall, openUrl } from './installer.js';
7
- export { fmtBytes, configDir } from './util.js';
7
+ export { fmtBytes, configDir, resolveConnectableHost } from './util.js';
8
8
  export { findPreset, savePreset, listPresets, presetsDir, presetPath, variantsSignature, hardwareFingerprint } from './presets.js';
9
- export { readSettings, writeSettings, settingsPath, THEMES, DEFAULT_THEME, normalizeTheme } from './settings.js';
9
+ export { readSettings, writeSettings, settingsPath, THEMES, DEFAULT_THEME, normalizeTheme, getActiveServer, DEFAULT_SERVERS, DEFAULT_ACTIVE_SERVER_ID } from './settings.js';
10
10
  export { TunerError, benchError } from './errors.js';
11
11
  export { sampleRam, sampleGpus, startTelemetrySampler } from './telemetry.js';
12
12
  export { checkUpdate, isNewerVersion, CURRENT_VERSION } from './updater.js';
13
13
  export { detectAgents, installMcpServer, SUPPORTED_AGENTS } from './mcp/index.js';
14
+ export { TunerApiClient } from './client.js';
@@ -9,6 +9,11 @@ import { PROFILES } from './profiles.js';
9
9
  export const THEMES = Object.freeze(['system', 'light', 'dark']);
10
10
  export const DEFAULT_THEME = 'system';
11
11
 
12
+ export const DEFAULT_SERVERS = Object.freeze([
13
+ Object.freeze({ id: 'local', name: 'Local Machine', url: 'http://127.0.0.1:7860', token: null }),
14
+ ]);
15
+ export const DEFAULT_ACTIVE_SERVER_ID = 'local';
16
+
12
17
  /** Returns the value if it is a valid profile, otherwise null. */
13
18
  function storedProfile(value) {
14
19
  return PROFILES.includes(value) ? value : null;
@@ -18,15 +23,47 @@ export function normalizeTheme(theme) {
18
23
  return THEMES.includes(theme) ? theme : DEFAULT_THEME;
19
24
  }
20
25
 
26
+ export function normalizeServers(servers) {
27
+ if (!Array.isArray(servers)) return [...DEFAULT_SERVERS];
28
+ const list = servers
29
+ .filter((s) => s && typeof s === 'object' && s.id)
30
+ .map((s) => ({
31
+ id: String(s.id),
32
+ name: String(s.name || s.id),
33
+ url: String(s.url || '').replace(/\/+$/, ''),
34
+ token: s.token ? String(s.token) : null,
35
+ }));
36
+ if (list.length === 0) return [...DEFAULT_SERVERS];
37
+ if (!list.some((s) => s.id === 'local')) {
38
+ list.unshift(DEFAULT_SERVERS[0]);
39
+ }
40
+ return list;
41
+ }
42
+
43
+ export function normalizeActiveServerId(activeId, servers) {
44
+ const list = normalizeServers(servers);
45
+ return list.some((s) => s.id === activeId) ? activeId : DEFAULT_ACTIVE_SERVER_ID;
46
+ }
47
+
48
+ export function getActiveServer(settings) {
49
+ const servers = normalizeServers(settings?.servers);
50
+ const activeId = normalizeActiveServerId(settings?.activeServerId, servers);
51
+ return servers.find((s) => s.id === activeId) || servers[0];
52
+ }
53
+
21
54
  export const settingsPath = () => path.join(configDir(), 'settings.json');
22
55
 
23
56
  /** Current settings. A missing, corrupt or hand-edited file yields the defaults; never throws. */
24
57
  export async function readSettings() {
25
58
  const s = await readJson(settingsPath(), null);
59
+ const servers = normalizeServers(s?.servers);
60
+ const activeServerId = normalizeActiveServerId(s?.activeServerId, servers);
26
61
  return {
27
62
  lang: normalizeLocale(s?.lang),
28
63
  theme: normalizeTheme(s?.theme),
29
64
  profile: storedProfile(s?.profile),
65
+ servers,
66
+ activeServerId,
30
67
  };
31
68
  }
32
69
 
@@ -37,8 +74,19 @@ export async function writeSettings(patch) {
37
74
  const lang = normalizeLocale(patch?.lang !== undefined ? patch.lang : base.lang);
38
75
  const theme = normalizeTheme(patch?.theme !== undefined ? patch.theme : base.theme);
39
76
  const profile = storedProfile(patch?.profile) ?? base.profile;
40
- const next = { ...base, ...patch, lang, theme, profile };
77
+ const servers = patch?.servers !== undefined ? normalizeServers(patch.servers) : normalizeServers(base.servers);
78
+ const activeServerId = patch?.activeServerId !== undefined
79
+ ? normalizeActiveServerId(patch.activeServerId, servers)
80
+ : normalizeActiveServerId(base.activeServerId, servers);
81
+
82
+ const next = { ...base, ...patch, lang, theme, profile, servers, activeServerId };
41
83
  await fs.mkdir(configDir(), { recursive: true });
42
84
  await fs.writeFile(settingsPath(), JSON.stringify(next, null, 2));
43
- return { lang: next.lang, theme: next.theme, profile: storedProfile(next.profile) };
85
+ return {
86
+ lang: next.lang,
87
+ theme: next.theme,
88
+ profile: storedProfile(next.profile),
89
+ servers: next.servers,
90
+ activeServerId: next.activeServerId,
91
+ };
44
92
  }
package/src/core/tuner.js CHANGED
@@ -270,7 +270,7 @@ export class Tuner extends EventEmitter {
270
270
  * Balanced measures and loads the key the user chose; speed and quality reuse the variant stored in
271
271
  * the preset, or pick one once the VRAM is free.
272
272
  */
273
- async load(key, ctx, { force = false, dryRun = false, maxCandidates = 3, depthFraction = 0.5, measure = true, confirmApply = async () => true, profile: requested = DEFAULT_PROFILE } = {}) {
273
+ async load(key, ctx, { force = false, dryRun = false, maxCandidates = 3, depthFraction = 0.5, measure = true, confirmApply = async () => true, authorizeOllama, profile: requested = DEFAULT_PROFILE } = {}) {
274
274
  return this.withTelemetry(async () => {
275
275
  const profile = normalizeProfile(requested);
276
276
  const model = await this.modelInfo(key);
@@ -320,7 +320,7 @@ export class Tuner extends EventEmitter {
320
320
  if (!(await confirmApply(preview))) return { source, candidate: slim(candidate), report, preview, applied: false, profile, variant: loadKey };
321
321
 
322
322
  this.log('status', { code: 'status.applying', progress: { pct: 88, phase: 'apply' } });
323
- const applied = await this.engine.apply(this.ctx, modelObj, candidate, { useSudo: true, skipIfApplied: source === 'preset' });
323
+ const applied = await this.engine.apply(this.ctx, modelObj, candidate, { useSudo: true, skipIfApplied: source === 'preset', ...(authorizeOllama ? { authorize: authorizeOllama } : {}) });
324
324
  this.log('applied', { result: applied, progress: { pct: 92, phase: 'apply' } });
325
325
  if (applied.pendingCommands?.length) return { source, candidate: slim(candidate), report, applied, loaded: null, profile, variant: loadKey };
326
326
 
package/src/core/util.js CHANGED
@@ -146,3 +146,29 @@ export function cpuBusyPercent(a, b) {
146
146
  }
147
147
  return total > 0 ? 100 * (1 - idle / total) : 0;
148
148
  }
149
+
150
+ export function resolveConnectableHost(host = '127.0.0.1', networkInterfaces = os.networkInterfaces) {
151
+ if (host !== '0.0.0.0' && host !== '::') {
152
+ return host;
153
+ }
154
+
155
+ let interfaces;
156
+ if (typeof networkInterfaces === 'function') {
157
+ interfaces = networkInterfaces();
158
+ } else if (networkInterfaces && typeof networkInterfaces.networkInterfaces === 'function') {
159
+ interfaces = networkInterfaces.networkInterfaces();
160
+ } else {
161
+ interfaces = os.networkInterfaces();
162
+ }
163
+
164
+ for (const name of Object.keys(interfaces || {})) {
165
+ const list = interfaces[name] || [];
166
+ const addr = list.find((info) => (info.family === 'IPv4' || info.family === 4) && !info.internal);
167
+ if (addr) {
168
+ return addr.address;
169
+ }
170
+ }
171
+
172
+ return '127.0.0.1';
173
+ }
174
+
package/src/i18n/en.js CHANGED
@@ -1,5 +1,25 @@
1
1
  // English catalog: the base language. Every key here must also exist in es.js.
2
2
  export default {
3
+ "web.localServerSettings": "Local Server Settings",
4
+ "web.serverAutostartLabel": "Start server after login",
5
+ "web.serverStartupEnabled": "Automatic server startup is enabled",
6
+ "web.serverStartupDisabled": "Automatic server startup is disabled",
7
+ "web.serverStartupUnknown": "Automatic server startup state is unknown",
8
+ "web.serverStartupSaved": "Startup setting saved for next login",
9
+ "web.serverTokenLabel": "Server authentication token",
10
+ "web.serverTokenReveal": "Reveal token",
11
+ "web.serverTokenHide": "Hide token",
12
+ "web.serverTokenCopy": "Copy token",
13
+ "web.serverTokenCopied": "Token copied",
14
+ "web.serverNoConfig": "No server configuration yet. Run /serve or enable automatic startup first.",
15
+ "web.serverConnectionInfo": "Server listens on {host}:{port}. On another computer use this computer LAN IP with port {port}, not 0.0.0.0 or localhost.",
16
+ "web.serverStartupUnconfirmed": "Could not confirm startup state.",
17
+ "web.serverTokenCopyFailed": "Could not confirm token copy.",
18
+ "cli.serverConnection": "View server connection details and token",
19
+ "cli.serverConnectionDetails": "Listen address: {host}:{port}; Authentication Token: {token}; From another computer use the server LAN IP with port {port}, not 0.0.0.0 or localhost.",
20
+ "cli.serverNoConfig": "No server configuration yet. Run /serve or enable automatic startup first.",
21
+ "cli.serverConnectionInvalid": "Saved server configuration is invalid.",
22
+ "cli.serverConnectionHelp": "In /settings, select View server connection details and token to retrieve the saved token. Desktop Settings also provides explicit reveal and copy actions.",
3
23
  // Engine choice
4
24
  'engine.reason.onlyInstalled': '{name} is the only one installed.',
5
25
  'engine.reason.multiGpu': 'You have several GPUs: LM Studio lets you set the GPU order and the KV type per model.',
@@ -42,6 +62,13 @@ export default {
42
62
  'errors.ggufNotFound': 'Cannot find the GGUF file for {key}',
43
63
  'errors.lmstudioOpen': 'LM Studio is open; close it before applying the configuration.',
44
64
  'errors.ollamaUnknownModel': 'Ollama does not recognize the model {key}',
65
+ 'errors.ollamaServiceConfig': 'Invalid Ollama service configuration; no privileged operation was started.',
66
+ 'errors.ollamaServiceTarget': 'Automatic service configuration requires the local Ollama service on port 11434.',
67
+ 'errors.ollamaServiceReady': 'Ollama did not become ready after configuration. The model was not loaded.',
68
+ 'errors.ollamaAuthorizationRequired': 'Administrator authorization is required. Run interactively to authorize Ollama configuration, then retry.',
69
+ 'errors.ollamaAuthorizationUnavailable': 'Administrator authorization is unavailable. Check that sudo or a desktop polkit authentication agent is installed.',
70
+ 'errors.ollamaAuthorizationCancelled': 'Administrator authorization was cancelled. The model was not loaded.',
71
+ 'errors.ollamaAuthorizationFailed': 'Ollama service configuration failed. Check the service and its configuration backup before retrying.',
45
72
 
46
73
  // Install methods
47
74
  'install.ollamaScript': 'Official script (ollama.com/install.sh, asks for sudo)',
@@ -73,10 +100,13 @@ export default {
73
100
  'changes.ollamaSetx': 'User environment variables updated (setx)',
74
101
  'changes.ollamaLaunchctl': 'Variables set with launchctl (until the next reboot; also add them to your profile if you want them to persist)',
75
102
  'changes.ollamaSystemd': 'systemd override installed and Ollama restarted',
103
+ 'changes.ollamaSystemdUnchanged': 'Ollama service settings verified; no restart needed',
76
104
 
77
105
  'errors.unknownLocale': 'Unknown language "{lang}". Valid values: {list}',
78
106
  'errors.unknownTheme': 'Unknown theme "{theme}". Valid values: {list}',
79
107
  'errors.unknownProfile': 'Unknown profile "{profile}". Valid values: {list}',
108
+ 'errors.unauthorized': 'Unauthorized: a valid Bearer token is required.',
109
+ 'errors.forbiddenRemote': 'Forbidden: this operation cannot be performed remotely.',
80
110
  'errors.installNotFinished': 'The installation did not finish. Run llm-tuner again when it is done.',
81
111
  'errors.modelDownloadFailed': 'Could not download the model',
82
112
  'errors.noInstaller': 'There is no automatic installer for this system.',
@@ -106,7 +136,7 @@ Commands:
106
136
  /load Tune and load a model with optimal hardware settings
107
137
  /mcp Connect local engine to AI coding agents as MCP server
108
138
  /presets List saved presets
109
- /settings Interactive settings menu (language and theme)
139
+ /settings Interactive settings menu (language, theme, and server startup)
110
140
  /help Show this help message
111
141
  /exit Exit the interactive menu
112
142
 
@@ -124,12 +154,27 @@ Options:
124
154
  --lang <en|es> Interface language (saved for next time)
125
155
  --theme <system|light|dark> Interface theme (saved for next time)
126
156
 
157
+ Server startup can be enabled or disabled after login in /settings. It starts on your next login, not immediately.
158
+
159
+ Ollama service changes may ask for administrator authorization in the terminal.
160
+ Unchanged settings do not restart the service. JSON/noninteractive mode never asks for a password.
161
+
127
162
  Presets are saved in {presetsDir}`,
128
163
  'cli.desktopStartFailed': 'Could not start the desktop app: {error}\nThe desktop runtime (Electron) is downloaded the first time you run llm-tuner-desktop, so that first run needs a network connection.\nThe terminal interface works without it: run llm-tuner.',
129
164
  'cli.settingsTitle': 'Settings',
130
165
  'cli.settingsMenu': 'Select an option to configure:',
131
166
  'cli.settingsLanguage': 'Language',
132
167
  'cli.settingsTheme': 'Theme',
168
+ 'cli.settingsAutostart': 'Automatic server startup after login',
169
+ 'cli.settingsAutostartEnable': 'Enable',
170
+ 'cli.settingsAutostartDisable': 'Disable',
171
+ 'cli.enabled': 'Enabled',
172
+ 'cli.disabled': 'Disabled',
173
+ 'cli.unknown': 'Unknown',
174
+ 'cli.autostartEnabled': 'Server startup is enabled for your next login.',
175
+ 'cli.autostartDisabled': 'Server startup is disabled.',
176
+ 'cli.autostartFailed': 'Could not update automatic server startup: {error}',
177
+ 'cli.settingsServers': 'Active Server',
133
178
  'cli.settingsExit': 'Save and return',
134
179
  'cli.settingsSaved': 'Settings saved.',
135
180
  'cli.langNotSaved': 'Could not save the language preference: {error}',
@@ -273,12 +318,25 @@ Presets are saved in {presetsDir}`,
273
318
  'web.mcpAlreadyConfigured': 'Configured',
274
319
  'web.mcpSuccess': 'Successfully configured MCP in: {agents}',
275
320
  'web.mcpSaveBtn': 'Save & Configure',
321
+ 'web.servers': 'Servers',
322
+ 'web.addServer': 'Add Server',
323
+ 'web.removeServer': 'Remove',
324
+ 'web.addServerModalTitle': 'Add Remote Server',
325
+ 'web.serverName': 'Server Name',
326
+ 'web.serverUrl': 'Server URL',
327
+ 'web.serverToken': 'Authentication Token',
328
+ 'web.testConnection': 'Test Connection',
329
+ 'web.saveServer': 'Save Server',
330
+ 'web.serverConnected': 'Connection successful!',
331
+ 'web.serverConnectionFailed': 'Could not connect to server: {error}',
332
+ 'web.serverActive': 'Active',
276
333
 
277
334
  // CLI Slash Commands
278
335
  'cli.menuTitle': 'Select a command:',
279
336
  'cli.cmdLoadDesc': 'Tune and load a model with optimal hardware settings',
280
337
  'cli.cmdMcpDesc': 'Connect local engine to AI coding agents as MCP server',
281
338
  'cli.cmdPresetsDesc': 'View saved tuning presets',
339
+ 'cli.cmdServeDesc': 'Start remote server mode with API authentication',
282
340
  'cli.cmdSettingsDesc': 'Configure interface language and visual theme',
283
341
  'cli.cmdHelpDesc': 'View usage help and CLI flags',
284
342
  'cli.cmdExitDesc': 'Exit',
package/src/i18n/es.js CHANGED
@@ -1,5 +1,25 @@
1
1
  // Spanish catalog. Same keys and {params} as en.js.
2
2
  export default {
3
+ "web.localServerSettings": "Configuración del servidor local",
4
+ "web.serverAutostartLabel": "Iniciar servidor después del inicio de sesión",
5
+ "web.serverStartupEnabled": "El inicio automático del servidor está habilitado",
6
+ "web.serverStartupDisabled": "El inicio automático del servidor está deshabilitado",
7
+ "web.serverStartupUnknown": "El estado del inicio automático del servidor es desconocido",
8
+ "web.serverStartupSaved": "Configuración de inicio guardada para el próximo inicio de sesión",
9
+ "web.serverTokenLabel": "Token de autenticación del servidor",
10
+ "web.serverTokenReveal": "Mostrar token",
11
+ "web.serverTokenHide": "Ocultar token",
12
+ "web.serverTokenCopy": "Copiar token",
13
+ "web.serverTokenCopied": "Token copiado",
14
+ "web.serverNoConfig": "Aún no hay configuración del servidor. Ejecute /serve o habilite el inicio automático primero.",
15
+ "web.serverConnectionInfo": "El servidor escucha en {host}:{port}. Desde otro ordenador, use la IP LAN de este equipo con el puerto {port}, no 0.0.0.0 ni localhost.",
16
+ "web.serverStartupUnconfirmed": "No se pudo confirmar el estado del inicio.",
17
+ "web.serverTokenCopyFailed": "No se pudo confirmar la copia del token.",
18
+ "cli.serverConnection": "Ver detalles de conexión del servidor y token",
19
+ "cli.serverConnectionDetails": "Dirección de escucha: {host}:{port}; Token de autenticación: {token}; Desde otro ordenador, use la IP LAN del servidor con el puerto {port}, no 0.0.0.0 ni localhost.",
20
+ "cli.serverNoConfig": "Aún no hay configuración del servidor. Ejecute /serve o habilite el inicio automático primero.",
21
+ "cli.serverConnectionInvalid": "La configuración del servidor guardada no es válida.",
22
+ "cli.serverConnectionHelp": "En /settings, seleccione 'Ver detalles de conexión del servidor y token' para recuperar el token guardado. Los Ajustes de escritorio también ofrecen acciones explícitas de mostrar y copiar.",
3
23
  // Engine choice
4
24
  'engine.reason.onlyInstalled': '{name} es el único instalado.',
5
25
  'engine.reason.multiGpu': 'Tienes varias GPUs: LM Studio permite fijar el orden de GPUs y el tipo de KV por modelo.',
@@ -42,6 +62,13 @@ export default {
42
62
  'errors.ggufNotFound': 'No encuentro el archivo GGUF de {key}',
43
63
  'errors.lmstudioOpen': 'LM Studio está abierto; ciérralo antes de aplicar la configuración.',
44
64
  'errors.ollamaUnknownModel': 'Ollama no reconoce el modelo {key}',
65
+ 'errors.ollamaServiceConfig': 'Configuración de servicio de Ollama no válida; no se inició ninguna operación con privilegios.',
66
+ 'errors.ollamaServiceTarget': 'La configuración automática requiere el servicio local de Ollama en el puerto 11434.',
67
+ 'errors.ollamaServiceReady': 'Ollama no estuvo disponible después de la configuración. El modelo no se cargó.',
68
+ 'errors.ollamaAuthorizationRequired': 'Se requiere autorización de administrador. Ejecuta en modo interactivo para autorizar la configuración de Ollama e inténtalo de nuevo.',
69
+ 'errors.ollamaAuthorizationUnavailable': 'La autorización de administrador no está disponible. Comprueba que sudo o un agente de autenticación polkit de escritorio estén instalados.',
70
+ 'errors.ollamaAuthorizationCancelled': 'Se canceló la autorización de administrador. El modelo no se cargó.',
71
+ 'errors.ollamaAuthorizationFailed': 'Falló la configuración del servicio de Ollama. Comprueba el servicio y su copia de seguridad antes de reintentar.',
45
72
 
46
73
  // Install methods
47
74
  'install.ollamaScript': 'Script oficial (ollama.com/install.sh, pide sudo)',
@@ -73,10 +100,13 @@ export default {
73
100
  'changes.ollamaSetx': 'Variables de entorno de usuario actualizadas (setx)',
74
101
  'changes.ollamaLaunchctl': 'Variables aplicadas con launchctl (hasta el próximo reinicio; añade también a tu perfil si quieres que persistan)',
75
102
  'changes.ollamaSystemd': 'Override de systemd instalado y Ollama reiniciado',
103
+ 'changes.ollamaSystemdUnchanged': 'Configuración del servicio de Ollama verificada; no requiere reinicio',
76
104
 
77
105
  'errors.unknownLocale': 'Idioma desconocido "{lang}". Valores válidos: {list}',
78
106
  'errors.unknownTheme': 'Tema desconocido "{theme}". Valores válidos: {list}',
79
107
  'errors.unknownProfile': 'Perfil desconocido "{profile}". Valores válidos: {list}',
108
+ 'errors.unauthorized': 'No autorizado: se requiere un token Bearer válido.',
109
+ 'errors.forbiddenRemote': 'Prohibido: esta operación no se puede ejecutar de forma remota.',
80
110
  'errors.installNotFinished': 'La instalación no terminó. Vuelve a ejecutar llm-tuner cuando acabe.',
81
111
  'errors.modelDownloadFailed': 'No se pudo descargar el modelo',
82
112
  'errors.noInstaller': 'No hay un instalador automático para este sistema.',
@@ -106,7 +136,7 @@ Comandos:
106
136
  /load Ajustar y cargar un modelo con la configuración óptima
107
137
  /mcp Conectar motor local con agentes de IA como servidor MCP
108
138
  /presets Listar presets guardados
109
- /settings Menú interactivo de configuración (idioma y tema)
139
+ /settings Menú interactivo de configuración (idioma, tema e inicio del servidor)
110
140
  /help Mostrar esta ayuda
111
141
  /exit Salir del menú interactivo
112
142
 
@@ -124,12 +154,27 @@ Opciones:
124
154
  --lang <en|es> Idioma de la interfaz (se guarda para la próxima vez)
125
155
  --theme <system|light|dark> Tema de la interfaz (se guarda para la próxima)
126
156
 
157
+ El inicio del servidor se habilita o deshabilita después de iniciar sesión desde /settings. Se ejecuta en el próximo inicio de sesión, no de inmediato.
158
+
159
+ Los cambios del servicio de Ollama pueden solicitar autorización de administrador en la terminal.
160
+ Si los ajustes no cambian, el servicio no se reinicia. El modo JSON/no interactivo nunca pide contraseña.
161
+
127
162
  Los presets se guardan en {presetsDir}`,
128
163
  'cli.desktopStartFailed': 'No se pudo iniciar la aplicación de escritorio: {error}\nEl entorno de escritorio (Electron) se descarga la primera vez que ejecutas llm-tuner-desktop, así que esa primera ejecución necesita conexión a la red.\nLa interfaz de terminal funciona sin él: ejecuta llm-tuner.',
129
164
  'cli.settingsTitle': 'Configuración',
130
165
  'cli.settingsMenu': 'Selecciona una opción para configurar:',
131
166
  'cli.settingsLanguage': 'Idioma',
132
167
  'cli.settingsTheme': 'Tema',
168
+ 'cli.settingsAutostart': 'Inicio automático del servidor después de iniciar sesión',
169
+ 'cli.settingsAutostartEnable': 'Habilitar',
170
+ 'cli.settingsAutostartDisable': 'Deshabilitar',
171
+ 'cli.enabled': 'Habilitado',
172
+ 'cli.disabled': 'Deshabilitado',
173
+ 'cli.unknown': 'Desconocido',
174
+ 'cli.autostartEnabled': 'El inicio del servidor quedó habilitado para tu próximo inicio de sesión.',
175
+ 'cli.autostartDisabled': 'El inicio del servidor quedó deshabilitado.',
176
+ 'cli.autostartFailed': 'No se pudo actualizar el inicio automático del servidor: {error}',
177
+ 'cli.settingsServers': 'Servidor activo',
133
178
  'cli.settingsExit': 'Guardar y volver',
134
179
  'cli.settingsSaved': 'Configuración guardada.',
135
180
  'cli.langNotSaved': 'No se pudo guardar la preferencia de idioma: {error}',
@@ -273,12 +318,25 @@ Los presets se guardan en {presetsDir}`,
273
318
  'web.mcpAlreadyConfigured': 'Configurado',
274
319
  'web.mcpSuccess': 'MCP configurado con éxito en: {agents}',
275
320
  'web.mcpSaveBtn': 'Guardar y configurar',
321
+ 'web.servers': 'Servidores',
322
+ 'web.addServer': 'Agregar servidor',
323
+ 'web.removeServer': 'Eliminar',
324
+ 'web.addServerModalTitle': 'Agregar servidor remoto',
325
+ 'web.serverName': 'Nombre del servidor',
326
+ 'web.serverUrl': 'URL del servidor',
327
+ 'web.serverToken': 'Token de autenticación',
328
+ 'web.testConnection': 'Probar conexión',
329
+ 'web.saveServer': 'Guardar servidor',
330
+ 'web.serverConnected': '¡Conexión exitosa!',
331
+ 'web.serverConnectionFailed': 'No se pudo conectar al servidor: {error}',
332
+ 'web.serverActive': 'Activo',
276
333
 
277
334
  // CLI Slash Commands
278
335
  'cli.menuTitle': 'Selecciona un comando:',
279
336
  'cli.cmdLoadDesc': 'Ajustar y cargar un modelo con la configuración óptima',
280
337
  'cli.cmdMcpDesc': 'Conectar motor local con agentes de IA como servidor MCP',
281
338
  'cli.cmdPresetsDesc': 'Ver presets de configuración guardados',
339
+ 'cli.cmdServeDesc': 'Iniciar en modo servidor remoto con autenticación de API',
282
340
  'cli.cmdSettingsDesc': 'Configurar idioma y tema de la interfaz',
283
341
  'cli.cmdHelpDesc': 'Ver ayuda de uso y opciones',
284
342
  'cli.cmdExitDesc': 'Salir',