llm-tuner 0.4.4 → 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.
@@ -19,6 +19,7 @@ import { getColors } from '../colors.js';
19
19
  import { withNav, NAV_BACK, backChoice } from '../nav.js';
20
20
  import { TerminalAnimation } from '../animation.js';
21
21
  import { createOllamaAuthorizer } from '../../platform/ollama-authorize.js';
22
+ import { TunerApiClient } from '../../core/client.js';
22
23
 
23
24
  export async function loadCommand(args, { lang, theme }) {
24
25
  const c = getColors(theme);
@@ -64,12 +65,26 @@ export async function loadCommand(args, { lang, theme }) {
64
65
  out(c.dim(` ${t('cli.updateCommand', { cmd: 'npm install -g llm-tuner' })}\n`));
65
66
  }
66
67
 
67
- const hw = await detectHardware();
68
- out(c.dim(` ${hw.cpu.brand} · ${fmtBytes(hw.ram.totalBytes)} RAM`));
69
- for (const g of hw.gpus) out(c.dim(` GPU${g.index} ${g.name} · ${fmtBytes(g.totalBytes)}${g.pcieGen ? ` · PCIe ${g.pcieGen}.0 x${g.pcieWidth}` : ''}${g.displayAttached ? t('cli.display') : ''}`));
70
- if (!hw.gpus.length) out(c.y(` ${t('cli.noGpu')}`));
68
+ const settings = await readSettings();
69
+ const isRemote = settings.activeServerId !== 'local';
70
+ let client = null;
71
+ let hw;
72
+ let det;
73
+
74
+ if (isRemote) {
75
+ client = TunerApiClient.fromSettings(settings);
76
+ const state = await client.getState();
77
+ hw = state.hw;
78
+ det = state;
79
+ } else {
80
+ hw = await detectHardware();
81
+ det = await detectEngines(hw);
82
+ }
71
83
 
72
- let det = await detectEngines(hw);
84
+ const cpuTitle = hw.cpu?.brand || (hw.cpu?.physicalCores ? `${hw.cpu.physicalCores} cores` : 'CPU');
85
+ out(c.dim(` ${cpuTitle} · ${fmtBytes(hw.ram?.totalBytes)} RAM`));
86
+ for (const g of (hw.gpus || [])) out(c.dim(` GPU${g.index} ${g.name} · ${fmtBytes(g.totalBytes)}${g.pcieGen ? ` · PCIe ${g.pcieGen}.0 x${g.pcieWidth}` : ''}${g.displayAttached ? t('cli.display') : ''}`));
87
+ if (!hw.gpus?.length) out(c.y(` ${t('cli.noGpu')}`));
73
88
  let engineId = args.engine;
74
89
  let tuner = null;
75
90
  let models = [];
@@ -105,14 +120,20 @@ export async function loadCommand(args, { lang, theme }) {
105
120
  currentStep = 2;
106
121
  }
107
122
  else if (currentStep === 2) {
108
- if (!tuner || tuner.engine.id !== engineId) {
109
- if (!det.engines.find((e) => e.id === engineId)?.detection.installed) {
110
- await installFlow(engineId, { args, t, c });
111
- det = await detectEngines(hw);
112
- if (!det.engines.find((e) => e.id === engineId).detection.installed) throw new TunerError('errors.installNotFinished');
123
+ if (!tuner || tuner.engine?.id !== engineId) {
124
+ if (isRemote) {
125
+ const res = await client.listModels(engineId);
126
+ models = res.models || [];
127
+ tuner = { engine: { id: engineId, name: engineId } };
128
+ } else {
129
+ if (!det.engines.find((e) => e.id === engineId)?.detection.installed) {
130
+ await installFlow(engineId, { args, t, c });
131
+ det = await detectEngines(hw);
132
+ if (!det.engines.find((e) => e.id === engineId).detection.installed) throw new TunerError('errors.installNotFinished');
133
+ }
134
+ tuner = await Tuner.create(engineId);
135
+ models = await tuner.listModels();
113
136
  }
114
- tuner = await Tuner.create(engineId);
115
- models = await tuner.listModels();
116
137
  }
117
138
 
118
139
  if (args.model) {
@@ -290,31 +311,45 @@ export async function loadCommand(args, { lang, theme }) {
290
311
 
291
312
  let result;
292
313
  try {
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
- }),
299
- profile,
300
- force: !!args.force,
301
- dryRun: !!args['dry-run'],
302
- maxCandidates: Number(args.candidates) || 3,
303
- confirmApply: async (preview) => {
304
- anim.stop();
305
- out(c.b(`\n${t('cli.changes')}`));
306
- if (engineId === 'lmstudio') {
307
- preview.files.forEach((f) => out(` ${f}`));
308
- if (preview.restartsApp) out(c.y(` ${t('cli.lmsRestart')}`));
309
- } else {
310
- out(` ${t('cli.ollamaNewModel', { name: c.b(preview.tunedModel) })}`);
311
- out(` ${t('cli.ollamaServer', { env: Object.entries(preview.env).map(([k, v]) => `${k}=${v}`).join(' ') })}`);
312
- }
313
- const confirmed = args.yes || await confirm({ message: t('cli.confirmApply'), default: true });
314
- if (confirmed) anim.start(t('status.applying'));
315
- return confirmed;
316
- },
317
- });
314
+ if (isRemote) {
315
+ result = await client.loadModel({
316
+ engine: engineId,
317
+ model: modelKey,
318
+ ctx,
319
+ profile,
320
+ force: !!args.force,
321
+ candidates: Number(args.candidates) || 3,
322
+ }, (e) => {
323
+ if (e.type === 'progress') anim.update(e);
324
+ if (e.type === 'log') out(c.dim(' ' + e.line));
325
+ });
326
+ } else {
327
+ result = await tuner.load(modelKey, ctx, {
328
+ authorizeOllama: createOllamaAuthorizer({
329
+ mode: process.stdin.isTTY && process.stderr.isTTY && !args.json ? 'terminal' : 'noninteractive',
330
+ beforePrompt: () => anim.stop(),
331
+ afterPrompt: () => anim.start(t('status.applying')),
332
+ }),
333
+ profile,
334
+ force: !!args.force,
335
+ dryRun: !!args['dry-run'],
336
+ maxCandidates: Number(args.candidates) || 3,
337
+ confirmApply: async (preview) => {
338
+ anim.stop();
339
+ out(c.b(`\n${t('cli.changes')}`));
340
+ if (engineId === 'lmstudio') {
341
+ preview.files.forEach((f) => out(` ${f}`));
342
+ if (preview.restartsApp) out(c.y(` ${t('cli.lmsRestart')}`));
343
+ } else {
344
+ out(` ${t('cli.ollamaNewModel', { name: c.b(preview.tunedModel) })}`);
345
+ out(` ${t('cli.ollamaServer', { env: Object.entries(preview.env).map(([k, v]) => `${k}=${v}`).join(' ') })}`);
346
+ }
347
+ const confirmed = args.yes || await confirm({ message: t('cli.confirmApply'), default: true });
348
+ if (confirmed) anim.start(t('status.applying'));
349
+ return confirmed;
350
+ },
351
+ });
352
+ }
318
353
  } catch (e) {
319
354
  if (!/^errors\.ollama(?:Service|Authorization)/.test(e.code || '')) throw e;
320
355
  if (args.json) {
@@ -1,5 +1,5 @@
1
1
  import { select, checkbox, input, Separator } from '@inquirer/prompts';
2
- import { detectHardware, detectEngines, detectAgents, installMcpServer } from '../../core/index.js';
2
+ import { detectHardware, detectEngines, detectAgents, installMcpServer, readSettings } from '../../core/index.js';
3
3
  import { t as translate } from '../../i18n/index.js';
4
4
  import { getColors } from '../colors.js';
5
5
  import { withNav, NAV_BACK, backChoice } from '../nav.js';
@@ -93,11 +93,25 @@ export async function mcpCommand(args, { lang, theme }) {
93
93
  }
94
94
 
95
95
  // Install MCP server configuration
96
+ let targetHost = args.host;
97
+ if (!targetHost) {
98
+ const settings = await readSettings().catch(() => null);
99
+ const activeSrv = (settings?.servers || []).find((s) => s.id === settings?.activeServerId);
100
+ if (activeSrv && activeSrv.id !== 'local' && activeSrv.url) {
101
+ try {
102
+ const hostname = new URL(activeSrv.url).hostname;
103
+ if (hostname && hostname !== '127.0.0.1' && hostname !== 'localhost') {
104
+ targetHost = engineId === 'lmstudio' ? `http://${hostname}:1234/v1` : `http://${hostname}:11434`;
105
+ }
106
+ } catch {}
107
+ }
108
+ }
109
+
96
110
  const result = await installMcpServer({
97
111
  engineId,
98
112
  customName,
99
113
  agentIds: selectedAgents,
100
- host: args.host,
114
+ host: targetHost,
101
115
  apiKey: args.apiKey,
102
116
  });
103
117
 
@@ -1,4 +1,5 @@
1
- import { listPresets } from '../../core/index.js';
1
+ import { listPresets, readSettings } from '../../core/index.js';
2
+ import { TunerApiClient } from '../../core/client.js';
2
3
  import { t as translate } from '../../i18n/index.js';
3
4
  import { getColors } from '../colors.js';
4
5
 
@@ -6,7 +7,16 @@ export async function presetsCommand(args, { lang, theme }) {
6
7
  const c = getColors(theme);
7
8
  const t = (key, params) => translate(lang, key, params);
8
9
 
9
- const list = await listPresets({ engine: args.engine, modelKey: args.model });
10
+ const settings = await readSettings();
11
+ let list = [];
12
+ if (settings.activeServerId !== 'local') {
13
+ const client = TunerApiClient.fromSettings(settings);
14
+ const data = await client.listPresets({ engine: args.engine, model: args.model });
15
+ list = data.presets || [];
16
+ } else {
17
+ list = await listPresets({ engine: args.engine, modelKey: args.model });
18
+ }
19
+
10
20
  if (!list.length) {
11
21
  console.log(t('cli.noPresets'));
12
22
  return;
@@ -0,0 +1,59 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { configDir, readJson, resolveConnectableHost } from '../../core/util.js';
5
+ import { startServer } from '../../web/server.js';
6
+
7
+ export function serverConfigPath() {
8
+ return path.join(configDir(), 'server.json');
9
+ }
10
+
11
+ export async function readServerConfig() {
12
+ try {
13
+ return await readJson(serverConfigPath(), null);
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ export async function writeServerConfig(cfg) {
20
+ await fs.mkdir(configDir(), { recursive: true });
21
+ await fs.writeFile(
22
+ serverConfigPath(),
23
+ JSON.stringify(cfg, null, 2),
24
+ 'utf8'
25
+ );
26
+ }
27
+
28
+ export async function serveCommand(args = {}) {
29
+ const host = args.host || '0.0.0.0';
30
+ const port = args.port !== undefined ? Number(args.port) : 7860;
31
+
32
+ const existingConfig = await readServerConfig();
33
+ const token = args.token || existingConfig?.token || randomBytes(32).toString('hex');
34
+
35
+ await writeServerConfig({ port, host, token });
36
+
37
+ const serverResult = await startServer({
38
+ port,
39
+ host,
40
+ token,
41
+ serverMode: true,
42
+ });
43
+
44
+ const displayHost = resolveConnectableHost(host);
45
+ const resolvedPort = serverResult.port || port;
46
+ const connectableUrl = `http://${displayHost}:${resolvedPort}`;
47
+
48
+ console.log(`LLM Tuner server mode running on ${connectableUrl}`);
49
+ console.log(`Authentication Token: ${token}`);
50
+
51
+ return {
52
+ server: serverResult.server,
53
+ url: connectableUrl,
54
+ port: resolvedPort,
55
+ host,
56
+ connectableHost: displayHost,
57
+ token,
58
+ };
59
+ }
@@ -1,19 +1,35 @@
1
1
  import { select } from '@inquirer/prompts';
2
- import { writeSettings } from '../../core/index.js';
2
+ import { writeSettings, resolveConnectableHost } from '../../core/index.js';
3
3
  import { t as translate } from '../../i18n/index.js';
4
+ import { createServerAutostart } from '../../platform/server-autostart.js';
5
+ import { serverConfigPath, readServerConfig } from './serve.js';
4
6
  import { getColors } from '../colors.js';
5
7
  import { withNav, NAV_BACK, backChoice } from '../nav.js';
6
8
 
7
- export async function settingsCommand(args, { lang, theme }) {
9
+ export async function settingsCommand(args, { lang, theme }, dependencies = {}) {
10
+ const {
11
+ selectPrompt = select,
12
+ autostart = createServerAutostart({ configPath: serverConfigPath() }),
13
+ readConfig = readServerConfig
14
+ } = dependencies;
8
15
  const c = getColors(theme);
9
16
  const t = (key, params) => translate(lang, key, params);
17
+ let startupEnabled;
18
+ try {
19
+ startupEnabled = await autostart.status();
20
+ } catch (e) {
21
+ startupEnabled = null;
22
+ console.error(c.y(t('cli.autostartFailed', { error: e.message })));
23
+ }
10
24
 
11
25
  while (true) {
12
- const action = await withNav(() => select({
26
+ const action = await withNav(() => selectPrompt({
13
27
  message: t('cli.settingsMenu'),
14
28
  choices: [
15
29
  { name: `${t('cli.settingsLanguage')}: ${lang === 'es' ? 'Español' : 'English'} (${lang})`, value: 'lang' },
16
30
  { name: `${t('cli.settingsTheme')}: ${theme.charAt(0).toUpperCase() + theme.slice(1)}`, value: 'theme' },
31
+ { name: `${t('cli.settingsAutostart')}: ${t(startupEnabled === null ? 'cli.unknown' : startupEnabled ? 'cli.enabled' : 'cli.disabled')}`, value: 'serverAutostart' },
32
+ { name: t('cli.serverConnection'), value: 'serverConnection' },
17
33
  { name: t('cli.settingsExit'), value: 'exit' },
18
34
  ],
19
35
  }));
@@ -24,7 +40,7 @@ export async function settingsCommand(args, { lang, theme }) {
24
40
  }
25
41
 
26
42
  if (action === 'lang') {
27
- const res = await withNav(() => select({
43
+ const res = await withNav(() => selectPrompt({
28
44
  message: t('cli.settingsLanguage'),
29
45
  default: lang,
30
46
  choices: [
@@ -42,7 +58,7 @@ export async function settingsCommand(args, { lang, theme }) {
42
58
  }
43
59
  }
44
60
  } else if (action === 'theme') {
45
- const res = await withNav(() => select({
61
+ const res = await withNav(() => selectPrompt({
46
62
  message: t('cli.settingsTheme'),
47
63
  default: theme,
48
64
  choices: [
@@ -60,6 +76,45 @@ export async function settingsCommand(args, { lang, theme }) {
60
76
  console.error(c.y(t('cli.themeNotSaved', { error: e.message })));
61
77
  }
62
78
  }
79
+ } else if (action === 'serverAutostart') {
80
+ const enabled = startupEnabled === true;
81
+ const res = await withNav(() => selectPrompt({
82
+ message: t('cli.settingsAutostart'),
83
+ default: enabled,
84
+ choices: [
85
+ { name: t('cli.settingsAutostartEnable'), value: true },
86
+ { name: t('cli.settingsAutostartDisable'), value: false },
87
+ backChoice(t, c),
88
+ ],
89
+ }));
90
+ if (res !== NAV_BACK) {
91
+ try {
92
+ const updated = await autostart.setEnabled(res);
93
+ if (updated !== res) throw new Error('The operating system did not confirm the requested startup state.');
94
+ startupEnabled = res;
95
+ console.log(c.g(t(res ? 'cli.autostartEnabled' : 'cli.autostartDisabled')));
96
+ } catch (e) {
97
+ console.error(c.y(t('cli.autostartFailed', { error: e.message })));
98
+ }
99
+ }
100
+ } else if (action === 'serverConnection') {
101
+ try {
102
+ const cfg = await readConfig();
103
+ if (!cfg) {
104
+ console.log(c.y(t('cli.serverNoConfig')));
105
+ } else {
106
+ const isValid = typeof cfg.host === 'string' && cfg.host.length > 0 &&
107
+ Number.isInteger(cfg.port) && cfg.port >= 1 && cfg.port <= 65535 &&
108
+ typeof cfg.token === 'string' && cfg.token.length > 0;
109
+ if (!isValid) {
110
+ throw new Error(t('cli.serverConnectionInvalid'));
111
+ }
112
+ const displayHost = resolveConnectableHost(cfg.host);
113
+ console.log(t('cli.serverConnectionDetails', { host: displayHost, port: cfg.port, token: cfg.token }));
114
+ }
115
+ } catch (e) {
116
+ console.error(c.y(t('common.error', { message: e.message })));
117
+ }
63
118
  }
64
119
  }
65
120
  }
package/src/cli/router.js CHANGED
@@ -4,6 +4,7 @@ import { t as translate, LOCALES, errorText } from '../i18n/index.js';
4
4
  import { loadCommand } from './commands/load.js';
5
5
  import { mcpCommand } from './commands/mcp.js';
6
6
  import { presetsCommand } from './commands/presets.js';
7
+ import { serveCommand } from './commands/serve.js';
7
8
  import { settingsCommand } from './commands/settings.js';
8
9
  import { helpCommand } from './commands/help.js';
9
10
  import { withNav } from './nav.js';
@@ -30,6 +31,10 @@ export function parseCliArgs(argv) {
30
31
  command = 'presets';
31
32
  continue;
32
33
  }
34
+ if (a === '/serve' || a === 'serve') {
35
+ command = 'serve';
36
+ continue;
37
+ }
33
38
  if (a === '/settings' || a === 'settings' || a === '/setting' || a === 'setting') {
34
39
  command = 'settings';
35
40
  args.settings = true;
@@ -62,6 +67,10 @@ export function parseCliArgs(argv) {
62
67
  command = 'presets';
63
68
  continue;
64
69
  }
70
+ if (k === 'serve') {
71
+ command = 'serve';
72
+ continue;
73
+ }
65
74
  if (k === 'help') {
66
75
  args.help = true;
67
76
  command = 'help';
@@ -131,6 +140,20 @@ export async function routeCli(argv, env = {}) {
131
140
  }
132
141
  }
133
142
 
143
+ if ('server' in args) {
144
+ const s = await readSettings();
145
+ const target = s.servers.find((srv) => srv.id === args.server || srv.name === args.server);
146
+ if (!target) {
147
+ console.error(`Unknown server "${args.server}". Valid servers: ${s.servers.map((x) => x.id).join(', ')}`);
148
+ return 1;
149
+ }
150
+ try {
151
+ await writeSettings({ activeServerId: target.id });
152
+ } catch (e) {
153
+ console.error(`Warning: ${e.message}`);
154
+ }
155
+ }
156
+
134
157
  if (command === 'version' || args.version) {
135
158
  console.log(CURRENT_VERSION);
136
159
  return 0;
@@ -141,6 +164,11 @@ export async function routeCli(argv, env = {}) {
141
164
  return 0;
142
165
  }
143
166
 
167
+ if (command === 'serve') {
168
+ await serveCommand(args);
169
+ return 0;
170
+ }
171
+
144
172
  // Interactive menu mode when run bare (persistent loop until /exit)
145
173
  if (command === 'menu') {
146
174
  const isInteractive = process.stdin.isTTY || env.forceInteractive;
@@ -163,6 +191,7 @@ export async function routeCli(argv, env = {}) {
163
191
  { name: `/load ${activeT('cli.cmdLoadDesc')}`, value: 'load' },
164
192
  { name: `/mcp ${activeT('cli.cmdMcpDesc')}`, value: 'mcp' },
165
193
  { name: `/presets ${activeT('cli.cmdPresetsDesc')}`, value: 'presets' },
194
+ { name: `/serve ${activeT('cli.cmdServeDesc')}`, value: 'serve' },
166
195
  { name: `/settings ${activeT('cli.cmdSettingsDesc')}`, value: 'settings' },
167
196
  { name: `/help ${activeT('cli.cmdHelpDesc')}`, value: 'help' },
168
197
  { name: `/exit ${activeT('cli.cmdExitDesc')}`, value: 'exit' },
@@ -190,6 +219,9 @@ export async function routeCli(argv, env = {}) {
190
219
  case 'presets':
191
220
  await presetsCommand(args, { lang: currentLang, theme: currentTheme });
192
221
  break;
222
+ case 'serve':
223
+ await serveCommand(args);
224
+ break;
193
225
  case 'settings':
194
226
  await settingsCommand(args, { lang: currentLang, theme: currentTheme });
195
227
  {
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { serveCommand } from './commands/serve.js';
6
+
7
+ function validConfig(config) {
8
+ return config && typeof config === 'object' && !Array.isArray(config)
9
+ && typeof config.host === 'string' && config.host.length > 0
10
+ && Number.isInteger(config.port) && config.port > 0 && config.port <= 65535
11
+ && typeof config.token === 'string' && config.token.length > 0;
12
+ }
13
+
14
+ export async function startSavedServer({ configDir, environment = process.env, readConfig, start = serveCommand } = {}) {
15
+ if (!configDir) throw new Error('A configuration directory is required to start the saved server.');
16
+ environment.LLM_TUNER_CONFIG_DIR = configDir;
17
+ const config = readConfig
18
+ ? await readConfig()
19
+ : await fs.readFile(path.join(configDir, 'server.json'), 'utf8').then(JSON.parse).catch((error) => {
20
+ throw new Error(`Could not read the saved server configuration at ${configDir}: ${error.message}`);
21
+ });
22
+ if (!validConfig(config)) throw new Error(`The saved server configuration at ${configDir} is invalid; the server was not started.`);
23
+ const options = { host: config.host, port: config.port, token: config.token };
24
+ if (start !== serveCommand) return start(options);
25
+ // Manual /serve prints the token; a login launcher must not leak it to startup logs.
26
+ const log = console.log;
27
+ console.log = () => {};
28
+ try {
29
+ return await start(options);
30
+ } finally {
31
+ console.log = log;
32
+ }
33
+ }
34
+
35
+ function configDirectoryFrom(argv) {
36
+ const index = argv.indexOf('--config-dir');
37
+ const value = index >= 0 ? argv[index + 1] : null;
38
+ if (!value) throw new Error('Missing --config-dir for the login-started server.');
39
+ return value;
40
+ }
41
+
42
+ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
43
+ startSavedServer({ configDir: configDirectoryFrom(process.argv.slice(2)) }).catch((error) => {
44
+ console.error(`Could not start the login-configured server: ${error.message}`);
45
+ process.exitCode = 1;
46
+ });
47
+ }
@@ -0,0 +1,127 @@
1
+ import { getActiveServer } from './settings.js';
2
+
3
+ export class TunerApiClient {
4
+ constructor({ baseUrl = 'http://127.0.0.1:7860', token = null } = {}) {
5
+ this.baseUrl = String(baseUrl || '').replace(/\/+$/, '');
6
+ this.token = token;
7
+ }
8
+
9
+ async _request(path, options = {}) {
10
+ const url = `${this.baseUrl}${path}`;
11
+ const headers = { ...options.headers };
12
+
13
+ if (this.token) {
14
+ headers['Authorization'] = `Bearer ${this.token}`;
15
+ }
16
+
17
+ if (options.body && typeof options.body === 'object') {
18
+ headers['Content-Type'] = 'application/json';
19
+ options.body = JSON.stringify(options.body);
20
+ }
21
+
22
+ const response = await fetch(url, { ...options, headers });
23
+ if (!response.ok) {
24
+ let errMessage = `HTTP ${response.status}: ${response.statusText}`;
25
+ let errCode = null;
26
+ try {
27
+ const errJson = await response.json();
28
+ if (errJson?.error) errMessage = errJson.error;
29
+ if (errJson?.code) errCode = errJson.code;
30
+ } catch {}
31
+ const err = new Error(errMessage);
32
+ err.status = response.status;
33
+ if (errCode) err.code = errCode;
34
+ throw err;
35
+ }
36
+
37
+ return response.json();
38
+ }
39
+
40
+ async getState() {
41
+ return this._request('/api/state');
42
+ }
43
+
44
+ async listModels(engine) {
45
+ const query = engine ? `?engine=${encodeURIComponent(engine)}` : '';
46
+ return this._request(`/api/models${query}`);
47
+ }
48
+
49
+ async planModel({ engine, model, ctx }) {
50
+ return this._request('/api/plan', {
51
+ method: 'POST',
52
+ body: { engine, model, ctx },
53
+ });
54
+ }
55
+
56
+ async listPresets({ engine, model } = {}) {
57
+ const params = new URLSearchParams();
58
+ if (engine) params.set('engine', engine);
59
+ if (model) params.set('model', model);
60
+ const query = params.toString() ? `?${params.toString()}` : '';
61
+ return this._request(`/api/presets${query}`);
62
+ }
63
+
64
+ async chat({ engine, model, messages }) {
65
+ return this._request('/api/chat', {
66
+ method: 'POST',
67
+ body: { engine, model, messages },
68
+ });
69
+ }
70
+
71
+ async loadModel({ engine, model, ctx, profile, force, candidates }, onProgress) {
72
+ const { jobId } = await this._request('/api/load', {
73
+ method: 'POST',
74
+ body: { engine, model, ctx, profile, force, candidates },
75
+ });
76
+
77
+ if (!onProgress) return { jobId };
78
+
79
+ const response = await fetch(`${this.baseUrl}/api/jobs/${jobId}/events`, {
80
+ headers: this.token ? { 'Authorization': `Bearer ${this.token}` } : {},
81
+ });
82
+
83
+ const reader = response.body.getReader();
84
+ const decoder = new TextDecoder();
85
+ let buffer = '';
86
+
87
+ try {
88
+ while (true) {
89
+ const { value, done } = await reader.read();
90
+ if (done) break;
91
+
92
+ buffer += decoder.decode(value, { stream: true });
93
+ const lines = buffer.split('\n');
94
+ buffer = lines.pop();
95
+
96
+ for (const line of lines) {
97
+ const trimmed = line.trim();
98
+ if (!trimmed || !trimmed.startsWith('data: ')) continue;
99
+
100
+ const data = JSON.parse(trimmed.slice(6));
101
+ if (typeof onProgress === 'function') {
102
+ onProgress(data);
103
+ }
104
+
105
+ if (data.type === 'finished') {
106
+ if (data.ok === false) {
107
+ const err = new Error(data.error || 'Job failed');
108
+ if (data.code) err.code = data.code;
109
+ throw err;
110
+ }
111
+ return data.result;
112
+ }
113
+ }
114
+ }
115
+ } finally {
116
+ await reader.cancel();
117
+ }
118
+ }
119
+
120
+ static fromSettings(settings) {
121
+ const active = getActiveServer(settings);
122
+ return new TunerApiClient({
123
+ baseUrl: active.url,
124
+ token: active.token,
125
+ });
126
+ }
127
+ }
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';