offgrid-ai 0.9.6 → 0.10.1
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 +6 -6
- package/package.json +4 -3
- package/resources/hf-download.py +79 -0
- package/resources/mlxvlm-server-wrapper.py +112 -0
- package/resources/recommendations.json +60 -0
- package/src/backend-installers.mjs +1 -16
- package/src/backends.mjs +18 -45
- package/src/benchmark/finalize.mjs +3 -90
- package/src/benchmark/flow.mjs +3 -4
- package/src/benchmark/metrics.mjs +0 -44
- package/src/benchmark/prepare.mjs +1 -1
- package/src/benchmark.mjs +3 -1
- package/src/commands/main.mjs +7 -7
- package/src/commands/models.mjs +21 -18
- package/src/commands/onboard.mjs +67 -9
- package/src/commands/run.mjs +20 -5
- package/src/commands/status.mjs +1 -1
- package/src/config.mjs +11 -2
- package/src/discovery-shared.mjs +44 -0
- package/src/hardware.mjs +49 -0
- package/src/harness-pi.mjs +25 -11
- package/src/huggingface.mjs +209 -0
- package/src/managed.mjs +1 -5
- package/src/mlx-discovery.mjs +294 -0
- package/src/mlx-flags.mjs +93 -0
- package/src/model-catalog.mjs +78 -11
- package/src/model-name.mjs +7 -25
- package/src/model-presenters.mjs +114 -38
- package/src/process.mjs +129 -32
- package/src/profile-setup.mjs +105 -0
- package/src/profiles.mjs +30 -0
- package/src/recommendations.mjs +56 -14
- package/src/scan.mjs +43 -8
package/src/benchmark.mjs
CHANGED
|
@@ -6,5 +6,7 @@ export { findBenchmarkRepo, linkBenchmarkRepo } from "./benchmark/repo.mjs";
|
|
|
6
6
|
export { prepareBenchmarkRun } from "./benchmark/prepare.mjs";
|
|
7
7
|
export { runBenchmarkInPi } from "./benchmark/pi-runner.mjs";
|
|
8
8
|
export { queryServerMetrics } from "./benchmark/metrics.mjs";
|
|
9
|
-
|
|
9
|
+
// unloadModelFromServer now lives in src/process.mjs (managed-server counterpart to stopProfile).
|
|
10
|
+
export { unloadModelFromServer } from "./process.mjs";
|
|
11
|
+
export { finalizeBenchmarkRun, renderBenchmarkSummary } from "./benchmark/finalize.mjs";
|
|
10
12
|
export { benchmarkForProfile, benchmarkFlow } from "./benchmark/flow.mjs";
|
package/src/commands/main.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { findLlamaServer, ensureDirs } from "../config.mjs";
|
|
2
2
|
import { backendFor } from "../backends.mjs";
|
|
3
3
|
import { scanGgufModels } from "../scan.mjs";
|
|
4
|
+
import { scanMlxModels } from "../mlx-discovery.mjs";
|
|
4
5
|
import { loadProfiles } from "../profiles.mjs";
|
|
5
6
|
import { hasPi } from "../harness-pi.mjs";
|
|
6
7
|
import { offerManagedLlamaRuntimeUpdate } from "../runtime.mjs";
|
|
7
|
-
import { hasLmStudioInstalled,
|
|
8
|
+
import { hasLmStudioInstalled, hasOmlxInstalled, scanManagedModels } from "../managed.mjs";
|
|
8
9
|
import { recommendedModel } from "../recommendations.mjs";
|
|
9
10
|
import { pc, startInteractive, createPrompt } from "../ui.mjs";
|
|
10
11
|
import { onboardFlow } from "./onboard.mjs";
|
|
@@ -26,9 +27,10 @@ export async function mainFlow() {
|
|
|
26
27
|
const llamaBinary = await findLlamaServer();
|
|
27
28
|
const { models: ggufModels, drafters } = await scanGgufModels();
|
|
28
29
|
const managedModels = await scanManagedModels();
|
|
30
|
+
const mlxModels = await scanMlxModels();
|
|
29
31
|
const profiles = await loadProfiles();
|
|
30
32
|
const hasAnyBackend = llamaBinary || managedModels.some((item) => item.status === "ok" && item.models.length > 0);
|
|
31
|
-
const hasAnyModels = ggufModels.length > 0 || managedModels.some((item) => item.status === "ok" && item.models.length > 0);
|
|
33
|
+
const hasAnyModels = ggufModels.length > 0 || mlxModels.length > 0 || managedModels.some((item) => item.status === "ok" && item.models.length > 0);
|
|
32
34
|
|
|
33
35
|
const piInstalled = await hasPi();
|
|
34
36
|
const needsLlama = ggufModels.length > 0 || profiles.some((profile) => backendFor(profile.backend).type === "local-server");
|
|
@@ -56,16 +58,16 @@ export async function mainFlow() {
|
|
|
56
58
|
if (!process.stdin.isTTY) return await statusCommand();
|
|
57
59
|
|
|
58
60
|
startInteractive("offgrid-ai");
|
|
59
|
-
return await modelCommandCenter({ profiles, ggufModels, managedModels, drafters });
|
|
61
|
+
return await modelCommandCenter({ profiles, ggufModels, managedModels, mlxModels, drafters });
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
async function printNoModelsHelp(llamaBinary) {
|
|
63
65
|
console.log(pc.yellow("No models found."));
|
|
64
66
|
console.log(pc.dim("You need to download a model to use offgrid-ai.\n"));
|
|
65
67
|
|
|
66
|
-
const
|
|
68
|
+
const omlxInstalled = await hasOmlxInstalled();
|
|
67
69
|
const lmStudioInstalled = hasLmStudioInstalled();
|
|
68
|
-
const hasBackends = llamaBinary ||
|
|
70
|
+
const hasBackends = llamaBinary || omlxInstalled || lmStudioInstalled;
|
|
69
71
|
if (!hasBackends) {
|
|
70
72
|
console.log(pc.dim("Run offgrid-ai to install a backend and download a model."));
|
|
71
73
|
return;
|
|
@@ -73,7 +75,6 @@ async function printNoModelsHelp(llamaBinary) {
|
|
|
73
75
|
|
|
74
76
|
console.log(pc.bold("Backend status:"));
|
|
75
77
|
console.log(` ${lmStudioInstalled ? pc.green("✓") : pc.red("✗")} LM Studio ${lmStudioInstalled ? "— installed" : "— not installed"}`);
|
|
76
|
-
console.log(` ${ollamaInstalled ? pc.green("✓") : pc.red("✗")} Ollama ${ollamaInstalled ? "— installed" : "— not installed"}`);
|
|
77
78
|
console.log(` ${omlxInstalled ? pc.green("✓") : pc.red("✗")} oMLX ${omlxInstalled ? "— installed" : "— not installed"}`);
|
|
78
79
|
console.log(` ${llamaBinary ? pc.green("✓") : pc.red("✗")} llama-server ${llamaBinary ? "— installed" : "— not installed"}`);
|
|
79
80
|
console.log();
|
|
@@ -84,6 +85,5 @@ async function printNoModelsHelp(llamaBinary) {
|
|
|
84
85
|
console.log(" Open LM Studio → browse models → download");
|
|
85
86
|
console.log(pc.dim(` Recommended: ${model.label}`));
|
|
86
87
|
}
|
|
87
|
-
if (ollamaInstalled) console.log(pc.bold(` ollama pull ${model.ollama}`));
|
|
88
88
|
if (omlxInstalled) console.log(pc.bold(" omlx start"));
|
|
89
89
|
}
|
package/src/commands/models.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { syncPiConfig, removeFromPiConfig } from "../harness-pi.mjs";
|
|
|
6
6
|
import { configureLocalProfile } from "../profile-setup.mjs";
|
|
7
7
|
import { pc, startInteractive, createPrompt } from "../ui.mjs";
|
|
8
8
|
import { buildCatalogItems, createManagedProfile, itemKey, loadModelCatalog, normalizeCatalog } from "../model-catalog.mjs";
|
|
9
|
-
import { modelSelectOption, modelNameWidth, printGgufModelDetails, printManagedModelDetails, printWorkspaceHeader, printBenchmarkLine, printProfileDetails } from "../model-presenters.mjs";
|
|
9
|
+
import { modelSelectOption, modelNameWidth, printGgufModelDetails, printMlxModelDetails, printManagedModelDetails, printWorkspaceHeader, printBenchmarkLine, printProfileDetails } from "../model-presenters.mjs";
|
|
10
10
|
import { runProfile } from "./run.mjs";
|
|
11
11
|
|
|
12
12
|
const { stripVTControlCharacters } = await import("node:util");
|
|
@@ -40,7 +40,6 @@ export async function modelCommandCenter(initialCatalog) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const runningProfilesNow = [];
|
|
43
|
-
const serverUpIds = new Set();
|
|
44
43
|
const modelMissingIds = new Set();
|
|
45
44
|
for (const profile of normalized.profiles) {
|
|
46
45
|
if (await isProfileRunning(profile)) {
|
|
@@ -48,11 +47,10 @@ export async function modelCommandCenter(initialCatalog) {
|
|
|
48
47
|
continue;
|
|
49
48
|
}
|
|
50
49
|
if (backendFor(profile.backend).type === "managed-server" && await isProfileServerUp(profile)) {
|
|
51
|
-
if (await modelAvailableOnServer(profile))
|
|
52
|
-
else modelMissingIds.add(profile.id);
|
|
50
|
+
if (!(await modelAvailableOnServer(profile))) modelMissingIds.add(profile.id);
|
|
53
51
|
}
|
|
54
52
|
}
|
|
55
|
-
printWorkspaceHeader(normalized, runningProfilesNow,
|
|
53
|
+
printWorkspaceHeader(normalized, runningProfilesNow, modelMissingIds);
|
|
56
54
|
await printBenchmarkLine();
|
|
57
55
|
|
|
58
56
|
const nameWidth = modelNameWidth(allItems);
|
|
@@ -62,13 +60,12 @@ export async function modelCommandCenter(initialCatalog) {
|
|
|
62
60
|
if (item.fileMissing) return "missing";
|
|
63
61
|
if (runningProfilesNow.some((profile) => profile.id === item.profile.id)) return "running";
|
|
64
62
|
if (modelMissingIds.has(item.profile.id)) return "missing";
|
|
65
|
-
if (serverUpIds.has(item.profile.id)) return "serverup";
|
|
66
63
|
return "ready";
|
|
67
64
|
}
|
|
68
65
|
return "setup";
|
|
69
66
|
};
|
|
70
67
|
|
|
71
|
-
const groupOrder = ["running", "
|
|
68
|
+
const groupOrder = ["running", "ready", "setup", "missing"];
|
|
72
69
|
const grouped = new Map(groupOrder.map((key) => [key, []]));
|
|
73
70
|
for (const item of allItems) grouped.get(statusFor(item)).push(item);
|
|
74
71
|
|
|
@@ -77,7 +74,7 @@ export async function modelCommandCenter(initialCatalog) {
|
|
|
77
74
|
const bucket = grouped.get(group);
|
|
78
75
|
if (!bucket || bucket.length === 0) continue;
|
|
79
76
|
for (const item of bucket) {
|
|
80
|
-
const opt = modelSelectOption(item, { runningProfilesNow,
|
|
77
|
+
const opt = modelSelectOption(item, { runningProfilesNow, modelMissingIds, nameWidth });
|
|
81
78
|
choices.push({ value: opt.value, label: opt.label, hint: opt.hint });
|
|
82
79
|
}
|
|
83
80
|
}
|
|
@@ -159,6 +156,7 @@ async function performAction(prompt, action, item) {
|
|
|
159
156
|
if (action === "inspect") {
|
|
160
157
|
if (item.type === "profile") return await printProfileDetails(await readProfile(item.profile.id));
|
|
161
158
|
if (item.type === "managed") return printManagedModelDetails(item.model, BACKENDS[item.backendId]);
|
|
159
|
+
if (item.model?.format === "mlx") return await printMlxModelDetails(item.model);
|
|
162
160
|
return printGgufModelDetails(item.model, item.drafter);
|
|
163
161
|
}
|
|
164
162
|
if (action === "benchmark") {
|
|
@@ -169,20 +167,13 @@ async function performAction(prompt, action, item) {
|
|
|
169
167
|
const { benchmarkFlow } = await import("../benchmark.mjs");
|
|
170
168
|
return await benchmarkFlow();
|
|
171
169
|
}
|
|
172
|
-
if (action === "run") return await runItem(
|
|
170
|
+
if (action === "run") return await runItem(item);
|
|
173
171
|
if (action === "reconfigure" || action === "setup") return await setupItem(prompt, item, action);
|
|
174
172
|
if (action === "remove" && item.type === "profile") return await removeProfileInteractive(item.profile.id);
|
|
175
173
|
}
|
|
176
174
|
|
|
177
|
-
async function runItem(
|
|
178
|
-
|
|
179
|
-
const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
|
|
180
|
-
const configured = await configureLocalProfile(prompt, profile);
|
|
181
|
-
if (!configured) return;
|
|
182
|
-
await saveProfile(configured);
|
|
183
|
-
await syncPiConfig(configured);
|
|
184
|
-
printProfileSaved(configured.id);
|
|
185
|
-
return await runProfile(configured);
|
|
175
|
+
async function runItem(item) {
|
|
176
|
+
return await runProfile(await readProfile(item.profile.id));
|
|
186
177
|
}
|
|
187
178
|
|
|
188
179
|
function printProfileSaved(id) {
|
|
@@ -205,6 +196,18 @@ async function setupItem(prompt, item, action) {
|
|
|
205
196
|
printProfileSaved(profile.id);
|
|
206
197
|
return;
|
|
207
198
|
}
|
|
199
|
+
// MLX models: build a mlx-vlm profile and run interactive config.
|
|
200
|
+
if (item.model.format === "mlx") {
|
|
201
|
+
const { createProfileFromMlxModel } = await import("../profiles.mjs");
|
|
202
|
+
const { configureMlxProfile } = await import("../profile-setup.mjs");
|
|
203
|
+
const profile = await createProfileFromMlxModel(item.model);
|
|
204
|
+
const configured = await configureMlxProfile(prompt, profile);
|
|
205
|
+
if (!configured) return;
|
|
206
|
+
await saveProfile(configured, { writeCommand: true });
|
|
207
|
+
await syncPiConfig(configured);
|
|
208
|
+
printProfileSaved(configured.id);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
208
211
|
const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
|
|
209
212
|
const configured = await configureLocalProfile(prompt, profile);
|
|
210
213
|
if (!configured) return;
|
package/src/commands/onboard.mjs
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { ensureDirs, findLlamaServer, hasHomebrew } from "../config.mjs";
|
|
2
|
+
import { ensureDirs, findLlamaServer, hasHomebrew, HF_HUB_DIR } from "../config.mjs";
|
|
3
3
|
import { BACKENDS } from "../backends.mjs";
|
|
4
4
|
import { scanGgufModels } from "../scan.mjs";
|
|
5
|
+
import { scanMlxModels } from "../mlx-discovery.mjs";
|
|
5
6
|
import { hasPi } from "../harness-pi.mjs";
|
|
6
7
|
import { offerManagedLlamaRuntimeUpdate } from "../runtime.mjs";
|
|
7
8
|
import { scanManagedModels } from "../managed.mjs";
|
|
8
9
|
import { BACKEND_INSTALL_CHOICES, BACKEND_INSTALLERS } from "../backend-installers.mjs";
|
|
9
|
-
import {
|
|
10
|
+
import { recommendedModel, selectFormat, allFittingModels } from "../recommendations.mjs";
|
|
11
|
+
import { hasHuggingfaceHub, resolveHfDownload, downloadToHfCache } from "../huggingface.mjs";
|
|
12
|
+
import { detectHardware, getFreeDiskBytes, installedRamGB } from "../hardware.mjs";
|
|
10
13
|
import { runCommand } from "../exec.mjs";
|
|
11
|
-
import { pc, renderRows, renderSection, startInteractive, createPrompt } from "../ui.mjs";
|
|
14
|
+
import { pc, formatBytes, renderRows, renderSection, startInteractive, createPrompt } from "../ui.mjs";
|
|
12
15
|
|
|
13
16
|
export async function onboardFlow() {
|
|
14
17
|
await ensureDirs();
|
|
@@ -24,14 +27,22 @@ export async function onboardFlow() {
|
|
|
24
27
|
const llamaBinary = await ensureLlamaRuntime(prompt);
|
|
25
28
|
if (!(await ensurePi(prompt, run))) return;
|
|
26
29
|
|
|
27
|
-
const { models: ggufModels } = await
|
|
28
|
-
|
|
30
|
+
const [{ models: ggufModels }, managedModels, mlxModels] = await Promise.all([
|
|
31
|
+
scanGgufModels(),
|
|
32
|
+
scanManagedModels(),
|
|
33
|
+
scanMlxModels(),
|
|
34
|
+
]);
|
|
29
35
|
const totalManaged = managedModels.reduce((sum, item) => sum + item.models.length, 0);
|
|
30
|
-
const hasModels = ggufModels.length > 0 || totalManaged > 0;
|
|
36
|
+
const hasModels = ggufModels.length > 0 || totalManaged > 0 || mlxModels.length > 0;
|
|
31
37
|
|
|
32
38
|
if (hasModels) {
|
|
33
|
-
printFoundModels(ggufModels, managedModels, llamaBinary);
|
|
39
|
+
printFoundModels(ggufModels, managedModels, mlxModels, llamaBinary);
|
|
34
40
|
} else {
|
|
41
|
+
const canDownload = await hasHuggingfaceHub();
|
|
42
|
+
if (canDownload) {
|
|
43
|
+
const downloaded = await offerModelDownload(prompt);
|
|
44
|
+
if (downloaded) return;
|
|
45
|
+
}
|
|
35
46
|
await offerBackendInstall(prompt, run);
|
|
36
47
|
return;
|
|
37
48
|
}
|
|
@@ -52,7 +63,7 @@ async function ensureLlamaRuntime(prompt) {
|
|
|
52
63
|
]), { formatBorder: pc.cyan }));
|
|
53
64
|
await offerManagedLlamaRuntimeUpdate(prompt);
|
|
54
65
|
llamaBinary = await findLlamaServer();
|
|
55
|
-
if (!llamaBinary) console.log(pc.yellow("Skipping llama.cpp for now. You can still use
|
|
66
|
+
if (!llamaBinary) console.log(pc.yellow("Skipping llama.cpp for now. You can still use oMLX, or run offgrid-ai again to install the managed runtime."));
|
|
56
67
|
}
|
|
57
68
|
if (llamaBinary) console.log(pc.green(`✓ llama-server: ${llamaBinary}`));
|
|
58
69
|
return llamaBinary;
|
|
@@ -85,11 +96,14 @@ async function ensurePi(prompt, run) {
|
|
|
85
96
|
return true;
|
|
86
97
|
}
|
|
87
98
|
|
|
88
|
-
function printFoundModels(ggufModels, managedModels, llamaBinary) {
|
|
99
|
+
function printFoundModels(ggufModels, managedModels, mlxModels, llamaBinary) {
|
|
89
100
|
if (ggufModels.length > 0) {
|
|
90
101
|
console.log(pc.green(`✓ Found ${ggufModels.length} GGUF model${ggufModels.length === 1 ? "" : "s"}`));
|
|
91
102
|
if (!llamaBinary) console.log(pc.yellow("Install the managed llama.cpp runtime to run these GGUF models."));
|
|
92
103
|
}
|
|
104
|
+
if (mlxModels.length > 0) {
|
|
105
|
+
console.log(pc.green(`✓ Found ${mlxModels.length} MLX model${mlxModels.length === 1 ? "" : "s"}`));
|
|
106
|
+
}
|
|
93
107
|
for (const { backendId, models, status, reason } of managedModels) {
|
|
94
108
|
if (status === "unavailable") {
|
|
95
109
|
console.log(pc.yellow(`${BACKENDS[backendId].label}: unavailable${reason ? ` — ${reason}` : ""}`));
|
|
@@ -99,6 +113,50 @@ function printFoundModels(ggufModels, managedModels, llamaBinary) {
|
|
|
99
113
|
}
|
|
100
114
|
}
|
|
101
115
|
|
|
116
|
+
async function offerModelDownload(prompt) {
|
|
117
|
+
const hardware = detectHardware();
|
|
118
|
+
const candidates = allFittingModels(hardware)
|
|
119
|
+
.map((entry) => ({ entry, format: selectFormat(entry, hardware) }))
|
|
120
|
+
.filter((item) => item.format != null);
|
|
121
|
+
if (candidates.length === 0) {
|
|
122
|
+
console.log(pc.yellow("No curated models fit your hardware."));
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const primary = candidates[0];
|
|
127
|
+
console.log(renderSection("Download a recommended model", renderRows([
|
|
128
|
+
["Model", pc.bold(primary.entry.label)],
|
|
129
|
+
["Format", primary.format],
|
|
130
|
+
["Minimum RAM", String(primary.entry.minRamGb) + " GB"],
|
|
131
|
+
["Your RAM", installedRamGB() + " GB"],
|
|
132
|
+
]), { formatBorder: pc.cyan }));
|
|
133
|
+
|
|
134
|
+
const shouldDownload = await prompt.yesNo("Download " + primary.entry.label + " (" + primary.format + ")?", true);
|
|
135
|
+
if (!shouldDownload) return false;
|
|
136
|
+
|
|
137
|
+
const hfRef = primary.format === "mlx" ? primary.entry.mlx : primary.entry.gguf;
|
|
138
|
+
try {
|
|
139
|
+
const plan = await resolveHfDownload(hfRef);
|
|
140
|
+
console.log(pc.dim("Total size: " + formatBytes(plan.totalSizeBytes)));
|
|
141
|
+
const freeBytes = getFreeDiskBytes(HF_HUB_DIR);
|
|
142
|
+
if (plan.totalSizeBytes > 0 && freeBytes < plan.totalSizeBytes * 1.1) {
|
|
143
|
+
console.log(pc.red(`Not enough disk space in ${HF_HUB_DIR}: need ~${formatBytes(plan.totalSizeBytes)}, only ${formatBytes(freeBytes)} free.`));
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
await downloadToHfCache(plan, {
|
|
147
|
+
onProgress({ percentage }) {
|
|
148
|
+
process.stdout.write(pc.cyan("\r " + percentage + "% downloaded"));
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
process.stdout.write("\n");
|
|
152
|
+
console.log(pc.green("✓ Download complete. Run offgrid-ai to use the model."));
|
|
153
|
+
return true;
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.log(pc.red("Download failed: " + err.message));
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
102
160
|
async function offerBackendInstall(prompt, run) {
|
|
103
161
|
console.log(pc.yellow("\nNo models found."));
|
|
104
162
|
console.log(pc.dim("You need at least one model backend to use offgrid-ai.\n"));
|
package/src/commands/run.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { ensureDirs } from "../config.mjs";
|
|
3
3
|
import { backendFor } from "../backends.mjs";
|
|
4
4
|
import { normalizeProfile, readProfile, saveProfile } from "../profiles.mjs";
|
|
5
|
-
import { startServer, stopProfile, waitForReady, serverReady, serverMatchesProfile, modelAvailableOnServer } from "../process.mjs";
|
|
5
|
+
import { startServer, stopProfile, waitForReady, serverReady, serverMatchesProfile, modelAvailableOnServer, unloadModelFromServer } from "../process.mjs";
|
|
6
6
|
import { syncPiConfig, hasPiModel, launchPi, hasPi } from "../harness-pi.mjs";
|
|
7
7
|
import { tailFriendly } from "../logs.mjs";
|
|
8
8
|
import { estimateMemory } from "../estimate.mjs";
|
|
@@ -35,7 +35,7 @@ export async function runProfile(profile, options = {}) {
|
|
|
35
35
|
}
|
|
36
36
|
const available = await modelAvailableOnServer(profile);
|
|
37
37
|
if (!available) {
|
|
38
|
-
const modelId = profile.omlxModel ?? profile.
|
|
38
|
+
const modelId = profile.omlxModel ?? profile.modelAlias ?? profile.label;
|
|
39
39
|
throw new Error(`${modelId} is not available on ${backend.label} at ${profile.baseUrl}.`);
|
|
40
40
|
}
|
|
41
41
|
console.log(pc.green(`[ready] ${backend.label} at ${profile.baseUrl}`));
|
|
@@ -116,9 +116,24 @@ async function launchHarness(profile, options, isManaged, withHarness, backend)
|
|
|
116
116
|
try {
|
|
117
117
|
await launchPi(profile);
|
|
118
118
|
} finally {
|
|
119
|
-
if (!
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
if (!options["keep-server"]) {
|
|
120
|
+
if (!isManaged) {
|
|
121
|
+
const result = await stopProfile(profile);
|
|
122
|
+
console.log(result.stopped ? pc.green(`[stop] ${result.message}`) : pc.dim(`[stop] ${result.message}`));
|
|
123
|
+
} 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
|
+
const result = await unloadModelFromServer(profile);
|
|
129
|
+
if (result.unloaded) {
|
|
130
|
+
console.log(pc.green(`[unload] ${backend.label}: model unloaded`));
|
|
131
|
+
} else if (result.reason) {
|
|
132
|
+
console.log(pc.dim(`[unload] ${backend.label}: ${result.reason}`));
|
|
133
|
+
} else if (result.error) {
|
|
134
|
+
console.log(pc.yellow(`[unload] ${backend.label}: ${result.error}`));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
122
137
|
}
|
|
123
138
|
}
|
|
124
139
|
}
|
package/src/commands/status.mjs
CHANGED
|
@@ -42,7 +42,7 @@ export async function statusCommand() {
|
|
|
42
42
|
const detailRows = [];
|
|
43
43
|
for (const { profile, status } of [...managedUpMissing, ...managedUpNotLoaded]) {
|
|
44
44
|
const backend = backendFor(profile.backend);
|
|
45
|
-
const modelId = profile.omlxModel ?? profile.
|
|
45
|
+
const modelId = profile.omlxModel ?? profile.modelAlias ?? profile.id;
|
|
46
46
|
const state = status.modelAvailable
|
|
47
47
|
? pc.yellow("server up · model not loaded")
|
|
48
48
|
: pc.red("server up · model missing");
|
package/src/config.mjs
CHANGED
|
@@ -15,9 +15,17 @@ export const MANAGED_LLAMA_SERVER = join(RUNTIME_DIR, "bin", "llama-server");
|
|
|
15
15
|
|
|
16
16
|
// ── Default scan directories ──────────────────────────────────────────────
|
|
17
17
|
|
|
18
|
+
// HuggingFace hub cache: $HF_HUB_CACHE, else $HF_HOME/hub, else
|
|
19
|
+
// ~/.cache/huggingface/hub. This is where huggingface_hub stores
|
|
20
|
+
// models--org--name/... and where offgrid-ai scans + downloads. Pointing at the
|
|
21
|
+
// hub (not the HF root) keeps the HF-hub MLX/GGUF scanners and the downloader
|
|
22
|
+
// on the same layout.
|
|
23
|
+
export const HF_HUB_DIR = process.env.HF_HUB_CACHE
|
|
24
|
+
|| (process.env.HF_HOME ? join(process.env.HF_HOME, "hub") : join(homedir(), ".cache", "huggingface", "hub"));
|
|
25
|
+
|
|
18
26
|
export const DEFAULT_MODEL_DIRS = [
|
|
19
27
|
join(homedir(), ".lmstudio", "models"),
|
|
20
|
-
|
|
28
|
+
HF_HUB_DIR,
|
|
21
29
|
];
|
|
22
30
|
|
|
23
31
|
// ── External config paths ─────────────────────────────────────────────────
|
|
@@ -65,7 +73,8 @@ export async function saveConfig(config) {
|
|
|
65
73
|
|
|
66
74
|
export async function getModelScanDirs() {
|
|
67
75
|
const config = await loadConfig();
|
|
68
|
-
|
|
76
|
+
// Dedupe (a user may list a default dir explicitly) so we never scan twice.
|
|
77
|
+
return [...DEFAULT_MODEL_DIRS, ...config.modelScanDirs].filter((dir, i, arr) => arr.indexOf(dir) === i);
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
// ── Binary discovery ──────────────────────────────────────────────────────
|
|
@@ -0,0 +1,44 @@
|
|
|
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.
|
|
4
|
+
|
|
5
|
+
import { basename, dirname } from "node:path";
|
|
6
|
+
|
|
7
|
+
/** Minimum on-disk size for a model to count as real (skips tiny test/embedding files). */
|
|
8
|
+
export const MIN_MODEL_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Model-type / architecture names that indicate an embedding model. Shared by
|
|
12
|
+
* GGUF filtering (general.architecture) and MLX filtering (config.model_type /
|
|
13
|
+
* architectures[0]). Format-specific heuristics (e.g. GGUF filename patterns)
|
|
14
|
+
* live alongside this set in each scanner.
|
|
15
|
+
*/
|
|
16
|
+
export const EMBEDDING_MODEL_TYPES = new Set([
|
|
17
|
+
"bert",
|
|
18
|
+
"roberta",
|
|
19
|
+
"mpnet",
|
|
20
|
+
"nomic_bert",
|
|
21
|
+
"nomic-bert",
|
|
22
|
+
"jina",
|
|
23
|
+
"e5",
|
|
24
|
+
"gte",
|
|
25
|
+
"bge",
|
|
26
|
+
"all_minilm",
|
|
27
|
+
"all-minilm",
|
|
28
|
+
"sentence_transformers",
|
|
29
|
+
"sentence-transformers",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Infer a human-readable source label from a model scan path.
|
|
34
|
+
* Generic container folders (models, hub, cache) defer to their parent name
|
|
35
|
+
* (e.g. ~/.cache/huggingface/hub -> "huggingface"; ~/.lmstudio/models -> "lmstudio").
|
|
36
|
+
*/
|
|
37
|
+
export function inferSourceLabel(scanPath) {
|
|
38
|
+
const name = basename(scanPath).replace(/^\./, "");
|
|
39
|
+
const parent = basename(dirname(scanPath));
|
|
40
|
+
if (name === "models" || name === "hub" || name === "cache") {
|
|
41
|
+
return parent.replace(/^\./, "");
|
|
42
|
+
}
|
|
43
|
+
return name;
|
|
44
|
+
}
|
package/src/hardware.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Hardware detection — pure functions, no side effects.
|
|
2
|
+
// Ported from deprecated-offgrid-desktop/src/main/hardware.ts.
|
|
3
|
+
//
|
|
4
|
+
// This is the canonical source for hardware facts the model-serving logic
|
|
5
|
+
// needs: recommendations, onboarding, MLX context sizing, disk-space guards,
|
|
6
|
+
// and memory estimation.
|
|
7
|
+
|
|
8
|
+
import { totalmem } from "node:os";
|
|
9
|
+
import { statfsSync, existsSync } from "node:fs";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Detect system hardware via the Node.js `os` module.
|
|
14
|
+
* Pure function — no side effects, trivially testable.
|
|
15
|
+
*/
|
|
16
|
+
export function detectHardware() {
|
|
17
|
+
return {
|
|
18
|
+
totalRamBytes: totalmem(),
|
|
19
|
+
platform: process.platform,
|
|
20
|
+
arch: process.arch,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Installed RAM in GB (integer). */
|
|
25
|
+
export function installedRamGB() {
|
|
26
|
+
return Math.round(totalmem() / (1024 ** 3));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get available disk space for a directory (in bytes).
|
|
31
|
+
*
|
|
32
|
+
* If the directory doesn't exist yet, walks up to the nearest existing parent
|
|
33
|
+
* directory. Returns a very large number if the check fails (so we don't block
|
|
34
|
+
* a download unnecessarily).
|
|
35
|
+
*/
|
|
36
|
+
export function getFreeDiskBytes(dir) {
|
|
37
|
+
try {
|
|
38
|
+
let checkDir = dir;
|
|
39
|
+
while (!existsSync(checkDir)) {
|
|
40
|
+
const parent = dirname(checkDir);
|
|
41
|
+
if (parent === checkDir) break; // reached root
|
|
42
|
+
checkDir = parent;
|
|
43
|
+
}
|
|
44
|
+
const stats = statfsSync(checkDir);
|
|
45
|
+
return stats.bavail * stats.bsize;
|
|
46
|
+
} catch {
|
|
47
|
+
return Number.MAX_SAFE_INTEGER;
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/harness-pi.mjs
CHANGED
|
@@ -5,6 +5,18 @@ import { loadProfiles } from "./profiles.mjs";
|
|
|
5
5
|
import { readJson, writeJson } from "./json.mjs";
|
|
6
6
|
import pc from "picocolors";
|
|
7
7
|
|
|
8
|
+
// ── Pi model id ─────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The model id Pi must send in requests. mlx-vlm registers the loaded model
|
|
12
|
+
* with the full --model path as its API id (verified via /v1/models); sending
|
|
13
|
+
* the repo-id label instead makes mlx-vlm unload the local model and re-fetch
|
|
14
|
+
* the repo from HuggingFace. Other backends use the friendly modelAlias.
|
|
15
|
+
*/
|
|
16
|
+
export function piApiModelId(profile) {
|
|
17
|
+
return profile.backend === "mlx-vlm" ? profile.modelPath : profile.modelAlias;
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
// ── Sync Pi config ─────────────────────────────────────────────────────────
|
|
9
21
|
|
|
10
22
|
export async function syncPiConfig(profile) {
|
|
@@ -30,26 +42,27 @@ export async function removeFromPiConfig(profile) {
|
|
|
30
42
|
const provider = config.providers[profile.providerId];
|
|
31
43
|
if (!provider?.models) return { cleaned: false, reason: `no ${profile.providerId} provider in Pi config` };
|
|
32
44
|
const before = provider.models.length;
|
|
33
|
-
|
|
45
|
+
const apiId = piApiModelId(profile);
|
|
46
|
+
provider.models = provider.models.filter((m) => m.id !== apiId);
|
|
34
47
|
if (provider.models.length === 0) delete config.providers[profile.providerId];
|
|
35
48
|
if (before > provider.models.length) {
|
|
36
49
|
await writeJson(PI_CONFIG, config);
|
|
37
50
|
return { cleaned: true, removed: before - provider.models.length };
|
|
38
51
|
}
|
|
39
|
-
return { cleaned: false, reason: `${
|
|
52
|
+
return { cleaned: false, reason: `${apiId} not in Pi config` };
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
// ── Check if Pi has the model ──────────────────────────────────────────────
|
|
43
56
|
|
|
44
57
|
export async function hasPiModel(profile) {
|
|
45
58
|
const config = await readJson(PI_CONFIG, null);
|
|
46
|
-
return Boolean(config?.providers?.[profile.providerId]?.models?.some?.((m) => m.id === profile
|
|
59
|
+
return Boolean(config?.providers?.[profile.providerId]?.models?.some?.((m) => m.id === piApiModelId(profile)));
|
|
47
60
|
}
|
|
48
61
|
|
|
49
62
|
// ── Launch Pi ──────────────────────────────────────────────────────────────
|
|
50
63
|
|
|
51
64
|
export async function launchPi(profile) {
|
|
52
|
-
const model =
|
|
65
|
+
const model = `${profile.providerId}/${piApiModelId(profile)}`;
|
|
53
66
|
console.log(pc.bold(`[pi] pi --model ${model}`));
|
|
54
67
|
await runForeground("pi", ["--model", model]);
|
|
55
68
|
}
|
|
@@ -88,7 +101,7 @@ function piModelConfig(profile) {
|
|
|
88
101
|
const compat = modelCompat(profile);
|
|
89
102
|
const reasoning = modelReasoning(profile);
|
|
90
103
|
return {
|
|
91
|
-
id: profile
|
|
104
|
+
id: piApiModelId(profile),
|
|
92
105
|
name: profile.label,
|
|
93
106
|
input: modelInput(profile),
|
|
94
107
|
...(reasoning === undefined ? {} : { reasoning }),
|
|
@@ -99,7 +112,9 @@ function piModelConfig(profile) {
|
|
|
99
112
|
}
|
|
100
113
|
|
|
101
114
|
function modelInput(profile) {
|
|
102
|
-
|
|
115
|
+
if (profile.mmprojPath && existsSync(profile.mmprojPath)) return ["text", "image"];
|
|
116
|
+
if (profile.capabilities?.vision) return ["text", "image"];
|
|
117
|
+
return ["text"];
|
|
103
118
|
}
|
|
104
119
|
|
|
105
120
|
function modelCompat(profile) {
|
|
@@ -119,15 +134,14 @@ function modelReasoning(profile) {
|
|
|
119
134
|
}
|
|
120
135
|
|
|
121
136
|
function modelFamily(profile) {
|
|
122
|
-
return [profile.id, profile.label, profile.modelAlias, profile.modelPath, profile.
|
|
137
|
+
return [profile.id, profile.label, profile.modelAlias, profile.modelPath, profile.omlxModel].filter(Boolean).join(" ").toLowerCase();
|
|
123
138
|
}
|
|
124
139
|
|
|
125
|
-
function piApiKey(
|
|
126
|
-
return
|
|
140
|
+
function piApiKey() {
|
|
141
|
+
return "none";
|
|
127
142
|
}
|
|
128
143
|
|
|
129
|
-
function providerCompat(
|
|
130
|
-
if (providerId === "ollama") return { supportsDeveloperRole: true, supportsReasoningEffort: false };
|
|
144
|
+
function providerCompat() {
|
|
131
145
|
return { supportsDeveloperRole: false, supportsReasoningEffort: false };
|
|
132
146
|
}
|
|
133
147
|
|