offgrid-ai 0.16.3 → 0.18.0

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.
@@ -1,16 +1,13 @@
1
- import { ensureDirs, findLlamaServer, ensureHomebrewFor, HF_HUB_DIR } from "../config.mjs";
1
+ import { ensureDirs, findLlamaServer } from "../config.mjs";
2
2
  import { BACKENDS } from "../backends.mjs";
3
3
  import { scanGgufModels } from "../scan.mjs";
4
4
  import { hasPi } from "../harness-pi.mjs";
5
5
  import { offerManagedLlamaRuntimeUpdate } from "../runtime.mjs";
6
6
  import { ensureOmlxRuntime } from "../omlx-runtime.mjs";
7
7
  import { scanManagedModels } from "../managed.mjs";
8
- import { BACKEND_INSTALL_CHOICES, BACKEND_INSTALLERS } from "../backend-installers.mjs";
9
- import { recommendedModel, selectFormat, allFittingModels } from "../recommendations.mjs";
10
- import { hasHuggingfaceHub, resolveHfDownload, downloadToHfCache } from "../huggingface.mjs";
11
- import { detectHardware, getFreeDiskBytes, installedRamGB } from "../hardware.mjs";
8
+ import { downloadFlow } from "../download.mjs";
12
9
  import { runCommand } from "../exec.mjs";
13
- import { pc, formatBytes, renderRows, renderSection, startInteractive, createPrompt } from "../ui.mjs";
10
+ import { pc, renderRows, renderSection, startInteractive, createPrompt } from "../ui.mjs";
14
11
 
