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 +2 -1
- package/package.json +1 -1
- package/src/cli/commands/load.js +15 -0
- package/src/core/engines/ollama-service.js +158 -0
- package/src/core/engines/ollama.js +16 -23
- package/src/core/tuner.js +2 -2
- package/src/i18n/en.js +11 -0
- package/src/i18n/es.js +11 -0
- package/src/platform/ollama-authorize.js +79 -0
- package/src/web/server.js +9 -6
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
package/src/cli/commands/load.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
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}`;
|