llm-tuner 0.3.0 → 0.4.1
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/README.es.md +19 -3
- package/README.md +19 -3
- package/package.json +1 -1
- package/src/cli/animation.js +85 -0
- package/src/cli/colors.js +12 -0
- package/src/cli/commands/help.js +7 -0
- package/src/cli/commands/load.js +338 -0
- package/src/cli/commands/mcp.js +118 -0
- package/src/cli/commands/presets.js +21 -0
- package/src/cli/commands/settings.js +65 -0
- package/src/cli/index.js +10 -367
- package/src/cli/nav.js +82 -0
- package/src/cli/router.js +243 -0
- package/src/core/engines/lmstudio.js +69 -16
- package/src/core/index.js +1 -0
- package/src/core/mcp/adapters/json-adapter.js +37 -0
- package/src/core/mcp/adapters/toml-adapter.js +82 -0
- package/src/core/mcp/index.js +82 -0
- package/src/core/mcp/registry.js +121 -0
- package/src/i18n/en.js +46 -4
- package/src/i18n/es.js +46 -4
- package/src/web/public/app.js +172 -1
- package/src/web/public/index.html +33 -0
- package/src/web/public/style.css +29 -0
- package/src/web/server.js +20 -1
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { select } from '@inquirer/prompts';
|
|
2
|
+
import { readSettings, writeSettings, THEMES, PROFILES, CURRENT_VERSION } from '../core/index.js';
|
|
3
|
+
import { t as translate, LOCALES, errorText } from '../i18n/index.js';
|
|
4
|
+
import { loadCommand } from './commands/load.js';
|
|
5
|
+
import { mcpCommand } from './commands/mcp.js';
|
|
6
|
+
import { presetsCommand } from './commands/presets.js';
|
|
7
|
+
import { settingsCommand } from './commands/settings.js';
|
|
8
|
+
import { helpCommand } from './commands/help.js';
|
|
9
|
+
import { withNav } from './nav.js';
|
|
10
|
+
|
|
11
|
+
export function parseCliArgs(argv) {
|
|
12
|
+
const flags = new Set(['help', 'version', 'yes', 'dry-run', 'json', 'force', 'presets', 'settings']);
|
|
13
|
+
const tuningKeys = new Set(['engine', 'model', 'ctx', 'profile', 'candidates', 'force', 'dry-run', 'yes', 'json']);
|
|
14
|
+
const args = {};
|
|
15
|
+
let command = null;
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
const a = argv[i];
|
|
19
|
+
|
|
20
|
+
// Slash commands or positional aliases
|
|
21
|
+
if (a === '/load' || a === 'load') {
|
|
22
|
+
command = 'load';
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (a === '/mcp' || a === 'mcp') {
|
|
26
|
+
command = 'mcp';
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (a === '/presets' || a === 'presets') {
|
|
30
|
+
command = 'presets';
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (a === '/settings' || a === 'settings' || a === '/setting' || a === 'setting') {
|
|
34
|
+
command = 'settings';
|
|
35
|
+
args.settings = true;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (a === '/help' || a === 'help') {
|
|
39
|
+
command = 'help';
|
|
40
|
+
args.help = true;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (a === '/exit' || a === 'exit') {
|
|
44
|
+
command = 'exit';
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Short flags
|
|
49
|
+
if (a === '-h') { args.help = true; command = 'help'; continue; }
|
|
50
|
+
if (a === '-v') { args.version = true; command = 'version'; continue; }
|
|
51
|
+
|
|
52
|
+
// Double-dash flags & options
|
|
53
|
+
if (a.startsWith('--')) {
|
|
54
|
+
const k = a.slice(2);
|
|
55
|
+
if (k === 'setting' || k === 'settings') {
|
|
56
|
+
args.settings = true;
|
|
57
|
+
command = 'settings';
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (k === 'presets') {
|
|
61
|
+
args.presets = true;
|
|
62
|
+
command = 'presets';
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (k === 'help') {
|
|
66
|
+
args.help = true;
|
|
67
|
+
command = 'help';
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (k === 'version') {
|
|
71
|
+
args.version = true;
|
|
72
|
+
command = 'version';
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (flags.has(k)) {
|
|
77
|
+
args[k] = true;
|
|
78
|
+
} else {
|
|
79
|
+
args[k] = argv[++i];
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Normalize dry-run / dryRun
|
|
86
|
+
if (args['dry-run']) args.dryRun = true;
|
|
87
|
+
else if (args.dryRun) args['dry-run'] = true;
|
|
88
|
+
|
|
89
|
+
// Route fallback: if command not explicitly set
|
|
90
|
+
if (!command) {
|
|
91
|
+
const hasTuning = Object.keys(args).some((k) => tuningKeys.has(k));
|
|
92
|
+
if (hasTuning) {
|
|
93
|
+
command = 'load';
|
|
94
|
+
} else {
|
|
95
|
+
command = 'menu';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { command, ...args };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function routeCli(argv, env = {}) {
|
|
103
|
+
const parsed = parseCliArgs(argv);
|
|
104
|
+
let { command, ...args } = parsed;
|
|
105
|
+
|
|
106
|
+
let { lang, theme } = await readSettings();
|
|
107
|
+
const t = (key, params) => translate(lang, key, params);
|
|
108
|
+
|
|
109
|
+
if ('lang' in args) {
|
|
110
|
+
if (!LOCALES.includes(args.lang)) {
|
|
111
|
+
console.error(translate('en', 'errors.unknownLocale', { lang: args.lang ?? '', list: LOCALES.join(', ') }));
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
lang = args.lang;
|
|
115
|
+
try { await writeSettings({ lang }); } catch (e) { console.error(`Warning: ${e.message}`); }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if ('theme' in args) {
|
|
119
|
+
if (!THEMES.includes(args.theme)) {
|
|
120
|
+
console.error(translate(lang, 'errors.unknownTheme', { theme: args.theme ?? '', list: THEMES.join(', ') }));
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
123
|
+
theme = args.theme;
|
|
124
|
+
try { await writeSettings({ theme }); } catch (e) { console.error(`Warning: ${e.message}`); }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if ('profile' in args) {
|
|
128
|
+
if (!PROFILES.includes(args.profile)) {
|
|
129
|
+
console.error(translate(lang, 'errors.unknownProfile', { profile: args.profile ?? '', list: PROFILES.join(', ') }));
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (command === 'version' || args.version) {
|
|
135
|
+
console.log(CURRENT_VERSION);
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (command === 'help' || args.help) {
|
|
140
|
+
await helpCommand(args, { lang, theme });
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Interactive menu mode when run bare (persistent loop until /exit)
|
|
145
|
+
if (command === 'menu') {
|
|
146
|
+
const isInteractive = process.stdin.isTTY || env.forceInteractive;
|
|
147
|
+
if (!isInteractive) {
|
|
148
|
+
await loadCommand(args, { lang, theme });
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let currentLang = lang;
|
|
153
|
+
let currentTheme = theme;
|
|
154
|
+
|
|
155
|
+
while (true) {
|
|
156
|
+
const activeT = (k, p) => translate(currentLang, k, p);
|
|
157
|
+
let selectedCommand;
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
selectedCommand = await withNav(() => select({
|
|
161
|
+
message: activeT('cli.menuTitle'),
|
|
162
|
+
choices: [
|
|
163
|
+
{ name: `/load ${activeT('cli.cmdLoadDesc')}`, value: 'load' },
|
|
164
|
+
{ name: `/mcp ${activeT('cli.cmdMcpDesc')}`, value: 'mcp' },
|
|
165
|
+
{ name: `/presets ${activeT('cli.cmdPresetsDesc')}`, value: 'presets' },
|
|
166
|
+
{ name: `/settings ${activeT('cli.cmdSettingsDesc')}`, value: 'settings' },
|
|
167
|
+
{ name: `/help ${activeT('cli.cmdHelpDesc')}`, value: 'help' },
|
|
168
|
+
{ name: `/exit ${activeT('cli.cmdExitDesc')}`, value: 'exit' },
|
|
169
|
+
],
|
|
170
|
+
}), { allowBack: false });
|
|
171
|
+
} catch (e) {
|
|
172
|
+
if (e?.name === 'ExitPromptError') {
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
throw e;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (selectedCommand === 'exit') {
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
switch (selectedCommand) {
|
|
184
|
+
case 'load':
|
|
185
|
+
await loadCommand(args, { lang: currentLang, theme: currentTheme });
|
|
186
|
+
break;
|
|
187
|
+
case 'mcp':
|
|
188
|
+
await mcpCommand(args, { lang: currentLang, theme: currentTheme });
|
|
189
|
+
break;
|
|
190
|
+
case 'presets':
|
|
191
|
+
await presetsCommand(args, { lang: currentLang, theme: currentTheme });
|
|
192
|
+
break;
|
|
193
|
+
case 'settings':
|
|
194
|
+
await settingsCommand(args, { lang: currentLang, theme: currentTheme });
|
|
195
|
+
{
|
|
196
|
+
const s = await readSettings();
|
|
197
|
+
currentLang = s.lang;
|
|
198
|
+
currentTheme = s.theme;
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
case 'help':
|
|
202
|
+
await helpCommand(args, { lang: currentLang, theme: currentTheme });
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
} catch (e) {
|
|
206
|
+
if (e?.name === 'ExitPromptError') {
|
|
207
|
+
// Sub-command was cancelled, return to main menu
|
|
208
|
+
} else {
|
|
209
|
+
console.error(`\n${activeT('common.error', { message: errorText(currentLang, e) })}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
console.log('');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
switch (command) {
|
|
219
|
+
case 'load':
|
|
220
|
+
await loadCommand(args, { lang, theme });
|
|
221
|
+
return 0;
|
|
222
|
+
case 'mcp':
|
|
223
|
+
await mcpCommand(args, { lang, theme });
|
|
224
|
+
return 0;
|
|
225
|
+
case 'presets':
|
|
226
|
+
await presetsCommand(args, { lang, theme });
|
|
227
|
+
return 0;
|
|
228
|
+
case 'settings':
|
|
229
|
+
await settingsCommand(args, { lang, theme });
|
|
230
|
+
return 0;
|
|
231
|
+
case 'help':
|
|
232
|
+
await helpCommand(args, { lang, theme });
|
|
233
|
+
return 0;
|
|
234
|
+
default:
|
|
235
|
+
console.error(`Unknown command: ${command}`);
|
|
236
|
+
return 1;
|
|
237
|
+
}
|
|
238
|
+
} catch (e) {
|
|
239
|
+
if (e?.name === 'ExitPromptError') return 130;
|
|
240
|
+
console.error(`\n${t('common.error', { message: errorText(lang, e) })}`);
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -170,9 +170,10 @@ export const lmstudio = {
|
|
|
170
170
|
|
|
171
171
|
async benchmarkViaLms(ctx, model, candidate, opts) {
|
|
172
172
|
const bin = ctx.detection.bin;
|
|
173
|
-
const
|
|
173
|
+
const modelKey = model.key || model;
|
|
174
|
+
const restore = await this.writeModelConfig(ctx, modelKey, candidate, model.meta);
|
|
174
175
|
try {
|
|
175
|
-
const args = ['load',
|
|
176
|
+
const args = ['load', modelKey, '-y', '--identifier', 'llm-tuner-bench', '-c', String(candidate.ctx), '--gpu', candidate.fullOffload ? 'max' : String(candidate.gpuLayers / candidate.totalLayers)];
|
|
176
177
|
const r = await run(bin, args, { timeout: 600_000 });
|
|
177
178
|
if (r.code !== 0) return { ok: false, error: (r.stderr || r.stdout).slice(-400) };
|
|
178
179
|
return await runApiBenchmark({ kind: 'lmstudio', baseUrl: 'http://127.0.0.1:1234', model: 'llm-tuner-bench', ...opts });
|
|
@@ -182,17 +183,52 @@ export const lmstudio = {
|
|
|
182
183
|
}
|
|
183
184
|
},
|
|
184
185
|
|
|
185
|
-
|
|
186
|
+
/** Resolve the concrete indexedModelIdentifier for a given key or file. */
|
|
187
|
+
async resolveConcreteIdentifier(ctx, key, meta) {
|
|
188
|
+
if (!key) return key;
|
|
189
|
+
if (key.includes('/') && key.endsWith('.gguf')) return key;
|
|
190
|
+
const home = ctx.detection?.home || (await lmsHome());
|
|
191
|
+
const idx = await readJson(path.join(home, '.internal', 'model-index-cache.json'), { models: [] });
|
|
192
|
+
const entries = idx.models || [];
|
|
193
|
+
const byId = (id) => entries.find((e) => e.indexedModelIdentifier === id && !e.virtual);
|
|
194
|
+
let entry = null;
|
|
195
|
+
if (meta?.file) {
|
|
196
|
+
entry = entries.find((e) => e.entryPoint?.absPath === meta.file || (e.concreteModelDirAbsolutePath && meta.file.startsWith(e.concreteModelDirAbsolutePath)));
|
|
197
|
+
}
|
|
198
|
+
if (!entry) {
|
|
199
|
+
entry = entries.find((e) => e.indexedModelIdentifier === key || e.defaultIdentifier === key || e.virtual?.baseChain?.[0] === key);
|
|
200
|
+
}
|
|
201
|
+
const concrete = entry?.virtual?.concreteModelIndexedModelIdentifier || entry?.virtual?.baseChain?.at?.(-1);
|
|
202
|
+
if (concrete) entry = byId(concrete) || entry;
|
|
203
|
+
if (entry?.indexedModelIdentifier) return entry.indexedModelIdentifier;
|
|
204
|
+
if (entry?.entryPoint?.relPath) return entry.entryPoint.relPath;
|
|
205
|
+
if (ctx.detection?.bin) {
|
|
206
|
+
const r = await run(ctx.detection.bin, ['ls', '--json']);
|
|
207
|
+
if (r.code === 0) {
|
|
208
|
+
try {
|
|
209
|
+
const list = JSON.parse(r.stdout);
|
|
210
|
+
const found = (Array.isArray(list) ? list : []).find((m) => m && (m.modelKey === key || m.path === key || m.indexedModelIdentifier === key));
|
|
211
|
+
if (found?.indexedModelIdentifier || found?.path) return found.indexedModelIdentifier || found.path;
|
|
212
|
+
} catch { /* ignore */ }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return key;
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async configPaths(ctx, key, meta) {
|
|
186
219
|
const internal = path.join(ctx.detection.home, '.internal');
|
|
220
|
+
const concrete = key ? await this.resolveConcreteIdentifier(ctx, key, meta) : '';
|
|
187
221
|
return {
|
|
188
|
-
modelConfig: path.join(internal, 'user-concrete-model-default-config', `${key}.json`),
|
|
222
|
+
modelConfig: path.join(internal, 'user-concrete-model-default-config', `${concrete || key}.json`),
|
|
223
|
+
aliasConfig: concrete && concrete !== key ? path.join(internal, 'user-concrete-model-default-config', `${key}.json`) : null,
|
|
189
224
|
hardwareConfig: path.join(internal, 'hardware-config.json'),
|
|
190
225
|
};
|
|
191
226
|
},
|
|
192
227
|
|
|
193
|
-
async writeModelConfig(ctx, key, c) {
|
|
194
|
-
const
|
|
195
|
-
const
|
|
228
|
+
async writeModelConfig(ctx, key, c, meta) {
|
|
229
|
+
const paths = await this.configPaths(ctx, key, meta);
|
|
230
|
+
const file = paths.modelConfig;
|
|
231
|
+
const cur = (await readJson(file)) || (paths.aliasConfig ? (await readJson(paths.aliasConfig)) : null) || { preset: '', operation: { fields: [] }, load: { fields: [] } };
|
|
196
232
|
const fields = new Map((cur.load?.fields || []).map((f) => [f.key, f.value]));
|
|
197
233
|
const set = (k, v) => fields.set(k, v);
|
|
198
234
|
set('llm.load.contextLength', c.ctx);
|
|
@@ -207,15 +243,24 @@ export const lmstudio = {
|
|
|
207
243
|
set('llm.load.llama.kCacheQuantizationType', { checked: c.kvType !== 'f16', value: c.kvType });
|
|
208
244
|
set('llm.load.llama.vCacheQuantizationType', { checked: c.kvType !== 'f16', value: c.kvType });
|
|
209
245
|
if (c.cpuMoeLayers) set('llm.load.numCpuExpertLayersRatio', +(c.cpuMoeLayers / (c.totalLayers - 1)).toFixed(3));
|
|
210
|
-
const next = { ...cur, load: { ...(cur.load || {}), fields: [...fields].map(([
|
|
211
|
-
|
|
246
|
+
const next = { ...cur, load: { ...(cur.load || {}), fields: [...fields].map(([k, value]) => ({ key: k, value })) } };
|
|
247
|
+
const fileExists = await exists(file);
|
|
248
|
+
if (fileExists && JSON.stringify(next) === JSON.stringify(cur)) {
|
|
249
|
+
if (paths.aliasConfig && !(await exists(paths.aliasConfig))) {
|
|
250
|
+
await writeJsonWithBackup(paths.aliasConfig, next).catch(() => null);
|
|
251
|
+
}
|
|
252
|
+
return { file, backup: null, unchanged: true };
|
|
253
|
+
}
|
|
212
254
|
const backup = await writeJsonWithBackup(file, next);
|
|
255
|
+
if (paths.aliasConfig) {
|
|
256
|
+
await writeJsonWithBackup(paths.aliasConfig, next).catch(() => null);
|
|
257
|
+
}
|
|
213
258
|
return { file, backup };
|
|
214
259
|
},
|
|
215
260
|
|
|
216
261
|
/** hardware-config.json is superjson: {"json":[[backendName,{fields:[...]}]],"meta":{...}} */
|
|
217
262
|
async writeHardwareConfig(ctx, c) {
|
|
218
|
-
const file = this.configPaths(ctx, '').hardwareConfig;
|
|
263
|
+
const file = (await this.configPaths(ctx, '')).hardwareConfig;
|
|
219
264
|
const be = await this.backend(ctx);
|
|
220
265
|
const backendName = be?.name || 'llama.cpp';
|
|
221
266
|
const cur = (await readJson(file)) || { json: [], meta: { values: ['map'] } };
|
|
@@ -269,7 +314,7 @@ export const lmstudio = {
|
|
|
269
314
|
|
|
270
315
|
/** Does hardware-config.json already match what this candidate needs? */
|
|
271
316
|
async isHardwareApplied(ctx, c) {
|
|
272
|
-
const file = this.configPaths(ctx, '').hardwareConfig;
|
|
317
|
+
const file = (await this.configPaths(ctx, '')).hardwareConfig;
|
|
273
318
|
const be = await this.backend(ctx);
|
|
274
319
|
const cur = await readJson(file);
|
|
275
320
|
const entry = (cur?.json || []).find(([name]) => name === (be?.name || 'llama.cpp'));
|
|
@@ -288,7 +333,8 @@ export const lmstudio = {
|
|
|
288
333
|
* overwritten by the app on exit, so the app is closed while it changes.
|
|
289
334
|
*/
|
|
290
335
|
async apply(ctx, model, c, { closeApp = true, relaunch = true, dryRun = false } = {}) {
|
|
291
|
-
const
|
|
336
|
+
const modelKey = model.key || model;
|
|
337
|
+
const paths = await this.configPaths(ctx, modelKey, model.meta);
|
|
292
338
|
const hardwareOk = await this.isHardwareApplied(ctx, c);
|
|
293
339
|
const det = await this.detect();
|
|
294
340
|
const plan = { files: hardwareOk ? [paths.modelConfig] : [paths.modelConfig, paths.hardwareConfig], restartsApp: !hardwareOk && det.appRunning, candidate: c };
|
|
@@ -304,7 +350,7 @@ export const lmstudio = {
|
|
|
304
350
|
if (b.backup) backups.push(b.backup);
|
|
305
351
|
changes.push({ code: 'changes.lmsHardware' });
|
|
306
352
|
}
|
|
307
|
-
const a = await this.writeModelConfig(ctx,
|
|
353
|
+
const a = await this.writeModelConfig(ctx, modelKey, c, model.meta);
|
|
308
354
|
if (a.backup) backups.push(a.backup);
|
|
309
355
|
if (!a.unchanged) changes.push(c.fullOffload
|
|
310
356
|
? { code: 'changes.lmsModelFull', params: { ctx: c.ctx, kvType: c.kvType, threads: c.threads } }
|
|
@@ -317,16 +363,23 @@ export const lmstudio = {
|
|
|
317
363
|
},
|
|
318
364
|
|
|
319
365
|
/** Load the model the normal way (using the saved config) and optionally measure it. */
|
|
320
|
-
async load(ctx, model, { depthTokens = 0, genTokens = 200, onProgress } = {}) {
|
|
366
|
+
async load(ctx, model, { candidate, depthTokens = 0, genTokens = 200, onProgress } = {}) {
|
|
321
367
|
const bin = ctx.detection.bin;
|
|
322
368
|
if (!bin) return benchError('bench.lmsUnavailable');
|
|
323
369
|
await run(bin, ['server', 'start']);
|
|
324
370
|
await run(bin, ['unload', '--all']);
|
|
325
371
|
onProgress?.({ phase: 'load' });
|
|
326
|
-
const
|
|
372
|
+
const modelKey = model.key || model;
|
|
373
|
+
const args = ['load', modelKey, '-y'];
|
|
374
|
+
if (candidate?.ctx) args.push('-c', String(candidate.ctx));
|
|
375
|
+
if (candidate?.gpuLayers != null && candidate?.totalLayers) {
|
|
376
|
+
args.push('--gpu', candidate.fullOffload ? 'max' : String(+(candidate.gpuLayers / candidate.totalLayers).toFixed(3)));
|
|
377
|
+
}
|
|
378
|
+
if (candidate?.parallel) args.push('--parallel', String(candidate.parallel));
|
|
379
|
+
const r = await run(bin, args, { timeout: 600_000 });
|
|
327
380
|
if (r.code !== 0) return { ok: false, error: (r.stderr || r.stdout).slice(-400) };
|
|
328
381
|
const url = ctx.detection?.apiUrl || apiUrl();
|
|
329
|
-
return runApiBenchmark({ kind: 'lmstudio', baseUrl: url, model:
|
|
382
|
+
return runApiBenchmark({ kind: 'lmstudio', baseUrl: url, model: modelKey, depthTokens, genTokens, onProgress });
|
|
330
383
|
},
|
|
331
384
|
|
|
332
385
|
async chat(ctx, model, messages) {
|
package/src/core/index.js
CHANGED
|
@@ -10,3 +10,4 @@ export { readSettings, writeSettings, settingsPath, THEMES, DEFAULT_THEME, norma
|
|
|
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
|
+
export { detectAgents, installMcpServer, SUPPORTED_AGENTS } from './mcp/index.js';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readJson, writeJsonWithBackup } from '../../util.js';
|
|
2
|
+
|
|
3
|
+
export async function readJsonMcpServers(filePath, rootKey) {
|
|
4
|
+
const data = (await readJson(filePath, {})) || {};
|
|
5
|
+
return Object.keys(data[rootKey] || {});
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function upsertJsonMcpServer(
|
|
9
|
+
filePath,
|
|
10
|
+
rootKey,
|
|
11
|
+
serverName,
|
|
12
|
+
serverConfig,
|
|
13
|
+
{ isOpenCode = false } = {}
|
|
14
|
+
) {
|
|
15
|
+
const data = (await readJson(filePath, {})) || {};
|
|
16
|
+
|
|
17
|
+
if (!data[rootKey] || typeof data[rootKey] !== 'object') {
|
|
18
|
+
data[rootKey] = {};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (isOpenCode) {
|
|
22
|
+
data[rootKey][serverName] = {
|
|
23
|
+
type: 'local',
|
|
24
|
+
command: [serverConfig.command, ...(serverConfig.args || [])],
|
|
25
|
+
environment: serverConfig.env || {}
|
|
26
|
+
};
|
|
27
|
+
} else {
|
|
28
|
+
data[rootKey][serverName] = {
|
|
29
|
+
command: serverConfig.command,
|
|
30
|
+
args: serverConfig.args || [],
|
|
31
|
+
...(serverConfig.env ? { env: serverConfig.env } : {})
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const backup = await writeJsonWithBackup(filePath, data, { indent: 2 });
|
|
36
|
+
return { backup };
|
|
37
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { exists, timestamp } from '../../util.js';
|
|
4
|
+
|
|
5
|
+
export async function readTomlMcpServers(filePath) {
|
|
6
|
+
if (!(await exists(filePath))) return [];
|
|
7
|
+
|
|
8
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
9
|
+
const lines = content.split(/\r?\n/);
|
|
10
|
+
const serverNames = new Set();
|
|
11
|
+
|
|
12
|
+
for (const raw of lines) {
|
|
13
|
+
const line = raw.trim();
|
|
14
|
+
const match = line.match(/^\[mcp_servers\.([A-Za-z0-9_-]+)\]\s*$/);
|
|
15
|
+
if (match) {
|
|
16
|
+
serverNames.add(match[1]);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return [...serverNames];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function upsertTomlMcpServer(filePath, serverName, serverConfig) {
|
|
24
|
+
let content = '';
|
|
25
|
+
let backup = null;
|
|
26
|
+
|
|
27
|
+
if (await exists(filePath)) {
|
|
28
|
+
const backupPath = `${filePath}.bak-llm-tuner-${timestamp()}`;
|
|
29
|
+
await fs.copyFile(filePath, backupPath);
|
|
30
|
+
backup = backupPath;
|
|
31
|
+
content = await fs.readFile(filePath, 'utf8');
|
|
32
|
+
} else {
|
|
33
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
34
|
+
content = '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const lines = content.split(/\r?\n/);
|
|
38
|
+
const newLines = [];
|
|
39
|
+
let skipping = false;
|
|
40
|
+
|
|
41
|
+
for (const line of lines) {
|
|
42
|
+
const trimmed = line.trim();
|
|
43
|
+
|
|
44
|
+
if (trimmed.startsWith('[')) {
|
|
45
|
+
if (
|
|
46
|
+
trimmed === `[mcp_servers.${serverName}]` ||
|
|
47
|
+
trimmed.startsWith(`[mcp_servers.${serverName}.`)
|
|
48
|
+
) {
|
|
49
|
+
skipping = true;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
skipping = false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!skipping) {
|
|
56
|
+
newLines.push(line);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let baseContent = newLines.join('\n').trim();
|
|
61
|
+
if (baseContent.length > 0) {
|
|
62
|
+
baseContent += '\n\n';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let tomlBlock = `[mcp_servers.${serverName}]\n`;
|
|
66
|
+
tomlBlock += `command = ${JSON.stringify(serverConfig.command)}\n`;
|
|
67
|
+
tomlBlock += `args = ${JSON.stringify(serverConfig.args || [])}\n`;
|
|
68
|
+
|
|
69
|
+
if (serverConfig.env && typeof serverConfig.env === 'object' && Object.keys(serverConfig.env).length > 0) {
|
|
70
|
+
tomlBlock += `\n[mcp_servers.${serverName}.env]\n`;
|
|
71
|
+
for (const [key, value] of Object.entries(serverConfig.env)) {
|
|
72
|
+
tomlBlock += `${key} = ${JSON.stringify(String(value))}\n`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const finalContent = baseContent + tomlBlock;
|
|
77
|
+
const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.tmp-${process.pid}`);
|
|
78
|
+
await fs.writeFile(tempPath, finalContent, 'utf8');
|
|
79
|
+
await fs.rename(tempPath, filePath);
|
|
80
|
+
|
|
81
|
+
return { backup };
|
|
82
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { exists } from '../util.js';
|
|
2
|
+
import { SUPPORTED_AGENTS, resolveConfigPath, buildMcpPayload } from './registry.js';
|
|
3
|
+
import { readJsonMcpServers, upsertJsonMcpServer } from './adapters/json-adapter.js';
|
|
4
|
+
import { readTomlMcpServers, upsertTomlMcpServer } from './adapters/toml-adapter.js';
|
|
5
|
+
|
|
6
|
+
export { SUPPORTED_AGENTS, resolveConfigPath, buildMcpPayload };
|
|
7
|
+
|
|
8
|
+
export async function detectAgents({ platform, env, homedir } = {}) {
|
|
9
|
+
const results = [];
|
|
10
|
+
|
|
11
|
+
for (const agent of SUPPORTED_AGENTS) {
|
|
12
|
+
const configPath = resolveConfigPath(agent.id, { platform, env, homedir });
|
|
13
|
+
const fileExists = await exists(configPath);
|
|
14
|
+
|
|
15
|
+
let configuredServers = [];
|
|
16
|
+
if (fileExists) {
|
|
17
|
+
try {
|
|
18
|
+
if (agent.format === 'toml') {
|
|
19
|
+
configuredServers = await readTomlMcpServers(configPath);
|
|
20
|
+
} else {
|
|
21
|
+
configuredServers = await readJsonMcpServers(configPath, agent.rootKey);
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
configuredServers = [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
results.push({
|
|
29
|
+
id: agent.id,
|
|
30
|
+
name: agent.name,
|
|
31
|
+
format: agent.format,
|
|
32
|
+
configPath,
|
|
33
|
+
detected: fileExists,
|
|
34
|
+
configuredServers
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return results;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function installMcpServer(
|
|
42
|
+
{ engineId, customName, agentIds = [], host, apiKey },
|
|
43
|
+
{ platform, env, homedir } = {}
|
|
44
|
+
) {
|
|
45
|
+
if (!engineId || !customName) {
|
|
46
|
+
throw new Error('engineId and customName are required');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const payload = buildMcpPayload(engineId, { customName, host, apiKey });
|
|
50
|
+
const updated = [];
|
|
51
|
+
const errors = [];
|
|
52
|
+
|
|
53
|
+
for (const agentId of agentIds) {
|
|
54
|
+
const agent = SUPPORTED_AGENTS.find((a) => a.id === agentId);
|
|
55
|
+
if (!agent) {
|
|
56
|
+
errors.push({ agentId, error: `Unsupported agent ID: ${agentId}` });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const configPath = resolveConfigPath(agentId, { platform, env, homedir });
|
|
62
|
+
|
|
63
|
+
if (agent.format === 'toml') {
|
|
64
|
+
await upsertTomlMcpServer(configPath, customName, payload);
|
|
65
|
+
} else {
|
|
66
|
+
await upsertJsonMcpServer(
|
|
67
|
+
configPath,
|
|
68
|
+
agent.rootKey,
|
|
69
|
+
customName,
|
|
70
|
+
payload,
|
|
71
|
+
{ isOpenCode: agent.isOpenCode }
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
updated.push(agentId);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
errors.push({ agentId, error: err.message });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { success: errors.length === 0, updated, errors };
|
|
82
|
+
}
|