15
12
  export async function onboardFlow() {
16
13
  await ensureDirs();
@@ -37,12 +34,10 @@ export async function onboardFlow() {
37
34
  if (hasModels) {
38
35
  printFoundModels(ggufModels, managedModels, llamaBinary);
39
36
  } else {
40
- const canDownload = await hasHuggingfaceHub();
41
- if (canDownload) {
42
- const downloaded = await offerModelDownload(prompt);
43
- if (downloaded) return;
37
+ const downloaded = await downloadFlow(prompt);
38
+ if (!downloaded) {
39
+ console.log(pc.dim("\nRun offgrid-ai again when you've downloaded a model."));
44
40
  }
45
- await offerBackendInstall(prompt, run);
46
41
  return;
47
42
  }
48
43
 
@@ -109,98 +104,3 @@ function printFoundModels(ggufModels, managedModels, llamaBinary) {
109
104
  }
110
105
  }
111
106
 
112
- async function offerModelDownload(prompt) {
113
- const hardware = detectHardware();
114
- const candidates = allFittingModels(hardware)
115
- .map((entry) => ({ entry, format: selectFormat(entry, hardware) }))
116
- .filter((item) => item.format === "gguf");
117
- if (candidates.length === 0) {
118
- console.log(pc.yellow("No curated models fit your hardware."));
119
- return false;
120
- }
121
-
122
- const primary = candidates[0];
123
- console.log(renderSection("Download a recommended model", renderRows([
124
- ["Model", pc.bold(primary.entry.label)],
125
- ["Format", primary.format],
126
- ["Minimum RAM", String(primary.entry.minRamGb) + " GB"],
127
- ["Your RAM", installedRamGB() + " GB"],
128
- ]), { formatBorder: pc.cyan }));
129
-
130
- const shouldDownload = await prompt.yesNo("Download " + primary.entry.label + " (" + primary.format + ")?", true);
131
- if (!shouldDownload) return false;
132
-
133
- const hfRef = primary.entry.gguf;
134
- try {
135
- const plan = await resolveHfDownload(hfRef);
136
- console.log(pc.dim("Total size: " + formatBytes(plan.totalSizeBytes)));
137
- const freeBytes = getFreeDiskBytes(HF_HUB_DIR);
138
- if (plan.totalSizeBytes > 0 && freeBytes < plan.totalSizeBytes * 1.1) {
139
- console.log(pc.red(`Not enough disk space in ${HF_HUB_DIR}: need ~${formatBytes(plan.totalSizeBytes)}, only ${formatBytes(freeBytes)} free.`));
140
- return false;
141
- }
142
- await downloadToHfCache(plan, {
143
- onProgress({ percentage }) {
144
- process.stdout.write(pc.cyan("\r " + percentage + "% downloaded"));
145
- },
146
- });
147
- process.stdout.write("\n");
148
- console.log(pc.green("✓ Download complete. Run offgrid-ai to use the model."));
149
- return true;
150
- } catch (err) {
151
- console.log(pc.red("Download failed: " + err.message));
152
- return false;
153
- }
154
- }
155
-
156
- async function offerBackendInstall(prompt, run) {
157
- console.log(pc.yellow("\nNo models found."));
158
- console.log(pc.dim("You need at least one model backend to use offgrid-ai.\n"));
159
- const choice = await prompt.choice("Install a model backend?", BACKEND_INSTALL_CHOICES, "lmstudio");
160
- const model = recommendedModel();
161
-
162
- if (choice === "skip") {
163
- console.log(pc.dim("Run offgrid-ai again when you've set up a model backend."));
164
- return;
165
- }
166
- if (choice === "all") {
167
- await installAllBackends(prompt, run, model);
168
- return;
169
- }
170
- await installBackend(prompt, run, choice, model);
171
- }
172
-
173
- async function installBackend(prompt, run, backendId, model) {
174
- const installer = BACKEND_INSTALLERS[backendId];
175
- if (!(await ensureHomebrewFor(prompt, run, installer.label))) return;
176
- console.log(pc.cyan(`Installing ${installer.label} via Homebrew...`));
177
- try {
178
- await runInstallerCommands(run, installer);
179
- installer.success(model);
180
- } catch {
181
- console.log(pc.red(`✗ ${installer.label} installation failed.`));
182
- console.log(pc.dim(installer.failure));
183
- }
184
- }
185
-
186
- async function installAllBackends(prompt, run, model) {
187
- if (!(await ensureHomebrewFor(prompt, run, "model backends"))) return;
188
- const installed = [];
189
- for (const installer of Object.values(BACKEND_INSTALLERS)) {
190
- console.log(pc.cyan(`Installing ${installer.label} via Homebrew...`));
191
- try {
192
- await runInstallerCommands(run, installer);
193
- installed.push(installer.label);
194
- } catch {
195
- console.log(pc.yellow(installer.allFailure));
196
- }
197
- }
198
- if (installed.length > 0) {
199
- console.log(pc.green(`\n✓ Installed: ${installed.join(", ")}`));
200
- console.log(pc.dim(`Recommended for your machine (${installedRamGB()}GB RAM): ${model.label}`));
201
- }
202
- }
203
-
204
- async function runInstallerCommands(run, installer) {
205
- for (const [cmd, args, label] of installer.commands) await run(cmd, args, label);
206
- }
@@ -46,6 +46,7 @@ export async function runProfile(profile, options = {}) {
46
46
 
47
47
  printMemoryEstimate(profile, isManaged);
48
48
  await launchHarness(profile, options, isManaged, withHarness, backend);
49
+ console.log("");
49
50
  }
50
51
 
51
52
  async function ensureLocalServer(profile, backend, options) {
@@ -113,6 +114,7 @@ async function launchHarness(profile, options, isManaged, withHarness, backend)
113
114
  }
114
115
 
115
116
  if (!(await hasPiModel(profile))) await syncPiConfig(profile);
117
+
116
118
  try {
117
119
  await launchPi(profile);
118
120
  } finally {
@@ -121,10 +123,6 @@ async function launchHarness(profile, options, isManaged, withHarness, backend)
121
123
  const result = await stopProfile(profile);
122
124
  console.log(result.stopped ? pc.green(`[stop] ${result.message}`) : pc.dim(`[stop] ${result.message}`));
123
125
  } else {
124
- // Managed-server backends (oMLX): unload the model from the
125
- // server's memory via its HTTP API. The server itself stays running
126
- // (offgrid-ai doesn't manage it), but the model is released — same UX
127
- // as local-server backends where stopProfile kills the process.
128
126
  const result = await unloadModelFromServer(profile);
129
127
  if (result.unloaded) {
130
128
  console.log(pc.green(`[unload] ${backend.label}: model unloaded`));
@@ -85,4 +85,5 @@ export async function statusCommand() {
85
85
  ["Server", profile.baseUrl],
86
86
  ]), { formatBorder: status.ready ? pc.green : pc.yellow }));
87
87
  }
