offgrid-ai 0.6.4 → 0.6.5

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.6.4",
3
+ "version": "0.6.5",
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",
@@ -73,13 +73,14 @@ export function computeFlags(capabilities, modelPath, mmprojPath, draftModelPath
73
73
  flags.chatTemplateKwargs = { enable_thinking: true };
74
74
  }
75
75
 
76
- // Build argv
77
- const argv = [
78
- "--model", modelPath,
79
- ];
80
-
81
- if (mmprojPath) argv.push("--mmproj", mmprojPath);
82
- if (draftModelPath) argv.push("--spec-draft-model", draftModelPath);
76
+ // Build argv. Prefer llama.cpp's Hugging Face resolver when a repo ref is
77
+ // known, so companion files such as Gemma 4 MTP drafters are discovered by
78
+ // llama.cpp instead of offgrid-ai maintaining repo-specific file rules.
79
+ const hfRef = capabilities.hfRepo ? `${capabilities.hfRepo}${capabilities.hfVariant ? `:${capabilities.hfVariant}` : ""}` : null;
80
+ const argv = hfRef ? ["-hf", hfRef] : ["--model", modelPath];
81
+
82
+ if (mmprojPath && !hfRef) argv.push("--mmproj", mmprojPath);
83
+ if (draftModelPath && !hfRef) argv.push("--spec-draft-model", draftModelPath);
83
84
 
84
85
  argv.push(
85
86
  "--host", String(flags.host),
package/src/cli.mjs CHANGED
@@ -299,6 +299,7 @@ function optionSourceTag(sourceId, label) {
299
299
  ollama: pc.green,
300
300
  omlx: pc.magenta,
301
301
  gguf: pc.cyan,
302
+ hf: pc.cyan,
302
303
  };
303
304
  return optionTag(label, colors[sourceId] ?? pc.dim, OPTION_SOURCE_WIDTH);
304
305
  }
