offgrid-ai 0.4.1 → 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.1",
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";
@@ -9,7 +10,7 @@ import { startServer, stopProfile, waitForReady, serverReady, serverMatchesProfi
9
10
  import { syncPiConfig, removeFromPiConfig, hasPiModel, launchPi, hasPi } from "./harness-pi.mjs";
10
11
  import { tailFriendly } from "./logs.mjs";
11
12
  import { estimateMemory } from "./estimate.mjs";
12
- import { pc, formatBytes, renderRows, renderSection, renderCard, humanCapabilitySummary, statusText, startInteractive, createPrompt, parseOptions } from "./ui.mjs";
13
+ import { pc, formatBytes, renderRows, renderSection, renderCard, humanCapabilitySummary, startInteractive, createPrompt, parseOptions } from "./ui.mjs";
13
14
  import { checkForUpdate, currentPackageVersion, detectInvocation, updateCommand, runUpdateCommand } from "./updates.mjs";
14
15
  import { removeInstallerPathEntries } from "./shell-path.mjs";
15
16
  import { configureLocalProfile } from "./profile-setup.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, " ");
@@ -243,13 +244,14 @@ async function printModelCatalog({ profiles, newModels, managedItems }, items =
243
244
 
244
245
  const fileMissingCount = profiles.filter((p) => isProfileFileMissing(p)).length;
245
246
 
247
+ const summaryBorder = fileMissingCount > 0 ? pc.red : newModels.length > 0 ? pc.yellow : pc.dim;
246
248
  console.log("\n" + renderCard("Your local AI workspace", renderRows([
247
- ["Ready to chat", pc.green(`${profiles.length} saved setup${profiles.length === 1 ? "" : "s"}`)],
248
- ["Need setup", newModels.length > 0 ? pc.yellow(`${newModels.length} downloaded model${newModels.length === 1 ? "" : "s"}`) : pc.green("none")],
249
- ["Running now", runningProfilesNow.length > 0 ? pc.green(String(runningProfilesNow.length)) : pc.dim("none")],
250
- ["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.green("none")],
251
- ["Next step", fileMissingCount > 0 ? pc.red("Remove or fix missing setups") : profiles.length > 0 ? "Start chatting" : newModels.length > 0 ? "Set up a downloaded model" : "Download a model"],
252
- ]), { formatBorder: fileMissingCount > 0 ? pc.yellow : pc.cyan }));
249
+ ["Setups", `${profiles.length} saved`],
250
+ ["Need setup", newModels.length > 0 ? pc.yellow(`${newModels.length} model${newModels.length === 1 ? "" : "s"}`) : pc.dim("none")],
251
+ ["Running", runningProfilesNow.length > 0 ? pc.green(String(runningProfilesNow.length)) : pc.dim("none")],
252
+ ["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.dim("none")],
253
+ ["Next step", fileMissingCount > 0 ? pc.red("Remove or fix missing setups") : profiles.length > 0 ? "Start chatting" : newModels.length > 0 ? pc.yellow("Set up a downloaded model") : "Download a model"],
254
+ ]), { formatBorder: summaryBorder }));
253
255
 
254
256
  console.log("\n" + pc.bold("Ready to chat"));
255
257
  if (profiles.length === 0) {
@@ -260,18 +262,19 @@ async function printModelCatalog({ profiles, newModels, managedItems }, items =
260
262
  const piConfigured = await hasPiModel(profile);
261
263
  const fileMissing = isProfileFileMissing(profile);
262
264
  const num = itemNumber((item) => item.type === "profile" && item.profile.id === profile.id);
263
- console.log(profileCatalogCard(num, profile, { running, piConfigured, fileMissing }));
265
+ console.log(profileCatalogCard(num, profile, { running, piConfigured, fileMissing, drafters }));
264
266
  }
265
267
  }
266
268
 
267
- console.log("\n" + pc.bold("Downloaded, needs one-time setup"));
269
+ console.log("\n" + pc.bold("Needs one-time setup"));
268
270
  if (newModels.length === 0) {
269
- console.log(renderCard("All set", "Every downloaded local model already has a saved setup.", { formatBorder: pc.green }));
271
+ console.log(renderCard("All set", "Every downloaded local model already has a saved setup.", { formatBorder: pc.dim }));
270
272
  } else {
271
273
  for (const model of newModels.slice(0, 20)) {
272
274
  const caps = detectCapabilities(model.path, model.mmprojPath);
275
+ const drafter = matchDrafter(model.path, drafters);
273
276
  const num = itemNumber((item) => item.type === "new" && item.model.path === model.path);
274
- console.log(downloadedModelCard(num, model, caps));
277
+ console.log(downloadedModelCard(num, model, caps, { mtpAvailable: caps.mtp || Boolean(drafter), drafter }));
275
278
  }
276
279
  if (newModels.length > 20) console.log(pc.dim(` ... and ${newModels.length - 20} more`));
277
280
  }