88
+ console.log("");
88
89
  }
@@ -34,6 +34,7 @@ export async function stopCommand(argv) {
34
34
 
35
35
  const targets = selected === "__all" ? running : running.filter((item) => item.profile.id === selected);
36
36
  for (const { profile } of targets) await printStopResult(profile);
37
+ console.log("");
37
38
  } finally {
38
39
  prompt.close();
39
40
  }
package/src/config.mjs CHANGED
@@ -26,6 +26,7 @@ export const HF_HUB_DIR = process.env.HF_HUB_CACHE
26
26
 
27
27
  export const DEFAULT_MODEL_DIRS = [
28
28
  join(homedir(), ".lmstudio", "models"),
29
+ join(homedir(), ".omlx", "models"),
29
30
  HF_HUB_DIR,
30
31
  ];
31
32
 
@@ -47,7 +48,6 @@ const CONFIG_PATH = join(DATA_DIR, "config.json");
47
48
 
48
49
  const DEFAULT_CONFIG = {
49
50
  modelScanDirs: [],
50
- benchmarkRepoPath: null,
51
51
  binaryOverrides: {},
52
52
  };
53
53
 
@@ -78,6 +78,21 @@ export async function getModelScanDirs() {
78
78
  return [...DEFAULT_MODEL_DIRS, ...config.modelScanDirs].filter((dir, i, arr) => arr.indexOf(dir) === i);
79
79
  }
80
80
 
81
+ export async function addModelScanDir(dir) {
82
+ const config = await loadConfig();
83
+ config.modelScanDirs ??= [];
84
+ if (!config.modelScanDirs.includes(dir)) {
85
+ config.modelScanDirs.push(dir);
86
+ await saveConfig(config);
87
+ }
88
+ }
89
+
90
+ export async function removeModelScanDir(dir) {
91
+ const config = await loadConfig();
92
+ config.modelScanDirs = (config.modelScanDirs ?? []).filter((d) => d !== dir);
93
+ await saveConfig(config);
94
+ }
95
+
81
96
  // ── Binary discovery ──────────────────────────────────────────────────────
82
97
 
83
98
  import { execFile } from "node:child_process";
@@ -1,6 +1,5 @@
1
- // Shared discovery helpers used by both the GGUF scanner (scan.mjs) and the
2
- // MLX scanner (mlx-discovery.mjs). Keeping these here avoids a cross-dependency
3
- // between the two format-specific scanners.
1
+ // Helpers for the GGUF scanner (scan.mjs). Centralizes model-size and embedding-type
2
+ // constants so they're defined once rather than duplicated across scanners.
4
3
 
5
4
  import { basename, dirname } from "node:path";
6
5
 
