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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Privacy-first CLI for running local LLMs — discover, configure, run, benchmark",
5
5
  "author": "Eeshan Srivastava (https://eeshans.com)",
6
6
  "type": "module",
@@ -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", "2");
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
- return renderCard(`${num}. ${profile.label}`, renderRows([
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
- ["Pi", piConfigured ? pc.dim("synced") : pc.yellow("Needs sync")],
316
- ["Runs with", backend.label],
317
- ]), { formatBorder: border });
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) => ({ type: "new", model, label: model.label, hint: `${model.quant ?? "GGUF"} · ${formatBytes(model.sizeBytes)}` })),
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
- console.log("\n" + renderSection("Model overview", renderRows([
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
- console.log("\n" + renderSection("Model details", renderRows([
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
- ...(!isManaged ? [
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
- ]), { columns: 110 }));
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
- console.log("\n" + renderSection("Downloaded model", renderRows([
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
- ["Size", formatBytes(model.sizeBytes)],
453
- ])));
454
- console.log("\n" + renderSection("Model details", renderRows([
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
- ]), { columns: 110 }));
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;
@@ -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", "--spec-type draft-mtp --spec-draft-n-max 2"],
50
+ ["Flags", `--spec-type draft-mtp --spec-draft-n-max 4${configured.drafterPath ? " --spec-draft-model <drafter>" : ""}`],
50
51
  ])));
51
- const useMtp = await prompt.yesNo("Use MTP speculative decoding flags?", true);
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
- return applyProfileFlags({ ...profile, backend: "llama-cpp-mtp", providerId: "llama-cpp-mtp" }, flags, {
117
- values: { "--spec-type": "draft-mtp", "--spec-draft-n-max": 2 },
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
- const backend = backendId ?? (caps.mtp ? "llama-cpp-mtp" : "llama-cpp");
138
- const id = slugFromLabel(model.label);
139
- const { flags, argv } = computeFlags(caps, model.path, model.mmprojPath, null);
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
- capabilities: summarizeCapabilities(caps),
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
- return allModels.filter((m) => {
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 models = files.filter((path) => !basename(path).toLowerCase().includes("mmproj"));
41
+ const candidates = files.filter((path) => !basename(path).toLowerCase().includes("mmproj"));
42
+
43
+ const models = [];
44
+ const drafters = [];
28
45
 
29
- return models.map((path) => {
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
- return {
34
- path,
35
- mmprojPath,
36
- label: labelFromName(name),
37
- aliasSuggestion: aliasFromName(name),
38
- quant: quantFromName(name),
39
- sizeBytes: statSync(path).size,
40
- backend: "llama-cpp",
41
- source: "local-gguf",
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
  }