llm-tuner 0.4.3 → 0.4.4

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/electron/main.js CHANGED
@@ -2,6 +2,7 @@ import { app, BrowserWindow, shell, nativeImage } from 'electron';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { startServer } from '../src/web/server.js';
5
+ import { createOllamaAuthorizer } from '../src/platform/ollama-authorize.js';
5
6
 
6
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
8
 
@@ -20,7 +21,7 @@ if (process.platform === 'win32') {
20
21
  let server;
21
22
 
22
23
  async function createWindow() {
23
- const started = await startServer({ port: 0 });
24
+ const started = await startServer({ port: 0, authorizeOllama: createOllamaAuthorizer({ mode: 'desktop' }) });
24
25
  server = started.server;
25
26
 
26
27
  const iconFile = process.platform === 'win32' ? 'icon.ico' : 'icon.png';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-tuner",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Detects your hardware and finds the fastest configuration to run local LLMs with LM Studio or Ollama.",
5
5
  "keywords": [
6
6
  "llm",
@@ -18,6 +18,7 @@ import { profileChoices, profileNotes, profileSwitchNote, profileEventLines, sho
18
18
  import { getColors } from '../colors.js';
19
19
  import { withNav, NAV_BACK, backChoice } from '../nav.js';
20
20
  import { TerminalAnimation } from '../animation.js';
21
+ import { createOllamaAuthorizer } from '../../platform/ollama-authorize.js';
21
22
 
22
23
  export async function loadCommand(args, { lang, theme }) {
23
24
  const c = getColors(theme);
@@ -290,6 +291,11 @@ export async function loadCommand(args, { lang, theme }) {
290
291
  let result;
291
292
  try {
292
293
  result = await tuner.load(modelKey, ctx, {
294
+ authorizeOllama: createOllamaAuthorizer({
295
+ mode: process.stdin.isTTY && process.stderr.isTTY && !args.json ? 'terminal' : 'noninteractive',
296
+ beforePrompt: () => anim.stop(),
297
+ afterPrompt: () => anim.start(t('status.applying')),
298
+ }),
293
299
  profile,
294
300
  force: !!args.force,
295
301
  dryRun: !!args['dry-run'],
@@ -309,6 +315,15 @@ export async function loadCommand(args, { lang, theme }) {
309
315
  return confirmed;
310
316
  },
311
317
  });
318
+ } catch (e) {
319
+ if (!/^errors\.ollama(?:Service|Authorization)/.test(e.code || '')) throw e;
320
+ if (args.json) {
321
+ console.log(JSON.stringify({ ok: false, code: e.code, params: e.params || {}, error: t(e.code, e.params) }));
322
+ process.exitCode = 1;
323
+ return;
324
+ }
325
+ e.message = t(e.code, e.params);
326
+ throw e;
312
327
  } finally {
313
328
  anim.stop();
314
329
  }
@@ -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/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/i18n/en.js CHANGED
@@ -42,6 +42,13 @@ export default {
42
42
  'errors.ggufNotFound': 'Cannot find the GGUF file for {key}',
43
43
  'errors.lmstudioOpen': 'LM Studio is open; close it before applying the configuration.',
44
44
  'errors.ollamaUnknownModel': 'Ollama does not recognize the model {key}',
45
+ 'errors.ollamaServiceConfig': 'Invalid Ollama service configuration; no privileged operation was started.',
46
+ 'errors.ollamaServiceTarget': 'Automatic service configuration requires the local Ollama service on port 11434.',
47
+ 'errors.ollamaServiceReady': 'Ollama did not become ready after configuration. The model was not loaded.',
48
+ 'errors.ollamaAuthorizationRequired': 'Administrator authorization is required. Run interactively to authorize Ollama configuration, then retry.',
49
+ 'errors.ollamaAuthorizationUnavailable': 'Administrator authorization is unavailable. Check that sudo or a desktop polkit authentication agent is installed.',
50
+ 'errors.ollamaAuthorizationCancelled': 'Administrator authorization was cancelled. The model was not loaded.',
51
+ 'errors.ollamaAuthorizationFailed': 'Ollama service configuration failed. Check the service and its configuration backup before retrying.',
45
52
 
46
53
  // Install methods
47
54
  'install.ollamaScript': 'Official script (ollama.com/install.sh, asks for sudo)',
@@ -73,6 +80,7 @@ export default {
73
80
  'changes.ollamaSetx': 'User environment variables updated (setx)',
74
81
  'changes.ollamaLaunchctl': 'Variables set with launchctl (until the next reboot; also add them to your profile if you want them to persist)',
75
82
  'changes.ollamaSystemd': 'systemd override installed and Ollama restarted',
83
+ 'changes.ollamaSystemdUnchanged': 'Ollama service settings verified; no restart needed',
76
84
 
77
85
  'errors.unknownLocale': 'Unknown language "{lang}". Valid values: {list}',
78
86
  'errors.unknownTheme': 'Unknown theme "{theme}". Valid values: {list}',
@@ -124,6 +132,9 @@ Options:
124
132
  --lang <en|es> Interface language (saved for next time)
125
133
  --theme <system|light|dark> Interface theme (saved for next time)
126
134
 
135
+ Ollama service changes may ask for administrator authorization in the terminal.
136
+ Unchanged settings do not restart the service. JSON/noninteractive mode never asks for a password.
137
+
127
138
  Presets are saved in {presetsDir}`,
128
139
  '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
140
  'cli.settingsTitle': 'Settings',
package/src/i18n/es.js CHANGED
@@ -42,6 +42,13 @@ export default {
42
42
  'errors.ggufNotFound': 'No encuentro el archivo GGUF de {key}',
43
43
  'errors.lmstudioOpen': 'LM Studio está abierto; ciérralo antes de aplicar la configuración.',
44
44
  'errors.ollamaUnknownModel': 'Ollama no reconoce el modelo {key}',
45
+ 'errors.ollamaServiceConfig': 'Configuración de servicio de Ollama no válida; no se inició ninguna operación con privilegios.',
46
+ 'errors.ollamaServiceTarget': 'La configuración automática requiere el servicio local de Ollama en el puerto 11434.',
47
+ 'errors.ollamaServiceReady': 'Ollama no estuvo disponible después de la configuración. El modelo no se cargó.',
48
+ 'errors.ollamaAuthorizationRequired': 'Se requiere autorización de administrador. Ejecuta en modo interactivo para autorizar la configuración de Ollama e inténtalo de nuevo.',
49
+ '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.',
50
+ 'errors.ollamaAuthorizationCancelled': 'Se canceló la autorización de administrador. El modelo no se cargó.',
51
+ 'errors.ollamaAuthorizationFailed': 'Falló la configuración del servicio de Ollama. Comprueba el servicio y su copia de seguridad antes de reintentar.',
45
52
 
46
53
  // Install methods
47
54
  'install.ollamaScript': 'Script oficial (ollama.com/install.sh, pide sudo)',
@@ -73,6 +80,7 @@ export default {
73
80
  'changes.ollamaSetx': 'Variables de entorno de usuario actualizadas (setx)',
74
81
  'changes.ollamaLaunchctl': 'Variables aplicadas con launchctl (hasta el próximo reinicio; añade también a tu perfil si quieres que persistan)',
75
82
  'changes.ollamaSystemd': 'Override de systemd instalado y Ollama reiniciado',
83
+ 'changes.ollamaSystemdUnchanged': 'Configuración del servicio de Ollama verificada; no requiere reinicio',
76
84
 
77
85
  'errors.unknownLocale': 'Idioma desconocido "{lang}". Valores válidos: {list}',
78
86
  'errors.unknownTheme': 'Tema desconocido "{theme}". Valores válidos: {list}',
@@ -124,6 +132,9 @@ Opciones:
124
132
  --lang <en|es> Idioma de la interfaz (se guarda para la próxima vez)
125
133
  --theme <system|light|dark> Tema de la interfaz (se guarda para la próxima)
126
134
 
135
+ Los cambios del servicio de Ollama pueden solicitar autorización de administrador en la terminal.
136
+ Si los ajustes no cambian, el servicio no se reinicia. El modo JSON/no interactivo nunca pide contraseña.
137
+
127
138
  Los presets se guardan en {presetsDir}`,
128
139
  '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
140
  'cli.settingsTitle': 'Configuración',
@@ -0,0 +1,79 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { serviceInvocation } from '../core/engines/ollama-service.js';
3
+ import { TunerError } from '../core/errors.js';
4
+
5
+ export const authorizationIO = { spawn };
6
+
7
+ function parseResult(stdout) {
8
+ const fields = Object.create(null);
9
+ for (const line of stdout.trimEnd().split('\n')) {
10
+ const index = line.indexOf('=');
11
+ const key = line.slice(0, index);
12
+ if (index < 0 || !['changed', 'restarted', 'backup'].includes(key) || Object.hasOwn(fields, key)) throw new TunerError('errors.ollamaAuthorizationFailed');
13
+ fields[key] = line.slice(index + 1);
14
+ }
15
+ if (!['0', '1'].includes(fields.changed) || !['0', '1'].includes(fields.restarted) || typeof fields.backup !== 'string' || (fields.backup && !/^\/etc\/systemd\/system\/ollama\.service\.d\/llm-tuner\.conf\.bak-llm-tuner-[A-Za-z0-9]+$/.test(fields.backup))) throw new TunerError('errors.ollamaAuthorizationFailed');
16
+ return { changed: fields.changed === '1', restarted: fields.restarted === '1', backups: fields.backup ? [fields.backup] : [] };
17
+ }
18
+
19
+ /** The interface chooses interaction, never the operation or its destinations. */
20
+ export function createOllamaAuthorizer({ mode = 'noninteractive', beforePrompt = () => {}, afterPrompt = () => {} } = {}) {
21
+ if (!['desktop', 'terminal', 'noninteractive'].includes(mode)) throw new TunerError('errors.ollamaServiceConfig');
22
+ return async (request) => {
23
+ if (!request || request.operation !== 'ollama-service-env' || Object.keys(request).some((k) => !['operation', 'env'].includes(k))) throw new TunerError('errors.ollamaServiceConfig');
24
+ const { command, args } = serviceInvocation(request.env);
25
+ const bin = mode === 'desktop' ? '/usr/bin/pkexec' : '/usr/bin/sudo';
26
+ const prefix = mode === 'desktop' ? ['--disable-internal-agent'] : mode === 'noninteractive' ? ['-n', '--'] : ['--'];
27
+ const terminal = mode === 'terminal';
28
+ try {
29
+ if (terminal) await beforePrompt();
30
+ return await new Promise((resolve, reject) => {
31
+ let child, timer, output = '', settled = false, pendingFailure;
32
+ const finish = (error, value) => {
33
+ if (settled) return;
34
+ settled = true;
35
+ clearTimeout(timer);
36
+ if (error) reject(error); else resolve(value);
37
+ };
38
+ const failed = () => new TunerError('errors.ollamaAuthorizationFailed');
39
+ const stop = () => {
40
+ pendingFailure = failed();
41
+ // A signal request is not proof of termination. Retain ownership
42
+ // until close, even if the OS refuses to signal the elevated child.
43
+ try { child.kill('SIGTERM'); } catch { /* wait for the elevated watchdog */ }
44
+ };
45
+ try {
46
+ child = authorizationIO.spawn(bin, [...prefix, command, ...args], {
47
+ env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C' },
48
+ stdio: terminal ? ['inherit', 'pipe', 'inherit'] : ['ignore', 'pipe', 'pipe'],
49
+ });
50
+ } catch (e) {
51
+ finish(new TunerError(e.code === 'ENOENT' ? 'errors.ollamaAuthorizationUnavailable' : 'errors.ollamaAuthorizationFailed'));
52
+ return;
53
+ }
54
+ timer = setTimeout(stop, 60000);
55
+ child.stdout?.on('data', (chunk) => {
56
+ if (pendingFailure) return;
57
+ output += String(chunk);
58
+ if (Buffer.byteLength(output) > 16384) { output = ''; stop(); }
59
+ });
60
+ // Drain diagnostics without collecting passwords or returning raw privileged output.
61
+ child.stderr?.resume();
62
+ child.on('error', (e) => {
63
+ const error = new TunerError(e.code === 'ENOENT' ? 'errors.ollamaAuthorizationUnavailable' : 'errors.ollamaAuthorizationFailed');
64
+ if (!child.pid) finish(error); else pendingFailure = error;
65
+ });
66
+ child.on('close', (code, signal) => {
67
+ if (pendingFailure) { finish(pendingFailure); return; }
68
+ if (code !== 0 || signal) {
69
+ finish(new TunerError(mode === 'desktop' && code === 126 ? 'errors.ollamaAuthorizationCancelled' : mode === 'noninteractive' && code === 1 ? 'errors.ollamaAuthorizationRequired' : 'errors.ollamaAuthorizationFailed'));
70
+ return;
71
+ }
72
+ try { finish(null, parseResult(output)); } catch (e) { finish(e); }
73
+ });
74
+ });
75
+ } finally {
76
+ if (terminal) await afterPrompt();
77
+ }
78
+ };
79
+ }
package/src/web/server.js CHANGED
@@ -113,18 +113,19 @@ const routes = {
113
113
  'GET /api/presets': async (req, url) => ({ presets: await listPresets({ engine: url.searchParams.get('engine'), modelKey: url.searchParams.get('model') }) }),
114
114
 
115
115
  // The "Load" button: preset -> apply + load, or benchmark -> preset -> apply + load.
116
- 'POST /api/load': async (req) => {
116
+ 'POST /api/load': async (req, url, { authorizeOllama } = {}) => {
117
117
  if (busy) throw new TunerError('errors.loadInProgress');
118
118
  const { engine, model, ctx, force = false, candidates = 3, profile } = await body(req);
119
119
  // An unknown profile is rejected before any job exists; without one the tuner uses its default.
120
120
  const options = { force, maxCandidates: Number(candidates) };
121
+ if (authorizeOllama) options.authorizeOllama = authorizeOllama;
121
122
  if (profile !== undefined) options.profile = knownProfile(profile);
122
123
  const tuner = await Tuner.create(engine);
123
124
  const job = createJob();
124
125
  tuner.on('progress', (e) => job.emit(e));
125
126
  busy = true;
126
127
  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 } } } }))
128
+ .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
129
  .catch((e) => job.finish({ ok: false, ...errorBody(e) }))
129
130
  .finally(() => { busy = false; });
130
131
  return { jobId: job.id };
@@ -163,9 +164,11 @@ const routes = {
163
164
  };
164
165
 
165
166
 
166
- async function handler(req, res) {
167
+ async function handler(req, res, options) {
167
168
  const url = new URL(req.url, 'http://localhost');
168
169
  try {
170
+ // A foreign website must not trigger the desktop's privileged authorizer.
171
+ 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' });
169
172
  // Server-Sent Events for job progress.
170
173
  const ev = url.pathname.match(/^\/api\/jobs\/([\w-]+)\/events$/);
171
174
  if (ev) {
@@ -181,7 +184,7 @@ async function handler(req, res) {
181
184
  return;
182
185
  }
183
186
  const route = routes[`${req.method} ${url.pathname}`];
184
- if (route) return send(res, 200, await route(req, url));
187
+ if (route) return send(res, 200, await route(req, url, options));
185
188
 
186
189
  const i18n = url.pathname.match(/^\/api\/i18n\/([\w-]+)$/);
187
190
  if (i18n && req.method === 'GET') {
@@ -208,9 +211,9 @@ async function handler(req, res) {
208
211
  }
209
212
 
210
213
  /** Start the local UI server (bound to localhost only). */
211
- export function startServer({ port = Number(process.env.PORT) || 7860, open = false } = {}) {
214
+ export function startServer({ port = Number(process.env.PORT) || 7860, open = false, authorizeOllama } = {}) {
212
215
  return new Promise((resolve, reject) => {
213
- const server = http.createServer(handler);
216
+ const server = http.createServer((req, res) => handler(req, res, { authorizeOllama }));
214
217
  server.on('error', reject);
215
218
  server.listen(port, '127.0.0.1', () => {
216
219
  const url = `http://127.0.0.1:${server.address().port}`;