@@ -0,0 +1,221 @@
1
+ // Model download flow — HuggingFace downloads with quant picker and RAM fit.
2
+ // Used by onboarding (no models found) and the model picker (↓ Download a model).
3
+
4
+ import { hasHfCli, parseHfRef, resolveHfDownload, downloadModel, listGgufFiles, getHfModelInfo, isMlxRepo } from "./huggingface.mjs";
5
+ import { detectHardware, installedRamGB, getFreeDiskBytes } from "./hardware.mjs";
6
+ import { allFittingModels } from "./recommendations.mjs";
7
+ import { parseModelName } from "./model-name.mjs";
8
+ import { HF_HUB_DIR } from "./config.mjs";
9
+ import { offerOmlxRestart } from "./omlx-runtime.mjs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { pc, formatBytes, renderCard, renderRows } from "./ui.mjs";
13
+
14
+ const GB = 1024 ** 3;
15
+
16
+ /**
17
+ * Interactive model download flow.
18
+ * @param {object} prompt - createPrompt() instance
19
+ * @returns {Promise<boolean>} true if a model was downloaded
20
+ */
21
+ export async function downloadFlow(prompt) {
22
+ console.log("");
23
+ const method = await prompt.choice("Download a model", [
24
+ { value: "manual", label: "Enter a HuggingFace repo ID" },
25
+ { value: "recommended", label: "Recommended for your machine" },
26
+ ], "manual");
27
+
28
+ if (!method) return false;
29
+
30
+ let repo, filename;
31
+
32
+ if (method === "recommended") {
33
+ const hardware = detectHardware();
34
+ const models = allFittingModels(hardware);
35
+ if (models.length === 0) {
36
+ console.log(pc.yellow("No recommended models fit your hardware."));
37
+ console.log(pc.dim("You can still enter a repo ID manually."));
38
+ return false;
39
+ }
40
+ const choices = models.map((m) => ({
41
+ value: m,
42
+ label: `${pc.bold(m.label)} ${pc.dim(`(${m.minRamGb} GB RAM min)`)}`,
43
+ }));
44
+ const selected = await prompt.choice("Select a model", choices, choices[0].value);
45
+ if (!selected) return false;
46
+
47
+ // Determine available formats (ignore empty strings)
48
+ const hasGguf = Boolean(selected.gguf);
49
+ const hasMlx = Boolean(selected.mlx && selected.mlx.trim());
50
+
51
+ let format;
52
+ if (hasGguf && hasMlx) {
53
+ // Both available — let the user choose
54
+ const formatChoices = [
55
+ { value: "gguf", label: `GGUF (llama.cpp) — ${selected.gguf.split("/").pop()}` },
56
+ { value: "mlx", label: `MLX (oMLX) — ${selected.mlx}` },
57
+ ];
58
+ // Default to MLX on Apple Silicon, GGUF elsewhere
59
+ const defaultFormat = (hardware.platform === "darwin" && hardware.arch === "arm64") ? "mlx" : "gguf";
60
+ format = await prompt.choice("Download format", formatChoices, defaultFormat);
61
+ if (!format) return false;
62
+ } else if (hasGguf) {
63
+ format = "gguf";
64
+ } else if (hasMlx) {
65
+ format = "mlx";
66
+ } else {
67
+ console.log(pc.yellow("No download path available for this model."));
68
+ return false;
69
+ }
70
+
71
+ if (format === "gguf") {
72
+ const ref = parseHfRef(selected.gguf);
73
+ repo = ref.repo;
74
+ filename = ref.filename;
75
+ } else {
76
+ repo = selected.mlx;
77
+ filename = undefined;
78
+ }
79
+ } else {
80
+ console.log(pc.dim(" Browse models at huggingface.co/models"));
81
+ const input = await prompt.text("HuggingFace repo ID (e.g. unsloth/gemma-4-E2B-it-GGUF)", "");
82
+ if (!input || !input.trim()) return false;
83
+ const ref = parseHfRef(input.trim());
84
+ repo = ref.repo;
85
+ filename = ref.filename;
86
+ }
87
+
88
+ // For GGUF repos without a specific file, show quant picker
89
+ if (!filename) {
90
+ let ggufFiles;
91
+ try {
92
+ ggufFiles = await listGgufFiles(repo);
93
+ } catch (err) {
94
+ console.log(pc.red(`Could not fetch repo info: ${err.message}`));
95
+ return false;
96
+ }
97
+ if (ggufFiles.length > 0) {
98
+ filename = await pickGgufQuant(prompt, repo, ggufFiles);
99
+ if (!filename) return false;
100
+ } else {
101
+ // No GGUF files — check if it's an MLX repo via HF metadata
102
+ let modelInfo;
103
+ try {
104
+ modelInfo = await getHfModelInfo(repo);
105
+ } catch {
106
+ console.log(pc.red(`Could not fetch repo info for ${repo}. Check the repo ID and try again.`));
107
+ return false;
108
+ }
109
+ if (!isMlxRepo(modelInfo)) {
110
+ console.log(pc.yellow(`This repo is not a GGUF or MLX model (library: ${modelInfo.library_name ?? "unknown"}).`));
111
+ console.log(pc.dim("For llama.cpp: look for a repo ending in -GGUF (e.g. org/model-name-GGUF)"));
112
+ console.log(pc.dim("For oMLX: look for a repo in mlx-community/ (e.g. mlx-community/model-name-4bit)"));
113
+ return false;
114
+ }
115
+ // It's MLX — download everything
116
+ }
117
+ }
118
+
119
+ // Check for huggingface_hub
120
+ if (!(await hasHfCli())) {
121
+ console.log(pc.yellow("HuggingFace CLI is required to download models."));
122
+ console.log(pc.dim("Install it: pip3 install huggingface_hub"));
123
+ return false;
124
+ }
125
+
126
+ // Resolve download plan
127
+ const ref = filename ? `${repo}/${filename}` : repo;
128
+ let plan;
129
+ try {
130
+ plan = await resolveHfDownload(ref);
131
+ } catch (err) {
132
+ console.log(pc.red(`Could not resolve download: ${err.message}`));
133
+ return false;
134
+ }
135
+
136
+ // Check disk space
137
+ const freeBytes = getFreeDiskBytes(HF_HUB_DIR);
138
+ if (plan.totalSizeBytes > 0 && freeBytes < plan.totalSizeBytes * 1.1) {
139
+ console.log(pc.red(`Not enough disk space: need ~${formatBytes(plan.totalSizeBytes)}, only ${formatBytes(freeBytes)} free.`));
140
+ return false;
141
+ }
142
+
143
+ console.log(pc.dim(`\nDownloading ${repo}${filename ? `/${filename}` : ""} (${formatBytes(plan.totalSizeBytes)})`));
144
+ if (plan.format === "mlx") {
145
+ const modelParts = repo.split("/").filter(Boolean);
146
+ const localDir = join(homedir(), ".omlx", "models", ...modelParts);
147
+ console.log(pc.dim(`Location: ${localDir}\n`));
148
+ } else {
149
+ console.log(pc.dim(`Location: HF cache (${HF_HUB_DIR})\n`));
150
+ }
151
+
152
+ try {
153
+ if (plan.format === "mlx") {
154
+ // Download directly to ~/.omlx/models/<org>/<model> — oMLX scans this dir
155
+ const modelParts = repo.split("/").filter(Boolean);
156
+ const localDir = join(homedir(), ".omlx", "models", ...modelParts);
157
+ await downloadModel(plan, { localDir });
158
+ console.log(pc.green("\n✓ Download complete."));
159
+ await offerOmlxRestart(prompt, "to load the new model");
160
+ } else {
161
+ await downloadModel(plan);
162
+ console.log(pc.green("\n✓ Download complete. Run offgrid-ai again to see the model in the picker."));
163
+ }
164
+ return true;
165
+ } catch (err) {
166
+ console.log(pc.red("\nDownload failed: " + err.message));
167
+ return false;
168
+ }
169
+ }
170
+
171
+ // ── Quant picker with RAM fit indicators ───────────────────────────────────
172
+
173
+ async function pickGgufQuant(prompt, repo, ggufFiles) {
174
+ const hardware = detectHardware();
175
+ const totalRam = hardware.totalRamBytes;
176
+ const availableRam = totalRam - 4 * GB; // leave 4GB for OS
177
+
178
+ // Sort by size descending (highest quality first)
179
+ const sorted = [...ggufFiles].sort((a, b) => b.sizeBytes - a.sizeBytes);
180
+
181
+ // Find recommended: largest file that fits comfortably
182
+ const recommended = sorted.find((f) => f.sizeBytes + 2 * GB <= availableRam);
183
+
184
+ console.log("");
185
+ console.log(renderCard("Select quantization", renderRows([
186
+ ["Your RAM", `${installedRamGB()} GB`],
187
+ ["Available", `~${formatBytes(availableRam)} (after OS)`],
188
+ ["Rule", "Lower quant = smaller/faster · Higher = better quality"],
189
+ ]), { formatBorder: pc.cyan }));
190
+ console.log("");
191
+
192
+ const choices = sorted.map((file) => {
193
+ const sizeBytes = file.sizeBytes;
194
+ const parsed = parseModelName(file.path, "huggingface");
195
+ const quant = parsed.quant ?? file.path.replace(/\.gguf$/i, "");
196
+
197
+ let indicator, fitLabel;
198
+ if (sizeBytes > availableRam) {
199
+ indicator = pc.red("✗");
200
+ fitLabel = pc.red("won't fit");
201
+ } else if (sizeBytes + 2 * GB > availableRam) {
202
+ indicator = pc.yellow("⚠");
203
+ fitLabel = pc.yellow("tight");
204
+ } else {
205
+ indicator = pc.green("✓");
206
+ fitLabel = pc.green("fits");
207
+ }
208
+
209
+ const isRecommended = recommended && file.path === recommended.path;
210
+ const hint = isRecommended ? "recommended" : undefined;
211
+
212
+ return {
213
+ value: file.path,
214
+ label: `${indicator} ${quant.padEnd(12)} ${formatBytes(sizeBytes).padEnd(10)} ${fitLabel}`,
215
+ ...(hint ? { hint } : {}),
216
+ };
217
+ });
218
+
219
+ const defaultValue = recommended?.path;
220
+ return await prompt.choice("Quantization", choices, defaultValue);
221
+ }
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
- import { spawn } from "node:child_process";
2
+ import { execFile, spawn } from "node:child_process";
3
+ import { promisify } from "node:util";
3
4
  import { PI_CONFIG } from "./config.mjs";
