offgrid-ai 0.4.2 → 0.4.3
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/package.json +1 -1
- package/src/autodetect.mjs +1 -1
- package/src/backends.mjs +2 -2
- package/src/cli.mjs +79 -33
- package/src/profile-setup.mjs +12 -6
- package/src/profiles.mjs +13 -6
- package/src/scan.mjs +104 -15
package/package.json
CHANGED
package/src/autodetect.mjs
CHANGED
|
@@ -109,7 +109,7 @@ export function computeFlags(capabilities, modelPath, mmprojPath, draftModelPath
|
|
|
109
109
|
|
|
110
110
|
// MTP flags
|
|
111
111
|
if (mtp) {
|
|
112
|
-
argv.push("--spec-type", "draft-mtp", "--spec-draft-n-max", "
|
|
112
|
+
argv.push("--spec-type", "draft-mtp", "--spec-draft-n-max", "4");
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
return { flags, argv };
|
package/src/backends.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export const BACKENDS = {
|
|
|
27
27
|
defaultPort: LLAMA_CPP_PORT,
|
|
28
28
|
defaultBaseUrl: baseUrlFor({ port: LLAMA_CPP_PORT }),
|
|
29
29
|
needsCommandFile: true,
|
|
30
|
-
scanModels: () => scanGgufModels(),
|
|
30
|
+
scanModels: async () => (await scanGgufModels()).models,
|
|
31
31
|
},
|
|
32
32
|
"llama-cpp-mtp": {
|
|
33
33
|
id: "llama-cpp-mtp",
|
|
@@ -38,7 +38,7 @@ export const BACKENDS = {
|
|
|
38
38
|
defaultPort: LLAMA_CPP_MTP_PORT,
|
|
39
39
|
defaultBaseUrl: baseUrlFor({ port: LLAMA_CPP_MTP_PORT }),
|
|
40
40
|
needsCommandFile: true,
|
|
41
|
-
scanModels: () => scanGgufModels(),
|
|
41
|
+
scanModels: async () => (await scanGgufModels()).models,
|
|
42
42
|
},
|
|
43
43
|
"ollama": {
|
|
44
44
|
id: "ollama",
|
package/src/cli.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { totalmem } from "node:os";
|
|
2
2
|
import { existsSync, statSync, rmSync } from "node:fs";
|
|
3
|
+
import { basename } from "node:path";
|
|
3
4
|
import { ensureDirs, findLlamaServer, hasHomebrew, DATA_DIR } from "./config.mjs";
|
|
4
|
-
import { scanGgufModels } from "./scan.mjs";
|
|
5
|
+
import { scanGgufModels, matchDrafter } from "./scan.mjs";
|
|
5
6
|
import { createProfileFromModel, normalizeProfile, sanitizeProfileId } from "./profiles.mjs";
|
|
6
7
|
import { readProfile, saveProfile, deleteProfile, loadProfiles, readCommandArgv } from "./profiles.mjs";
|
|
7
8
|
import { backendFor, BACKENDS } from "./backends.mjs";
|
|
@@ -78,7 +79,7 @@ export async function mainFlow() {
|
|
|
78
79
|
|
|
79
80
|
// 1. Check what backends are available
|
|
80
81
|
const llamaBinary = await findLlamaServer();
|
|
81
|
-
const ggufModels = await scanGgufModels();
|
|
82
|
+
const { models: ggufModels } = await scanGgufModels();
|
|
82
83
|
const managedModels = await scanManagedModels();
|
|
83
84
|
const profiles = await loadProfiles();
|
|
84
85
|
const hasAnyBackend = llamaBinary || managedModels.some((m) => m.models.length > 0);
|
|
@@ -206,17 +207,17 @@ async function runCommand(argv) {
|
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
async function loadModelCatalog() {
|
|
209
|
-
const [profiles, ggufModels, managedModels] = await Promise.all([
|
|
210
|
+
const [profiles, { models: ggufModels, drafters }, managedModels] = await Promise.all([
|
|
210
211
|
loadProfiles(),
|
|
211
212
|
scanGgufModels(),
|
|
212
213
|
scanManagedModels(),
|
|
213
214
|
]);
|
|
214
|
-
return normalizeCatalog({ profiles, ggufModels, managedModels });
|
|
215
|
+
return normalizeCatalog({ profiles, ggufModels, drafters, managedModels });
|
|
215
216
|
}
|
|
216
217
|
|
|
217
218
|
function normalizeCatalog(catalog) {
|
|
218
219
|
if (catalog.newModels && catalog.managedItems) return catalog;
|
|
219
|
-
const { profiles, ggufModels, managedModels } = catalog;
|
|
220
|
+
const { profiles, ggufModels, drafters, managedModels } = catalog;
|
|
220
221
|
const profiledPaths = new Set(profiles.map((p) => p.modelPath).filter(Boolean));
|
|
221
222
|
const newModels = ggufModels.filter((m) => !profiledPaths.has(m.path));
|
|
222
223
|
const managedItems = [];
|
|
@@ -228,10 +229,10 @@ function normalizeCatalog(catalog) {
|
|
|
228
229
|
if (!profiledAliases.has(`${backendId}:${model.id}`)) managedItems.push({ model, backendId });
|
|
229
230
|
}
|
|
230
231
|
}
|
|
231
|
-
return { profiles, ggufModels, managedModels, newModels, managedItems };
|
|
232
|
+
return { profiles, ggufModels, drafters, managedModels, newModels, managedItems };
|
|
232
233
|
}
|
|
233
234
|
|
|
234
|
-
async function printModelCatalog({ profiles, newModels, managedItems }, items = modelCatalogItems({ profiles, newModels, managedItems })) {
|
|
235
|
+
async function printModelCatalog({ profiles, newModels, managedItems, drafters }, items = modelCatalogItems({ profiles, newModels, managedItems })) {
|
|
235
236
|
const itemNumber = (predicate) => {
|
|
236
237
|
const index = items.findIndex(predicate);
|
|
237
238
|
return index === -1 ? " " : String(index + 1).padStart(2, " ");
|
|
@@ -261,7 +262,7 @@ async function printModelCatalog({ profiles, newModels, managedItems }, items =
|
|
|
261
262
|
const piConfigured = await hasPiModel(profile);
|
|
262
263
|
const fileMissing = isProfileFileMissing(profile);
|
|
263
264
|
const num = itemNumber((item) => item.type === "profile" && item.profile.id === profile.id);
|
|
264
|
-
console.log(profileCatalogCard(num, profile, { running, piConfigured, fileMissing }));
|
|
265
|
+
console.log(profileCatalogCard(num, profile, { running, piConfigured, fileMissing, drafters }));
|
|
265
266
|
}
|
|
266
267
|
}
|
|
267
268
|
|
|
@@ -271,8 +272,9 @@ async function printModelCatalog({ profiles, newModels, managedItems }, items =
|
|
|
271
272
|
} else {
|
|
272
273
|
for (const model of newModels.slice(0, 20)) {
|
|
273
274
|
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
275
|
+
const drafter = matchDrafter(model.path, drafters);
|
|
274
276
|
const num = itemNumber((item) => item.type === "new" && item.model.path === model.path);
|
|
275
|
-
console.log(downloadedModelCard(num, model, caps));
|
|
277
|
+
console.log(downloadedModelCard(num, model, caps, { mtpAvailable: caps.mtp || Boolean(drafter), drafter }));
|
|
276
278
|
}
|
|
277
279
|
if (newModels.length > 20) console.log(pc.dim(` ... and ${newModels.length - 20} more`));
|
|
278
280
|
}
|
|
@@ -297,7 +299,7 @@ function isProfileFileMissing(profile) {
|
|
|
297
299
|
return !existsSync(profile.modelPath);
|
|
298
300
|
}
|
|
299
301
|
|
|
300
|
-
function profileCatalogCard(num, profile, { running, piConfigured, fileMissing }) {
|
|
302
|
+
function profileCatalogCard(num, profile, { running, piConfigured, fileMissing, drafters }) {
|
|
301
303
|
const backend = backendFor(profile.backend);
|
|
302
304
|
const caps = profile.capabilities ?? {};
|
|
303
305
|
let status;
|
|
@@ -309,18 +311,33 @@ function profileCatalogCard(num, profile, { running, piConfigured, fileMissing }
|
|
|
309
311
|
status = "Ready";
|
|
310
312
|
}
|
|
311
313
|
const border = fileMissing ? pc.red : running ? pc.green : pc.dim;
|
|
312
|
-
|
|
314
|
+
const mtpDrafter = profile.drafterPath
|
|
315
|
+
? pc.green("enabled")
|
|
316
|
+
: (drafters ? matchDrafter(profile.modelPath, drafters) : null)
|
|
317
|
+
? pc.yellow("available — re-setup to enable")
|
|
318
|
+
: (caps.architecture === "gemma4")
|
|
319
|
+
? pc.yellow("drafter not found")
|
|
320
|
+
: null;
|
|
321
|
+
const rows = [
|
|
313
322
|
["Status", status],
|
|
314
323
|
["Good for", fileMissing ? pc.red("Model file not found") : humanCapabilitySummary(caps)],
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
324
|
+
];
|
|
325
|
+
if (mtpDrafter) rows.push(["MTP", mtpDrafter]);
|
|
326
|
+
rows.push(["Pi", piConfigured ? pc.dim("synced") : pc.yellow("Needs sync")]);
|
|
327
|
+
rows.push(["Runs with", backend.label]);
|
|
328
|
+
return renderCard(`${num}. ${profile.label}`, renderRows(rows), { formatBorder: border });
|
|
318
329
|
}
|
|
319
330
|
|
|
320
|
-
function downloadedModelCard(num, model, caps) {
|
|
331
|
+
function downloadedModelCard(num, model, caps, { mtpAvailable } = {}) {
|
|
332
|
+
const mtpRow = mtpAvailable
|
|
333
|
+
? [["MTP", pc.green("Drafter found — setup will enable 2× speedup")]]
|
|
334
|
+
: (caps.architecture === "gemma4" || caps.architecture === "gemma4")
|
|
335
|
+
? [["MTP", pc.yellow("Drafter not found — download MTP model for 2× speedup")]]
|
|
336
|
+
: [];
|
|
321
337
|
return renderCard(`${num}. ${model.label}`, renderRows([
|
|
322
338
|
["Status", pc.yellow("Needs setup")],
|
|
323
339
|
["Good for", humanCapabilitySummary(caps)],
|
|
340
|
+
...mtpRow,
|
|
324
341
|
["Size", formatBytes(model.sizeBytes)],
|
|
325
342
|
]), { formatBorder: pc.yellow });
|
|
326
343
|
}
|
|
@@ -334,10 +351,13 @@ function managedModelCard(num, model, backend) {
|
|
|
334
351
|
]), { formatBorder: pc.dim });
|
|
335
352
|
}
|
|
336
353
|
|
|
337
|
-
function modelCatalogItems({ profiles, newModels, managedItems }) {
|
|
354
|
+
function modelCatalogItems({ profiles, newModels, managedItems, drafters }) {
|
|
338
355
|
return [
|
|
339
356
|
...profiles.map((profile) => ({ type: "profile", profile, label: profile.label, hint: `${profile.modelAlias} · ${profile.baseUrl}`, fileMissing: isProfileFileMissing(profile) })),
|
|
340
|
-
...newModels.map((model) =>
|
|
357
|
+
...newModels.map((model) => {
|
|
358
|
+
const drafter = matchDrafter(model.path, drafters);
|
|
359
|
+
return { type: "new", model, label: model.label, hint: `${model.quant ?? "GGUF"} · ${formatBytes(model.sizeBytes)}`, drafter };
|
|
360
|
+
}),
|
|
341
361
|
...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label, hint: BACKENDS[backendId].label })),
|
|
342
362
|
];
|
|
343
363
|
}
|
|
@@ -368,7 +388,7 @@ async function handleCatalogAction(prompt, action, item) {
|
|
|
368
388
|
if (action === "inspect") {
|
|
369
389
|
if (item.type === "profile") return await printProfileDetails(await readProfile(item.profile.id));
|
|
370
390
|
if (item.type === "managed") return printManagedModelDetails(item.model, BACKENDS[item.backendId]);
|
|
371
|
-
return printGgufModelDetails(item.model);
|
|
391
|
+
return printGgufModelDetails(item.model, item.drafter);
|
|
372
392
|
}
|
|
373
393
|
|
|
374
394
|
if (action === "setup") {
|
|
@@ -378,7 +398,7 @@ async function handleCatalogAction(prompt, action, item) {
|
|
|
378
398
|
await saveProfile(profile);
|
|
379
399
|
return await syncPiConfig(profile);
|
|
380
400
|
}
|
|
381
|
-
const profile = await createProfileFromModel(item.model);
|
|
401
|
+
const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
|
|
382
402
|
const configured = await configureLocalProfile(prompt, profile);
|
|
383
403
|
if (!configured) return;
|
|
384
404
|
await saveProfile(configured);
|
|
@@ -393,7 +413,7 @@ async function handleCatalogAction(prompt, action, item) {
|
|
|
393
413
|
await syncPiConfig(profile);
|
|
394
414
|
return await runProfile(profile);
|
|
395
415
|
}
|
|
396
|
-
const profile = await createProfileFromModel(item.model);
|
|
416
|
+
const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
|
|
397
417
|
const configured = await configureLocalProfile(prompt, profile);
|
|
398
418
|
if (!configured) return;
|
|
399
419
|
await saveProfile(configured);
|
|
@@ -413,25 +433,38 @@ async function printProfileDetails(profile) {
|
|
|
413
433
|
const statusRow = fileMissing
|
|
414
434
|
? pc.red("File missing")
|
|
415
435
|
: running ? pc.green("Running now") : "Ready";
|
|
416
|
-
|
|
436
|
+
const mtpStatus = profile.drafterPath
|
|
437
|
+
? pc.green(`MTP enabled (drafter: ${basename(profile.drafterPath)})`)
|
|
438
|
+
: (profile.capabilities?.architecture === "gemma4")
|
|
439
|
+
? pc.yellow("MTP available — download a drafter model to enable 2× speedup")
|
|
440
|
+
: null;
|
|
441
|
+
const overviewRows = [
|
|
417
442
|
["Name", pc.bold(profile.label)],
|
|
418
443
|
["Status", statusRow],
|
|
419
444
|
["Good for", fileMissing ? pc.red("Model file not found — remove or fix this setup") : humanCapabilitySummary(profile.capabilities ?? {})],
|
|
420
445
|
["Pi", piConfigured ? pc.dim("synced") : pc.yellow("Needs sync")],
|
|
421
446
|
["Server", fileMissing ? pc.red(profile.baseUrl) : profile.baseUrl],
|
|
422
|
-
]
|
|
447
|
+
];
|
|
448
|
+
if (mtpStatus) overviewRows.push(["MTP", mtpStatus]);
|
|
449
|
+
console.log("\n" + renderSection("Model overview", renderRows(overviewRows)));
|
|
423
450
|
|
|
424
|
-
|
|
451
|
+
const detailRows = [
|
|
425
452
|
["Setup ID", profile.id],
|
|
426
453
|
["Runs with", backend.label],
|
|
427
454
|
["Model alias", profile.modelAlias],
|
|
428
455
|
...(profile.capabilities ? [["Detected", capabilitySummary(profile.capabilities)]] : []),
|
|
429
|
-
|
|
456
|
+
];
|
|
457
|
+
if (!isManaged) {
|
|
458
|
+
detailRows.push(
|
|
430
459
|
["Local file", fileMissing ? pc.red(`${profile.modelPath} (not found)`) : profile.modelPath ?? "unknown"],
|
|
431
460
|
["Vision file", profile.mmprojPath ? (existsSync(profile.mmprojPath) ? profile.mmprojPath : pc.red(`${profile.mmprojPath} (not found)`)) : "none"],
|
|
432
461
|
["Model size", profile.modelPath && existsSync(profile.modelPath) ? formatBytes(statSync(profile.modelPath).size) : "unknown"],
|
|
433
|
-
|
|
434
|
-
|
|
462
|
+
);
|
|
463
|
+
if (profile.drafterPath) {
|
|
464
|
+
detailRows.push(["Drafter", existsSync(profile.drafterPath) ? profile.drafterPath : pc.red(`${profile.drafterPath} (not found)`)]);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
console.log("\n" + renderSection("Model details", renderRows(detailRows), { columns: 110 }));
|
|
435
468
|
|
|
436
469
|
if (fileMissing) {
|
|
437
470
|
console.log("\n" + pc.red("⚠ This model's file is no longer on disk. Remove this setup or move the file back."));
|
|
@@ -443,20 +476,33 @@ async function printProfileDetails(profile) {
|
|
|
443
476
|
}
|
|
444
477
|
}
|
|
445
478
|
|
|
446
|
-
function printGgufModelDetails(model) {
|
|
479
|
+
function printGgufModelDetails(model, drafter) {
|
|
447
480
|
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
448
|
-
|
|
481
|
+
const mtpAvailable = caps.mtp || Boolean(drafter);
|
|
482
|
+
const mtpRow = mtpAvailable
|
|
483
|
+
? [["MTP", pc.green("Drafter found — setup will enable 2× speedup")]]
|
|
484
|
+
: (caps.architecture === "gemma4")
|
|
485
|
+
? [["MTP", pc.yellow("Drafter not found — download MTP model for 2× speedup")]]
|
|
486
|
+
: [];
|
|
487
|
+
const overviewRows = [
|
|
449
488
|
["Name", pc.bold(model.label)],
|
|
450
489
|
["Status", pc.yellow("Needs one-time setup")],
|
|
451
490
|
["Good for", humanCapabilitySummary(caps)],
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
491
|
+
];
|
|
492
|
+
if (mtpRow.length) overviewRows.push(...mtpRow);
|
|
493
|
+
overviewRows.push(["Size", formatBytes(model.sizeBytes)]);
|
|
494
|
+
console.log("\n" + renderSection("Downloaded model", renderRows(overviewRows)));
|
|
495
|
+
const detailRows = [
|
|
455
496
|
["Local file", model.path],
|
|
456
497
|
["Vision file", model.mmprojPath ?? "none"],
|
|
457
498
|
["Detected", capabilitySummary(caps)],
|
|
458
499
|
["Quant", model.quant ?? "unknown"],
|
|
459
|
-
]
|
|
500
|
+
];
|
|
501
|
+
if (drafter) {
|
|
502
|
+
detailRows.push(["Drafter", drafter.path]);
|
|
503
|
+
detailRows.push(["Drafter size", formatBytes(drafter.sizeBytes)]);
|
|
504
|
+
}
|
|
505
|
+
console.log("\n" + renderSection("Model details", renderRows(detailRows), { columns: 110 }));
|
|
460
506
|
}
|
|
461
507
|
|
|
462
508
|
function printManagedModelDetails(model, backend) {
|
|
@@ -872,7 +918,7 @@ async function onboardFlow() {
|
|
|
872
918
|
console.log(pc.green("✓ Pi found"));
|
|
873
919
|
|
|
874
920
|
// 4. Model backends — at least one is mandatory
|
|
875
|
-
const ggufModels = await scanGgufModels();
|
|
921
|
+
const { models: ggufModels } = await scanGgufModels();
|
|
876
922
|
const managedModels = await scanManagedModels();
|
|
877
923
|
const totalManaged = managedModels.reduce((sum, m) => sum + m.models.length, 0);
|
|
878
924
|
const hasModels = ggufModels.length > 0 || totalManaged > 0;
|
package/src/profile-setup.mjs
CHANGED
|
@@ -43,12 +43,14 @@ export async function configureLocalProfile(prompt, profile) {
|
|
|
43
43
|
console.log(pc.dim("Sampling defaults are shown for transparency; you can edit command.json later if needed.\n"));
|
|
44
44
|
|
|
45
45
|
if (caps.mtp) {
|
|
46
|
+
const drafterInfo = configured.drafterPath ? `\n Drafter: ${configured.drafterPath}` : "";
|
|
46
47
|
console.log(renderSection("MTP detected", renderRows([
|
|
47
48
|
["Backend", "llama.cpp MTP"],
|
|
48
49
|
["Port", String(LLAMA_CPP_MTP_PORT)],
|
|
49
|
-
["Flags",
|
|
50
|
+
["Flags", `--spec-type draft-mtp --spec-draft-n-max 4${configured.drafterPath ? " --spec-draft-model <drafter>" : ""}`],
|
|
50
51
|
])));
|
|
51
|
-
|
|
52
|
+
if (drafterInfo) console.log(pc.dim(drafterInfo));
|
|
53
|
+
const useMtp = await prompt.yesNo("Use MTP speculative decoding?", true);
|
|
52
54
|
configured = useMtp ? applyMtpDefaults(configured) : removeMtpDefaults(configured);
|
|
53
55
|
}
|
|
54
56
|
|
|
@@ -113,15 +115,19 @@ export function applyRuntimeFlagOverrides(profile, overrides) {
|
|
|
113
115
|
|
|
114
116
|
function applyMtpDefaults(profile) {
|
|
115
117
|
const flags = { ...profile.flags, port: LLAMA_CPP_MTP_PORT };
|
|
116
|
-
|
|
117
|
-
values: { "--spec-type": "draft-mtp", "--spec-draft-n-max":
|
|
118
|
-
}
|
|
118
|
+
const edits = {
|
|
119
|
+
values: { "--spec-type": "draft-mtp", "--spec-draft-n-max": 4 },
|
|
120
|
+
};
|
|
121
|
+
if (profile.drafterPath) {
|
|
122
|
+
edits.values["--spec-draft-model"] = profile.drafterPath;
|
|
123
|
+
}
|
|
124
|
+
return applyProfileFlags({ ...profile, backend: "llama-cpp-mtp", providerId: "llama-cpp-mtp" }, flags, edits);
|
|
119
125
|
}
|
|
120
126
|
|
|
121
127
|
function removeMtpDefaults(profile) {
|
|
122
128
|
const flags = { ...profile.flags, port: LLAMA_CPP_PORT };
|
|
123
129
|
return applyProfileFlags({ ...profile, backend: "llama-cpp", providerId: "llama-cpp" }, flags, {
|
|
124
|
-
remove: ["--spec-type", "--spec-draft-n-max"],
|
|
130
|
+
remove: ["--spec-type", "--spec-draft-n-max", "--spec-draft-model"],
|
|
125
131
|
});
|
|
126
132
|
}
|
|
127
133
|
|
package/src/profiles.mjs
CHANGED
|
@@ -131,22 +131,29 @@ export function normalizeProfile(profile) {
|
|
|
131
131
|
|
|
132
132
|
// ── Auto-create profile from a discovered model ────────────────────────────
|
|
133
133
|
|
|
134
|
-
export async function createProfileFromModel(model, backendId) {
|
|
134
|
+
export async function createProfileFromModel(model, backendId, drafterPath) {
|
|
135
135
|
const { detectCapabilities } = await import("./autodetect.mjs");
|
|
136
136
|
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
const
|
|
137
|
+
// If a drafter is provided, this model supports MTP regardless of filename
|
|
138
|
+
const hasMtp = caps.mtp || Boolean(drafterPath);
|
|
139
|
+
const backend = backendId ?? (hasMtp ? "llama-cpp-mtp" : "llama-cpp");
|
|
140
|
+
const { flags, argv } = computeFlags(
|
|
141
|
+
{ ...caps, mtp: hasMtp },
|
|
142
|
+
model.path,
|
|
143
|
+
model.mmprojPath,
|
|
144
|
+
drafterPath ?? null,
|
|
145
|
+
);
|
|
140
146
|
|
|
141
147
|
return normalizeProfile({
|
|
142
|
-
id,
|
|
148
|
+
id: slugFromLabel(model.label),
|
|
143
149
|
label: model.label,
|
|
144
150
|
backend,
|
|
145
151
|
providerId: backend,
|
|
146
152
|
modelAlias: model.aliasSuggestion,
|
|
147
153
|
modelPath: model.path,
|
|
148
154
|
mmprojPath: model.mmprojPath,
|
|
149
|
-
|
|
155
|
+
drafterPath: drafterPath ?? null,
|
|
156
|
+
capabilities: summarizeCapabilities({ ...caps, mtp: hasMtp }),
|
|
150
157
|
preset: null, // no presets — auto-detected
|
|
151
158
|
flags,
|
|
152
159
|
commandArgv: argv,
|
package/src/scan.mjs
CHANGED
|
@@ -2,47 +2,128 @@ import { statSync } from "node:fs";
|
|
|
2
2
|
import { readdir } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { getModelScanDirs } from "./config.mjs";
|
|
5
|
+
import { readGgufMetadata } from "./gguf.mjs";
|
|
6
|
+
|
|
7
|
+
// ── Scan for GGUF models and MTP drafters ────────────────────────────────
|
|
5
8
|
|
|
6
9
|
export async function scanGgufModels(dirs) {
|
|
7
10
|
const scanDirs = dirs ?? await getModelScanDirs();
|
|
8
11
|
const allModels = [];
|
|
12
|
+
const allDrafters = [];
|
|
9
13
|
|
|
10
14
|
for (const root of scanDirs) {
|
|
11
|
-
const models = await scanOneDir(root);
|
|
15
|
+
const { models, drafters } = await scanOneDir(root);
|
|
12
16
|
allModels.push(...models);
|
|
17
|
+
allDrafters.push(...drafters);
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
// Deduplicate by path
|
|
16
21
|
const seen = new Set();
|
|
17
|
-
|
|
22
|
+
const models = allModels.filter((m) => {
|
|
18
23
|
if (seen.has(m.path)) return false;
|
|
19
24
|
seen.add(m.path);
|
|
20
25
|
return true;
|
|
21
26
|
}).sort((a, b) => a.label.localeCompare(b.label));
|
|
27
|
+
|
|
28
|
+
const drafterSeen = new Set();
|
|
29
|
+
const drafters = allDrafters.filter((d) => {
|
|
30
|
+
if (drafterSeen.has(d.path)) return false;
|
|
31
|
+
drafterSeen.add(d.path);
|
|
32
|
+
return true;
|
|
33
|
+
}).sort((a, b) => a.label.localeCompare(b.label));
|
|
34
|
+
|
|
35
|
+
return { models, drafters };
|
|
22
36
|
}
|
|
23
37
|
|
|
24
38
|
async function scanOneDir(root) {
|
|
25
39
|
const files = await findFiles(root, (path) => path.toLowerCase().endsWith(".gguf"));
|
|
26
40
|
const mmprojs = files.filter((path) => basename(path).toLowerCase().includes("mmproj"));
|
|
27
|
-
const
|
|
41
|
+
const candidates = files.filter((path) => !basename(path).toLowerCase().includes("mmproj"));
|
|
42
|
+
|
|
43
|
+
const models = [];
|
|
44
|
+
const drafters = [];
|
|
28
45
|
|
|
29
|
-
|
|
46
|
+
for (const path of candidates) {
|
|
30
47
|
const dir = dirname(path);
|
|
31
48
|
const mmprojPath = mmprojs.find((candidate) => dirname(candidate) === dir) ?? null;
|
|
32
49
|
const name = basename(path).replace(/\.gguf$/i, "");
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
const sizeBytes = statSync(path).size;
|
|
51
|
+
|
|
52
|
+
// Read GGUF metadata to detect drafter architecture
|
|
53
|
+
const meta = safeReadGgufMetadata(path);
|
|
54
|
+
const architecture = typeof meta["general.architecture"] === "string" ? meta["general.architecture"] : null;
|
|
55
|
+
|
|
56
|
+
if (architecture === "gemma4_assistant") {
|
|
57
|
+
// This is an MTP drafter model, not a main model
|
|
58
|
+
drafters.push({
|
|
59
|
+
path,
|
|
60
|
+
label: labelFromName(name),
|
|
61
|
+
aliasSuggestion: aliasFromName(name),
|
|
62
|
+
quant: quantFromName(name),
|
|
63
|
+
sizeBytes,
|
|
64
|
+
architecture,
|
|
65
|
+
targetHint: drafterTargetHint(name),
|
|
66
|
+
backend: "llama-cpp",
|
|
67
|
+
source: "local-gguf",
|
|
68
|
+
});
|
|
69
|
+
} else {
|
|
70
|
+
models.push({
|
|
71
|
+
path,
|
|
72
|
+
mmprojPath,
|
|
73
|
+
label: labelFromName(name),
|
|
74
|
+
aliasSuggestion: aliasFromName(name),
|
|
75
|
+
quant: quantFromName(name),
|
|
76
|
+
sizeBytes,
|
|
77
|
+
backend: "llama-cpp",
|
|
78
|
+
source: "local-gguf",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { models, drafters };
|
|
44
84
|
}
|
|
45
85
|
|
|
86
|
+
// ── Match drafters to target models ────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
// Map a drafter filename to a regex that matches its target model filenames.
|
|
89
|
+
// e.g. "mtp-gemma-4-31B-it" matches "gemma-4-31B-it*"
|
|
90
|
+
// "gemma-4-31B-it-assistant-Q8_0" matches "gemma-4-31B-it*"
|
|
91
|
+
// "gemma-4-12b-it-MTP-Q8_0" matches "gemma-4-12b-it*"
|
|
92
|
+
function drafterTargetHint(name) {
|
|
93
|
+
const lower = name.toLowerCase();
|
|
94
|
+
// Strip common prefixes/suffixes to get the base model identifier
|
|
95
|
+
let base = lower
|
|
96
|
+
.replace(/^mtp[-_]/i, "")
|
|
97
|
+
.replace(/[-_]mtp$/i, "")
|
|
98
|
+
.replace(/[-_]assistant([-_].*)?$/i, "")
|
|
99
|
+
.replace(/[-_]draft([-_].*)?$/i, "");
|
|
100
|
+
// Remove quant suffix for matching (Q4_K_M, Q8_0, UD-Q4_K_XL, etc.)
|
|
101
|
+
base = base.replace(/[-_]q\d_k_[a-z]+$/i, "").replace(/[-_]q\d_[01]$/i, "").replace(/[-_]ud-[a-z0-9_]+$/i, "");
|
|
102
|
+
return base;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Find a drafter that matches a given target model path/name.
|
|
106
|
+
// Returns the drafter object or null.
|
|
107
|
+
export function matchDrafter(targetPath, drafters) {
|
|
108
|
+
if (!drafters || drafters.length === 0) return null;
|
|
109
|
+
const targetName = basename(targetPath).replace(/\.gguf$/i, "").toLowerCase();
|
|
110
|
+
// Remove quant suffix from target for matching
|
|
111
|
+
const targetBase = targetName
|
|
112
|
+
.replace(/[-_]q\d_k_[a-z]+$/i, "")
|
|
113
|
+
.replace(/[-_]q\d_[01]$/i, "")
|
|
114
|
+
.replace(/[-_]ud-[a-z0-9_]+$/i, "");
|
|
115
|
+
|
|
116
|
+
for (const drafter of drafters) {
|
|
117
|
+
const hint = drafter.targetHint;
|
|
118
|
+
if (targetBase.startsWith(hint) || targetBase === hint) {
|
|
119
|
+
return drafter;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
46
127
|
async function findFiles(root, predicate) {
|
|
47
128
|
const result = [];
|
|
48
129
|
async function walk(dir) {
|
|
@@ -75,4 +156,12 @@ function aliasFromName(name) {
|
|
|
75
156
|
|
|
76
157
|
function quantFromName(name) {
|
|
77
158
|
return name.match(/(Q\d_K_[A-Z]+|Q\d_[01]|UD-[A-Z0-9_]+)/)?.[1];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function safeReadGgufMetadata(path) {
|
|
162
|
+
try {
|
|
163
|
+
return readGgufMetadata(path);
|
|
164
|
+
} catch {
|
|
165
|
+
return {};
|
|
166
|
+
}
|
|
78
167
|
}
|