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.
- package/README.md +0 -2
- package/package.json +3 -11
- package/resources/recommendations.json +8 -8
- package/src/cli.mjs +1 -4
- package/src/commands/main.mjs +20 -1
- package/src/commands/models.mjs +296 -39
- package/src/commands/onboard.mjs +6 -106
- package/src/commands/run.mjs +2 -4
- package/src/commands/status.mjs +1 -0
- package/src/commands/stop.mjs +1 -0
- package/src/config.mjs +16 -1
- package/src/discovery-shared.mjs +2 -3
- package/src/download.mjs +221 -0
- package/src/harness-pi.mjs +2 -3
- package/src/huggingface.mjs +72 -72
- package/src/managed.mjs +1 -6
- package/src/model-name.mjs +2 -2
- package/src/model-presenters.mjs +5 -36
- package/src/model-summary.mjs +2 -2
- package/src/omlx-runtime.mjs +29 -4
- package/src/process.mjs +3 -5
- package/src/profile-setup.mjs +206 -49
- package/src/profiles.mjs +1 -1
- package/src/runtime.mjs +2 -2
- package/src/ui.mjs +10 -8
- package/resources/hf-download.py +0 -79
- package/src/backend-installers.mjs +0 -42
- package/src/benchmark/finalize.mjs +0 -169
- package/src/benchmark/flow.mjs +0 -240
- package/src/benchmark/metrics.mjs +0 -107
- package/src/benchmark/prepare.mjs +0 -118
- package/src/benchmark/repo.mjs +0 -77
- package/src/benchmark/sdk-runner.mjs +0 -363
- package/src/benchmark/shared.mjs +0 -46
- package/src/benchmark.mjs +0 -12
- package/src/commands/benchmark.mjs +0 -4
package/src/commands/onboard.mjs
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
|
-
import { ensureDirs, findLlamaServer
|
|
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 {
|
|
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,
|
|
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
|
|
41
|
-
if (
|
|
42
|
-
|
|
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
|
-
}
|
package/src/commands/run.mjs
CHANGED
|
@@ -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`));
|
package/src/commands/status.mjs
CHANGED
package/src/commands/stop.mjs
CHANGED
|
@@ -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";
|
package/src/discovery-shared.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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
|
|
package/src/download.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/harness-pi.mjs
CHANGED
|
@@ -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 {
|
package/src/huggingface.mjs
CHANGED
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
// HuggingFace model download helpers.
|
|
2
|
-
// Uses the
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
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("
|
|
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
|
|
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 {
|
|
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
|
|
146
|
-
|
|
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
|
-
|
|
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
|
|
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",
|
|
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
|
-
|
|
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
|
-
}
|
package/src/model-name.mjs
CHANGED
|
@@ -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
|
|
7
|
+
// API calls, profile IDs, and Pi config matching.
|
|
8
8
|
// The returned `display` is the human-readable string shown in pickers, details,
|
|
9
|
-
|
|
9
|
+
|
|
10
10
|
|
|
11
11
|
// ── Known model families ────────────────────────────────────────────────
|
|
12
12
|
//
|