4
5
  import { loadProfiles } from "./profiles.mjs";
5
6
  import { readJson, writeJson } from "./json.mjs";
@@ -69,8 +70,6 @@ export async function launchPi(profile) {
69
70
 
70
71
  export async function hasPi() {
71
72
  try {
72
- const { execFile } = await import("node:child_process");
73
- const { promisify } = await import("node:util");
74
73
  await promisify(execFile)("which", ["pi"]);
75
74
  return true;
76
75
  } catch {
@@ -1,23 +1,19 @@
1
1
  // HuggingFace model download helpers.
2
- // Uses the Python huggingface_hub package (the standard, maintained downloader)
3
- // to download models into the standard HF cache directory.
4
- // Downloads go to ~/.cache/huggingface/hub, NOT a custom offgrid-ai folder.
2
+ // Uses the `hf` CLI (from huggingface_hub) for actual downloads.
3
+ // The interactive model/quant selection happens in download.mjs; here we
4
+ // just hand off to the CLI and let it handle progress bars, resumption, etc.
5
5
 
6
- import { execFile } from "node:child_process";
6
+ import { spawn, execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
- import { join, dirname } from "node:path";
9
8
  import { mkdir } from "node:fs/promises";
10
- import { fileURLToPath } from "node:url";
11
9
  import { HF_HUB_DIR } from "./config.mjs";
12
10
 
13
11
  const execFileAsync = promisify(execFile);
14
12
 
15
- const HF_DOWNLOAD_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "..", "resources", "hf-download.py");
16
-
17
- /** Check whether python3 + huggingface_hub is available. */
18
- export async function hasHuggingfaceHub() {
13
+ /** Check whether the `hf` CLI is available. */
14
+ export async function hasHfCli() {
19
15
  try {
20
- const { stdout } = await execFileAsync("python3", ["-c", "import huggingface_hub; print(huggingface_hub.__version__)"]);
16
+ const { stdout } = await execFileAsync("hf", ["--version"]);
21
17
  return Boolean(stdout.trim());
22
18
  } catch {
23
19
  return false;
@@ -96,6 +92,33 @@ async function getHfTree(repo, { branch = "main", fetchImpl = globalThis.fetch }
96
92
  return await response.json();
97
93
  }
98
94
 
95
+ /** List all GGUF files in a HuggingFace repo with their sizes. */
96
+ export async function listGgufFiles(repo, { fetchImpl = globalThis.fetch } = {}) {
97
+ const tree = await getHfTree(repo, { fetchImpl });
98
+ return tree
99
+ .filter((f) => f.type === "file" && f.path.endsWith(".gguf"))
100
+ .map((f) => ({
101
+ path: f.path,
102
+ sizeBytes: f.lfs?.size ?? f.size ?? 0,
103
+ }))
104
+ .sort((a, b) => a.sizeBytes - b.sizeBytes);
105
+ }
106
+
107
+ /** Fetch model metadata from the HF API. */
108
+ export async function getHfModelInfo(repo, { fetchImpl = globalThis.fetch } = {}) {
109
+ const url = `https://huggingface.co/api/models/${repo}`;
110
+ const response = await fetchImpl(url, { signal: AbortSignal.timeout(10000) });
111
+ if (!response.ok) throw new Error(`HuggingFace API error: HTTP ${response.status} for ${repo}`);
112
+ return await response.json();
113
+ }
114
+
115
+ /** Check if a repo is MLX-formatted based on its HF metadata. */
116
+ export function isMlxRepo(modelInfo) {
117
+ if (modelInfo.library_name === "mlx") return true;
118
+ if (Array.isArray(modelInfo.tags) && modelInfo.tags.includes("mlx")) return true;
119
+ return false;
120
+ }
121
+
99
122
  /** Resolve a user-provided HF reference into a download plan. */
100
123
  export async function resolveHfDownload(input, { fetchImpl = globalThis.fetch } = {}) {
101
124
  const { repo, filename } = parseHfRef(input);
@@ -136,74 +159,51 @@ export async function resolveHfDownload(input, { fetchImpl = globalThis.fetch }
136
159
  }
137
160
 
138
161
  /**
139
- * Download a resolved model into the HF hub cache.
162
+ * Download a resolved model using the `hf` CLI.
163
+ * GGUF: downloads single file to HF cache (offgrid-ai scanner finds it there).
164
+ * MLX: downloads full repo to a local directory (oMLX scans ~/.omlx/models).
165
+ * Progress bars are handled natively by the CLI (stdio inherited).
140
166
  * @param {object} model - from resolveHfDownload
141
- * @param {object} options
142
- * @param {function} options.onProgress - ({ downloadedBytes, totalBytes, percentage, file }) => void
167
+ * @param {object} [options]
168
+ * @param {string} [options.localDir] - for MLX: target directory
143
169
  * @returns {Promise<{ localDir: string, format: string }>}
144
170
  */
145
- export async function downloadToHfCache(model, options = {}) {
146
- await mkdir(HF_HUB_DIR, { recursive: true });
171
+ export async function downloadModel(model, options = {}) {
172
+ const args = ["download", model.repo];
173
+ let localDir;
147
174
 
148
- const script = HF_DOWNLOAD_SCRIPT;
149
- const args = ["--repo", model.repo, "--cache-dir", HF_HUB_DIR];
150
175
  if (model.format === "gguf") {
151
- args.push("--file", model.files[0].filename);
176
+ // Single file to HF cache — scanner finds it there
177
+ await mkdir(HF_HUB_DIR, { recursive: true });
178
+ args.push(model.files[0].filename, "--cache-dir", HF_HUB_DIR);
179
+ localDir = HF_HUB_DIR;
180
+ } else if (options.localDir) {
181
+ // Full repo to a flat local directory (oMLX)
182
+ await mkdir(options.localDir, { recursive: true });
183
+ args.push(
184
+ "--local-dir", options.localDir,
185
+ "--exclude", "*.md",
186
+ "--exclude", ".gitattributes",
187
+ "--exclude", "LICENSE",
188
+ "--exclude", ".gitignore",
189
+ );
190
+ localDir = options.localDir;
191
+ } else {
192
+ // Fallback: full repo to HF cache
193
+ await mkdir(HF_HUB_DIR, { recursive: true });
194
+ args.push("--cache-dir", HF_HUB_DIR);
195
+ localDir = HF_HUB_DIR;
152
196
  }
153
197
 
154
- const onProgress = options.onProgress ?? (() => {});
155
-
156
- return new Promise((resolve, reject) => {
157
- const child = execFile("python3", [script, ...args], { env: process.env });
158
-
159
- let stdoutBuf = "";
160
- let downloadedBytes = 0;
161
- let currentFile = null;
162
-
163
- // huggingface_hub streams NDJSON progress events to stdout, one per line.
164
- // Buffer and split on complete newlines so an event split across chunk
165
- // boundaries isn't silently dropped.
166
- const handleLine = (line) => {
167
- if (!line) return;
168
- try {
169
- const event = JSON.parse(line);
170
- if (event.type === "progress") {
171
- downloadedBytes = event.downloadedBytes ?? downloadedBytes;
172
- currentFile = event.file ?? currentFile;
173
- onProgress({
174
- downloadedBytes,
175
- totalBytes: model.totalSizeBytes,
176
- percentage: Math.min(100, Math.round((downloadedBytes / model.totalSizeBytes) * 100)),
177
- file: currentFile,
178
- });
179
- } else if (event.type === "complete") {
180
- resolve({ localDir: event.localDir, format: model.format });
181
- } else if (event.type === "error") {
182
- reject(new Error(event.message));
183
- }
184
- } catch {
185
- // Ignore non-JSON output (progress bars, etc.)
186
- }
187
- };
188
-
189
- child.stdout?.on("data", (chunk) => {
190
- stdoutBuf += String(chunk);
191
- let nl;
192
- while ((nl = stdoutBuf.indexOf("\n")) !== -1) {
193
- handleLine(stdoutBuf.slice(0, nl));
194
- stdoutBuf = stdoutBuf.slice(nl + 1);
195
- }
196
- });
197
-
198
- child.stderr?.on("data", () => {
199
- // huggingface_hub prints progress bars to stderr; ignore.
200
- });
201
-
198
+ const exitCode = await new Promise((resolve, reject) => {
199
+ const child = spawn("hf", args, { stdio: "inherit", env: process.env });
202
200
  child.on("error", reject);
203
- child.on("exit", (code) => {
204
- // Flush any final line that lacked a trailing newline.
205
- if (stdoutBuf.trim()) handleLine(stdoutBuf.trim());
206
- if (code !== 0) reject(new Error(`Download failed with exit code ${code}`));
207
- });
201
+ child.on("exit", resolve);
208
202
  });
203
+
204
+ if (exitCode !== 0) {
205
+ throw new Error(`hf download exited with code ${exitCode}`);
206
+ }
207
+
208
+ return { localDir, format: model.format };
209
209
  }
package/src/managed.mjs CHANGED
@@ -1,8 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { BACKENDS } from "./backends.mjs";
3
- import { hasOmlx } from "./omlx-runtime.mjs";
4
3
 
5
- export const MANAGED_BACKEND_IDS = ["omlx"];
4
+ const MANAGED_BACKEND_IDS = ["omlx"];
6
5
 
7
6
  export async function scanManagedModels() {
8
7
  const results = [];
@@ -21,7 +20,3 @@ export async function scanManagedModels() {
21
20
  export function hasLmStudioInstalled() {
22
21
  return existsSync("/Applications/LM Studio.app");
23
22
  }
24
-
25
- export async function hasOmlxInstalled() {
26
- return await hasOmlx();
27
- }
@@ -4,9 +4,9 @@
4
4
  // No other function should format, title-case, or dissect a model name.
5
5
  //
6
6
  // The returned `id` is always the raw identifier (untouched) and is used for
7
- // API calls, profile IDs, Pi config matching, and benchmark directory slugs.
7
+ // API calls, profile IDs, and Pi config matching.
8
8
  // The returned `display` is the human-readable string shown in pickers, details,
9
- // and benchmark metadata.
9
+
10
10
 
11
11
  // ── Known model families ────────────────────────────────────────────────
12
12
  //