@@ -331,13 +332,13 @@ function modelSelectOption(item, { runningProfilesNow, drafters }) {
331
332
  if (item.type === "new") {
332
333
  const { model, drafter } = item;
333
334
  const caps = detectCapabilities(model.path, model.mmprojPath);
334
- const mtpAvailable = caps.mtp || Boolean(drafter);
335
+ const mtpAvailable = caps.mtp || Boolean(drafter) || Boolean(model.hfRepo && /gemma-?4/i.test(`${model.hfRepo} ${model.label}`));
335
336
  let mtpLabel;
336
337
  if (mtpAvailable) mtpLabel = pc.green("MTP available");
337
338
  else if (caps.architecture === "gemma4") mtpLabel = pc.yellow("MTP needs drafter");
338
339
  const label = optionLabel({
339
340
  status: optionStatusTag("setup"),
340
- source: optionSourceTag("gguf", "GGUF file"),
341
+ source: optionSourceTag(model.hfRepo ? "hf" : "gguf", model.hfRepo ? "Hugging Face" : "GGUF file"),
341
342
  name: model.label,
342
343
  details: [pc.dim(formatBytes(model.sizeBytes)), mtpLabel],
343
344
  });
@@ -434,7 +435,7 @@ function printModelCards(items, { runningProfilesNow, drafters }) {
434
435
  for (const item of newModels) {
435
436
  const { model, drafter } = item;
436
437
  const caps = detectCapabilities(model.path, model.mmprojPath);
437
- const mtpAvailable = caps.mtp || Boolean(drafter);
438
+ const mtpAvailable = caps.mtp || Boolean(drafter) || Boolean(model.hfRepo && /gemma-?4/i.test(`${model.hfRepo} ${model.label}`));
438
439
  const mtpLabel = mtpAvailable
439
440
  ? pc.green("MTP ✓")
440
441
  : (caps.architecture === "gemma4")
@@ -542,6 +543,7 @@ async function printProfileDetails(profile) {
542
543
  ];
543
544
  if (!isManaged) {
544
545
  detailRows.push(
546
+ ...(profile.hfRepo ? [["Hugging Face", `${profile.hfRepo}${profile.hfVariant ? `:${profile.hfVariant}` : ""}`]] : []),
545
547
  ["Local file", fileMissing ? pc.red(`${profile.modelPath} (not found)`) : profile.modelPath ?? "unknown"],
546
548
  ["Vision file", profile.mmprojPath ? (existsSync(profile.mmprojPath) ? profile.mmprojPath : pc.red(`${profile.mmprojPath} (not found)`)) : "none"],
547
549
  ["Model size", profile.modelPath && existsSync(profile.modelPath) ? formatBytes(statSync(profile.modelPath).size) : "unknown"],
@@ -564,7 +566,7 @@ async function printProfileDetails(profile) {
564
566
 
565
567
  function printGgufModelDetails(model, drafter) {
566
568
  const caps = detectCapabilities(model.path, model.mmprojPath);
567
- const mtpAvailable = caps.mtp || Boolean(drafter);
569
+ const mtpAvailable = caps.mtp || Boolean(drafter) || Boolean(model.hfRepo && /gemma-?4/i.test(`${model.hfRepo} ${model.label}`));
568
570
  const mtpLabel = mtpAvailable
569
571
  ? pc.green("MTP ✓")
570
572
  : (caps.architecture === "gemma4")
@@ -582,6 +584,7 @@ function printGgufModelDetails(model, drafter) {
582
584
  ];
583
585
  console.log("\n" + renderSection("Downloaded model", renderRows(overviewRows)));
584
586
  const detailRows = [
587
+ ...(model.hfRepo ? [["Hugging Face", `${model.hfRepo}${model.hfVariant ? `:${model.hfVariant}` : ""}`]] : []),
585
588
  ["Local file", model.path],
586
589
  ["Vision file", model.mmprojPath ?? "none"],
587
590
  ["Detected", capabilitySummary(caps)],
@@ -34,14 +34,16 @@ export async function configureLocalProfile(prompt, profile) {
34
34
  let configured = profile;
35
35
  // Re-detect capabilities from the model file and check for drafters
36
36
  // so that re-setup can pick up MTP availability, vision changes, etc.
37
- const freshCaps = detectCapabilities(profile.modelPath, profile.mmprojPath);
37
+ const hfSource = profile.hfRepo ? { hfRepo: profile.hfRepo, hfVariant: profile.hfVariant } : {};
38
+ const freshCaps = { ...detectCapabilities(profile.modelPath, profile.mmprojPath), ...hfSource };
38
39
  let drafterPath = profile.drafterPath ?? null;
39
- if (!drafterPath) {
40
+ if (!drafterPath && !profile.hfRepo) {
40
41
  const { drafters } = await scanGgufModels();
41
42
  const drafter = matchDrafter(profile.modelPath, drafters);
42
43
  if (drafter) drafterPath = drafter.path;
43
44
  }
44
- const hasMtp = freshCaps.mtp || Boolean(drafterPath);
45
+ const hfGemma4Mtp = Boolean(profile.hfRepo && /gemma-?4/i.test(`${profile.hfRepo} ${profile.label}`));
46
+ const hasMtp = freshCaps.mtp || Boolean(drafterPath) || hfGemma4Mtp;
45
47
  const caps = { ...freshCaps, mtp: hasMtp };
46
48
  // If MTP is newly available, switch backend and add drafter path
47
49
  if (hasMtp && configured.backend !== "llama-cpp-mtp") {
@@ -71,7 +73,7 @@ export async function configureLocalProfile(prompt, profile) {
71
73
  console.log(renderSection("MTP detected", renderRows([
72
74
  ["Backend", "llama.cpp MTP"],
73
75
  ["Port", String(LLAMA_CPP_MTP_PORT)],
74
- ["Flags", `--spec-type draft-mtp --spec-draft-n-max 4${configured.drafterPath ? " --spec-draft-model <drafter>" : ""}`],
76
+ ["Flags", `--spec-type draft-mtp --spec-draft-n-max 4${configured.drafterPath && !configured.hfRepo ? " --spec-draft-model <drafter>" : ""}`],
75
77
  ])));
76
78
  if (drafterInfo) console.log(pc.dim(drafterInfo));
77
79
  const useMtp = await prompt.yesNo("Use MTP speculative decoding?", true);
@@ -142,7 +144,7 @@ function applyMtpDefaults(profile) {
142
144
  const edits = {
143
145
  values: { "--spec-type": "draft-mtp", "--spec-draft-n-max": 4 },
144
146
  };
145
- if (profile.drafterPath) {
147
+ if (profile.drafterPath && !profile.hfRepo) {
146
148
  edits.values["--spec-draft-model"] = profile.drafterPath;
147
149
  }
148
150
  return applyProfileFlags({ ...profile, backend: "llama-cpp-mtp", providerId: "llama-cpp-mtp" }, flags, edits);
package/src/profiles.mjs CHANGED
@@ -134,11 +134,15 @@ export function normalizeProfile(profile) {
134
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
- // If a drafter is provided, this model supports MTP regardless of filename
138
- const hasMtp = caps.mtp || Boolean(drafterPath);
137
+ // If a drafter is provided, this model supports MTP regardless of filename.
138
+ // For Hugging Face Gemma 4 GGUF repos, prefer llama.cpp's -hf companion-file
139
+ // discovery so root mtp-*.gguf drafters do not need offgrid-ai file matching.
140
+ const hfGemma4Mtp = Boolean(model.hfRepo && /gemma-?4/i.test(`${model.hfRepo} ${model.label}`));
141
+ const hasMtp = caps.mtp || Boolean(drafterPath) || hfGemma4Mtp;
139
142
  const backend = backendId ?? (hasMtp ? "llama-cpp-mtp" : "llama-cpp");
143
+ const hfSource = model.hfRepo ? { hfRepo: model.hfRepo, hfVariant: model.hfVariant } : {};
140
144
  const { flags, argv } = computeFlags(
141
- { ...caps, mtp: hasMtp },
145
+ { ...caps, mtp: hasMtp, ...hfSource },
142
146
  model.path,
143
147
  model.mmprojPath,
144
148
  drafterPath ?? null,
@@ -150,10 +154,12 @@ export async function createProfileFromModel(model, backendId, drafterPath) {
150
154
  backend,
151
155
  providerId: backend,
152
156
  modelAlias: model.aliasSuggestion,
157
+ source: model.hfRepo ? "huggingface" : model.source,
153
158
  modelPath: model.path,
154
- mmprojPath: model.mmprojPath,
159
+ mmprojPath: model.hfRepo ? null : model.mmprojPath,
160
+ ...hfSource,
155
161
  drafterPath: drafterPath ?? null,
156
- capabilities: summarizeCapabilities({ ...caps, mtp: hasMtp }),
162
+ capabilities: summarizeCapabilities({ ...caps, mtp: hasMtp, ...hfSource }),
157
163
  preset: null, // no presets — auto-detected
158
164
  flags,
159
165
  commandArgv: argv,
@@ -171,6 +177,8 @@ function summarizeCapabilities(caps) {
171
177
  quant: caps.quant,
172
178
  metaCtx: caps.metaCtx,
173
179
  mmprojProjectorType: caps.mmprojProjectorType,
180
+ hfRepo: caps.hfRepo,
181
+ hfVariant: caps.hfVariant,
174
182
  ctxSize: caps.ctxSize,
175
183
  };
176
184
  }
package/src/scan.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { statSync } from "node:fs";
2
2
  import { readdir } from "node:fs/promises";
3
- import { basename, dirname, join } from "node:path";
3
+ import { basename, dirname, join, sep } from "node:path";
4
4
  import { getModelScanDirs } from "./config.mjs";
5
5
  import { readGgufMetadata } from "./gguf.mjs";
6
6
 
@@ -67,6 +67,7 @@ async function scanOneDir(root) {
67
67
  source: "local-gguf",
68
68
  });
69
69
  } else {
70
+ const hf = inferHfRef(path, name);
70
71
  models.push({
71
72
  path,
72
73
  mmprojPath,
@@ -75,7 +76,8 @@ async function scanOneDir(root) {
75
76
  quant: quantFromName(name),
76
77
  sizeBytes,
77
78
  backend: "llama-cpp",
78
- source: "local-gguf",
79
+ source: hf ? "huggingface" : "local-gguf",
80
+ ...(hf ? { hfRepo: hf.repo, hfVariant: hf.variant } : {}),
79
81
  });
80
82
  }
81
83
  }
@@ -158,6 +160,24 @@ function quantFromName(name) {
158
160
  return name.match(/(Q\d_K_[A-Z]+|Q\d_[01]|UD-[A-Z0-9_]+)/)?.[1];
159
161
  }
160
162
 
163
+ export function inferHfRef(path, name = basename(path).replace(/\.gguf$/i, "")) {
164
+ const parts = path.split(sep);
165
+ const hubIndex = parts.lastIndexOf("hub");
166
+ const hfCacheRepo = hubIndex >= 0 ? parts.find((part, index) => index > hubIndex && part.startsWith("models--")) : null;
167
+ if (hfCacheRepo) {
168
+ return { repo: hfCacheRepo.replace(/^models--/u, "").replace(/--/gu, "/"), variant: quantFromName(name) ?? name };
169
+ }
170
+
171
+ const lmStudioIndex = parts.lastIndexOf(".lmstudio");
172
+ if (lmStudioIndex >= 0 && parts[lmStudioIndex + 1] === "models") {
173
+ const org = parts[lmStudioIndex + 2];
174
+ const repo = parts[lmStudioIndex + 3];
175
+ if (org && repo) return { repo: `${org}/${repo}`, variant: quantFromName(name) ?? name };
176
+ }
177
+
178
+ return null;
179
+ }
180
+
161
181
  function safeReadGgufMetadata(path) {
162
182
  try {
163
183
  return readGgufMetadata(path);