@@ -296,46 +299,65 @@ function isProfileFileMissing(profile) {
296
299
  return !existsSync(profile.modelPath);
297
300
  }
298
301
 
299
- function profileCatalogCard(num, profile, { running, piConfigured, fileMissing }) {
302
+ function profileCatalogCard(num, profile, { running, piConfigured, fileMissing, drafters }) {
300
303
  const backend = backendFor(profile.backend);
301
304
  const caps = profile.capabilities ?? {};
302
305
  let status;
303
306
  if (fileMissing) {
304
307
  status = pc.red("File missing");
305
308
  } else if (running) {
306
- status = statusText("running", "Running now");
309
+ status = pc.green("Running now");
307
310
  } else {
308
- status = statusText("ready", "Ready to chat");
311
+ status = "Ready";
309
312
  }
310
- return renderCard(`${num}. ${profile.label}`, renderRows([
313
+ const border = fileMissing ? pc.red : running ? pc.green : pc.dim;
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 = [
311
322
  ["Status", status],
312
323
  ["Good for", fileMissing ? pc.red("Model file not found") : humanCapabilitySummary(caps)],
313
- ["Pi", piConfigured ? pc.green("Synced") : pc.yellow("Needs sync")],
314
- ["Runs with", backend.label],
315
- ]), { formatBorder: fileMissing ? pc.red : running ? pc.green : pc.cyan });
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 });
316
329
  }
317
330
 
318
- 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
+ : [];
319
337
  return renderCard(`${num}. ${model.label}`, renderRows([
320
- ["Status", statusText("warning", "Needs one-time setup")],
338
+ ["Status", pc.yellow("Needs setup")],
321
339
  ["Good for", humanCapabilitySummary(caps)],
340
+ ...mtpRow,
322
341
  ["Size", formatBytes(model.sizeBytes)],
323
342
  ]), { formatBorder: pc.yellow });
324
343
  }
325
344
 
326
345
  function managedModelCard(num, model, backend) {
327
346
  return renderCard(`${num}. ${model.label}`, renderRows([
328
- ["Status", statusText("info", `Local model via ${backend.label}`)],
347
+ ["Status", pc.dim(`Via ${backend.label}`)],
329
348
  ["Runs with", backend.label],
330
- ["Model ID", pc.cyan(model.id)],
349
+ ["Model ID", model.id],
331
350
  ...(model.quant ? [["Size/type", model.quant]] : []),
332
- ]), { formatBorder: pc.magenta });
351
+ ]), { formatBorder: pc.dim });
333
352
  }
334
353
 
335
- function modelCatalogItems({ profiles, newModels, managedItems }) {
354
+ function modelCatalogItems({ profiles, newModels, managedItems, drafters }) {
336
355
  return [
337
356
  ...profiles.map((profile) => ({ type: "profile", profile, label: profile.label, hint: `${profile.modelAlias} · ${profile.baseUrl}`, fileMissing: isProfileFileMissing(profile) })),
338
- ...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
+ }),
339
361
  ...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label, hint: BACKENDS[backendId].label })),
340
362
  ];
341
363
  }
@@ -366,7 +388,7 @@ async function handleCatalogAction(prompt, action, item) {
366
388
  if (action === "inspect") {
367
389
  if (item.type === "profile") return await printProfileDetails(await readProfile(item.profile.id));
368
390
  if (item.type === "managed") return printManagedModelDetails(item.model, BACKENDS[item.backendId]);
369
- return printGgufModelDetails(item.model);
391
+ return printGgufModelDetails(item.model, item.drafter);
370
392
  }
371
393
 
372
394
  if (action === "setup") {
@@ -376,7 +398,7 @@ async function handleCatalogAction(prompt, action, item) {
376
398
  await saveProfile(profile);
377
399
  return await syncPiConfig(profile);
378
400
  }
379
- const profile = await createProfileFromModel(item.model);
401
+ const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
380
402
  const configured = await configureLocalProfile(prompt, profile);
381
403
  if (!configured) return;
382
404
  await saveProfile(configured);
@@ -391,7 +413,7 @@ async function handleCatalogAction(prompt, action, item) {
391
413
  await syncPiConfig(profile);
392
414
  return await runProfile(profile);
393
415
  }
394
- const profile = await createProfileFromModel(item.model);
416
+ const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
395
417
  const configured = await configureLocalProfile(prompt, profile);
396
418
  if (!configured) return;
397
419
  await saveProfile(configured);
@@ -410,26 +432,39 @@ async function printProfileDetails(profile) {
410
432
  const fileMissing = !isManaged && isProfileFileMissing(profile);
411
433
  const statusRow = fileMissing
412
434
  ? pc.red("File missing")
413
- : running ? pc.green("Running now") : pc.green("Ready to chat");
414
- console.log("\n" + renderSection("Model overview", renderRows([
435
+ : running ? pc.green("Running now") : "Ready";
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 = [
415
442
  ["Name", pc.bold(profile.label)],
416
443
  ["Status", statusRow],
417
444
  ["Good for", fileMissing ? pc.red("Model file not found — remove or fix this setup") : humanCapabilitySummary(profile.capabilities ?? {})],
418
- ["Pi", piConfigured ? pc.green("Synced") : pc.yellow("Needs sync")],
419
- ["Server", fileMissing ? pc.red(profile.baseUrl) : pc.green(profile.baseUrl)],
420
- ])));
445
+ ["Pi", piConfigured ? pc.dim("synced") : pc.yellow("Needs sync")],
446
+ ["Server", fileMissing ? pc.red(profile.baseUrl) : profile.baseUrl],
447
+ ];
448
+ if (mtpStatus) overviewRows.push(["MTP", mtpStatus]);
449
+ console.log("\n" + renderSection("Model overview", renderRows(overviewRows)));
421
450
 
422
- console.log("\n" + renderSection("Model details", renderRows([
423
- ["Setup ID", pc.cyan(profile.id)],
451
+ const detailRows = [
452
+ ["Setup ID", profile.id],
424
453
  ["Runs with", backend.label],
425
- ["Model alias", pc.cyan(profile.modelAlias)],
454
+ ["Model alias", profile.modelAlias],
426
455
  ...(profile.capabilities ? [["Detected", capabilitySummary(profile.capabilities)]] : []),
427
- ...(!isManaged ? [
456
+ ];
457
+ if (!isManaged) {
458
+ detailRows.push(
428
459
  ["Local file", fileMissing ? pc.red(`${profile.modelPath} (not found)`) : profile.modelPath ?? "unknown"],
429
460
  ["Vision file", profile.mmprojPath ? (existsSync(profile.mmprojPath) ? profile.mmprojPath : pc.red(`${profile.mmprojPath} (not found)`)) : "none"],
430
461
  ["Model size", profile.modelPath && existsSync(profile.modelPath) ? formatBytes(statSync(profile.modelPath).size) : "unknown"],
431
- ] : []),
432
- ]), { 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 }));
433
468
 
434
469
  if (fileMissing) {
435
470
  console.log("\n" + pc.red("⚠ This model's file is no longer on disk. Remove this setup or move the file back."));
@@ -441,20 +476,33 @@ async function printProfileDetails(profile) {
441
476
  }
442
477
  }
443
478
 
444
- function printGgufModelDetails(model) {
479
+ function printGgufModelDetails(model, drafter) {
445
480
  const caps = detectCapabilities(model.path, model.mmprojPath);
446
- 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 = [
447
488
  ["Name", pc.bold(model.label)],
448
489
  ["Status", pc.yellow("Needs one-time setup")],
449
490
  ["Good for", humanCapabilitySummary(caps)],
450
- ["Size", formatBytes(model.sizeBytes)],
451
- ])));
452
- 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 = [
453
496
  ["Local file", model.path],
454
497
  ["Vision file", model.mmprojPath ?? "none"],
455
498
  ["Detected", capabilitySummary(caps)],
456
499
  ["Quant", model.quant ?? "unknown"],
457
- ]), { 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 }));
458
506
  }
459
507
 
460
508
  function printManagedModelDetails(model, backend) {
@@ -659,9 +707,9 @@ async function statusCommand() {
659
707
  if (running.length === 0) {
660
708
  console.log(renderCard("Status", renderRows([
661
709
  ["Running now", pc.dim("none")],
662
- ["Ready setups", profiles.length > 0 ? pc.green(String(profiles.length)) : pc.dim("none")],
663
- ["Next step", profiles.length > 0 ? "Run offgrid-ai to start chatting" : "Run offgrid-ai to set up a model"],
664
- ]), { formatBorder: profiles.length > 0 ? pc.cyan : pc.yellow }));
710
+ ["Ready setups", profiles.length > 0 ? String(profiles.length) : pc.dim("none")],
711
+ ["Next step", profiles.length > 0 ? "Run offgrid-ai to start chatting" : pc.yellow("Run offgrid-ai to set up a model")],
712
+ ]), { formatBorder: pc.dim }));
665
713
  return;
666
714
  }
667
715
 
@@ -870,7 +918,7 @@ async function onboardFlow() {
870
918
  console.log(pc.green("✓ Pi found"));
871
919
 
872
920
  // 4. Model backends — at least one is mandatory
873
- const ggufModels = await scanGgufModels();
921
+ const { models: ggufModels } = await scanGgufModels();
874
922
  const managedModels = await scanManagedModels();
875
923
  const totalManaged = managedModels.reduce((sum, m) => sum + m.models.length, 0);
876
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
  }