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
|
@@ -0,0 +1,209 @@
|
|
|
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.
|
|
5
|
+
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { join, dirname } from "node:path";
|
|
9
|
+
import { mkdir } from "node:fs/promises";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { HF_HUB_DIR } from "./config.mjs";
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
|
|
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() {
|
|
19
|
+
try {
|
|
20
|
+
const { stdout } = await execFileAsync("python3", ["-c", "import huggingface_hub; print(huggingface_hub.__version__)"]);
|
|
21
|
+
return Boolean(stdout.trim());
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Parse a HuggingFace reference (URL, repo/filename, or repo ID). */
|
|
28
|
+
export function parseHfRef(input) {
|
|
29
|
+
const trimmed = input.trim();
|
|
30
|
+
|
|
31
|
+
if (trimmed.startsWith("https://huggingface.co/")) {
|
|
32
|
+
const url = new URL(trimmed);
|
|
33
|
+
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
34
|
+
const resolveIdx = pathParts.indexOf("resolve");
|
|
35
|
+
if (resolveIdx > 0 && pathParts[resolveIdx + 1] === "main") {
|
|
36
|
+
return {
|
|
37
|
+
repo: pathParts.slice(0, resolveIdx).join("/"),
|
|
38
|
+
filename: pathParts.slice(resolveIdx + 2).join("/"),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (pathParts.length >= 2) {
|
|
42
|
+
return {
|
|
43
|
+
repo: pathParts.slice(0, 2).join("/"),
|
|
44
|
+
filename: pathParts.length > 2 ? pathParts.slice(2).join("/") : undefined,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`Invalid HuggingFace URL: ${input}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const parts = trimmed.split("/").filter(Boolean);
|
|
51
|
+
if (parts.length < 2) {
|
|
52
|
+
throw new Error(`Invalid HuggingFace reference: "${input}". Expected at least org/name.`);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
repo: parts.slice(0, 2).join("/"),
|
|
56
|
+
filename: parts.length > 2 ? parts.slice(2).join("/") : undefined,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Resolve file metadata for a GGUF file from the HF tree API. */
|
|
61
|
+
export async function resolveGgufFile(ref, { fetchImpl = globalThis.fetch } = {}) {
|
|
62
|
+
const { repo, filename } = parseHfRef(ref);
|
|
63
|
+
const tree = await getHfTree(repo, { fetchImpl });
|
|
64
|
+
const entry = tree.find((f) => f.path === filename && f.type === "file");
|
|
65
|
+
if (!entry) throw new Error(`File '${filename}' not found in HuggingFace repo '${repo}'.`);
|
|
66
|
+
return {
|
|
67
|
+
repo,
|
|
68
|
+
filename,
|
|
69
|
+
url: `https://huggingface.co/${repo}/resolve/main/${filename}`,
|
|
70
|
+
sizeBytes: entry.lfs?.size ?? entry.size ?? 0,
|
|
71
|
+
sha256: entry.lfs?.oid ?? "",
|
|
72
|
+
relativePath: filename,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Resolve all model files in an MLX repo from the HF tree API. */
|
|
77
|
+
export async function resolveMlxRepo(repo, { fetchImpl = globalThis.fetch } = {}) {
|
|
78
|
+
const tree = await getHfTree(repo, { fetchImpl });
|
|
79
|
+
const modelFiles = tree.filter(
|
|
80
|
+
(f) => f.type === "file" && !f.path.startsWith(".") && f.path !== ".gitattributes" && f.path !== "README.md",
|
|
81
|
+
);
|
|
82
|
+
return modelFiles.map((f) => ({
|
|
83
|
+
repo,
|
|
84
|
+
filename: f.path,
|
|
85
|
+
url: `https://huggingface.co/${repo}/resolve/main/${f.path}`,
|
|
86
|
+
sizeBytes: f.lfs?.size ?? f.size ?? 0,
|
|
87
|
+
sha256: f.lfs?.oid ?? "",
|
|
88
|
+
relativePath: f.path,
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function getHfTree(repo, { branch = "main", fetchImpl = globalThis.fetch } = {}) {
|
|
93
|
+
const url = `https://huggingface.co/api/models/${repo}/tree/${branch}?recursive=true`;
|
|
94
|
+
const response = await fetchImpl(url, { signal: AbortSignal.timeout(10000) });
|
|
95
|
+
if (!response.ok) throw new Error(`HuggingFace API error: HTTP ${response.status} for ${repo}`);
|
|
96
|
+
return await response.json();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Resolve a user-provided HF reference into a download plan. */
|
|
100
|
+
export async function resolveHfDownload(input, { fetchImpl = globalThis.fetch } = {}) {
|
|
101
|
+
const { repo, filename } = parseHfRef(input);
|
|
102
|
+
|
|
103
|
+
if (filename && filename.endsWith(".gguf")) {
|
|
104
|
+
const file = await resolveGgufFile(`${repo}/${filename}`, { fetchImpl });
|
|
105
|
+
return {
|
|
106
|
+
id: repo.split("/").pop() ?? repo,
|
|
107
|
+
repo,
|
|
108
|
+
format: "gguf",
|
|
109
|
+
files: [file],
|
|
110
|
+
totalSizeBytes: file.sizeBytes,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const tree = await getHfTree(repo, { fetchImpl });
|
|
115
|
+
const ggufFiles = tree.filter((f) => f.type === "file" && f.path.endsWith(".gguf"));
|
|
116
|
+
if (ggufFiles.length > 0) {
|
|
117
|
+
const file = ggufFiles[0];
|
|
118
|
+
const resolved = await resolveGgufFile(`${repo}/${file.path}`, { fetchImpl });
|
|
119
|
+
return {
|
|
120
|
+
id: repo.split("/").pop() ?? repo,
|
|
121
|
+
repo,
|
|
122
|
+
format: "gguf",
|
|
123
|
+
files: [resolved],
|
|
124
|
+
totalSizeBytes: resolved.sizeBytes,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const files = await resolveMlxRepo(repo, { fetchImpl });
|
|
129
|
+
return {
|
|
130
|
+
id: repo.split("/").pop() ?? repo,
|
|
131
|
+
repo,
|
|
132
|
+
format: "mlx",
|
|
133
|
+
files,
|
|
134
|
+
totalSizeBytes: files.reduce((sum, f) => sum + f.sizeBytes, 0),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Download a resolved model into the HF hub cache.
|
|
140
|
+
* @param {object} model - from resolveHfDownload
|
|
141
|
+
* @param {object} options
|
|
142
|
+
* @param {function} options.onProgress - ({ downloadedBytes, totalBytes, percentage, file }) => void
|
|
143
|
+
* @returns {Promise<{ localDir: string, format: string }>}
|
|
144
|
+
*/
|
|
145
|
+
export async function downloadToHfCache(model, options = {}) {
|
|
146
|
+
await mkdir(HF_HUB_DIR, { recursive: true });
|
|
147
|
+
|
|
148
|
+
const script = HF_DOWNLOAD_SCRIPT;
|
|
149
|
+
const args = ["--repo", model.repo, "--cache-dir", HF_HUB_DIR];
|
|
150
|
+
if (model.format === "gguf") {
|
|
151
|
+
args.push("--file", model.files[0].filename);
|
|
152
|
+
}
|
|
153
|
+
|
|
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
|
+
|
|
202
|
+
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
|
+
});
|
|
208
|
+
});
|
|
209
|
+
}
|
package/src/managed.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { BACKENDS } from "./backends.mjs";
|
|
3
3
|
import { commandExists } from "./exec.mjs";
|
|
4
4
|
|
|
5
|
-
export const MANAGED_BACKEND_IDS = ["
|
|
5
|
+
export const MANAGED_BACKEND_IDS = ["omlx"];
|
|
6
6
|
|
|
7
7
|
export async function scanManagedModels() {
|
|
8
8
|
const results = [];
|
|
@@ -22,10 +22,6 @@ export function hasLmStudioInstalled() {
|
|
|
22
22
|
return existsSync("/Applications/LM Studio.app");
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export function hasOllamaInstalled() {
|
|
26
|
-
return commandExists("ollama");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
25
|
export function hasOmlxInstalled() {
|
|
30
26
|
return commandExists("omlx");
|
|
31
27
|
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// MLX model discovery + metadata — scans configured model directories for MLX
|
|
2
|
+
// model directories and parses their config.json.
|
|
3
|
+
// Ported from deprecated-offgrid-desktop/src/main/model-discovery.ts +
|
|
4
|
+
// mlx-metadata.ts (MLX subset only).
|
|
5
|
+
//
|
|
6
|
+
// This runs ALONGSIDE offgrid-ai's existing GGUF scan (scan.mjs scanGgufModels)
|
|
7
|
+
// — it does not replace it. The picker (main.mjs) will merge GGUF + MLX lists.
|
|
8
|
+
//
|
|
9
|
+
// An MLX model directory is one containing config.json + one or more
|
|
10
|
+
// *.safetensors files. HuggingFace Hub cache layout (models--org--name) is
|
|
11
|
+
// detected and scanned specially.
|
|
12
|
+
|
|
13
|
+
import { readdir, stat, readFile } from "node:fs/promises";
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { join, basename } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { getModelScanDirs } from "./config.mjs";
|
|
18
|
+
import { inferSourceLabel, MIN_MODEL_SIZE_BYTES, EMBEDDING_MODEL_TYPES } from "./discovery-shared.mjs";
|
|
19
|
+
|
|
20
|
+
// ── Folder → backend mapping ──────────────────────────────────────────────
|
|
21
|
+
// The oMLX folder is oMLX-exclusive: models there are served by the oMLX
|
|
22
|
+
// managed backend, NOT by mlx-vlm. Every OTHER scan dir is format-based
|
|
23
|
+
// (GGUF → llama.cpp, MLX → mlx-vlm). So mlx-vlm scans all configured dirs
|
|
24
|
+
// EXCEPT the oMLX folder.
|
|
25
|
+
const OMLX_MODELS_DIR = join(homedir(), ".omlx", "models");
|
|
26
|
+
function isOmlxFolder(p) {
|
|
27
|
+
return p === OMLX_MODELS_DIR || p.startsWith(OMLX_MODELS_DIR + "/");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── MLX directory detection ───────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** True if dir contains config.json + at least one .safetensors file. */
|
|
33
|
+
async function isMlxModelDir(dir) {
|
|
34
|
+
if (!existsSync(join(dir, "config.json"))) return false;
|
|
35
|
+
try {
|
|
36
|
+
const entries = await readdir(dir);
|
|
37
|
+
return entries.some((f) => f.endsWith(".safetensors"));
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Sum the size of all .safetensors files in an MLX model dir (bytes). */
|
|
44
|
+
async function getMlxDirSizeBytes(dir) {
|
|
45
|
+
try {
|
|
46
|
+
const entries = await readdir(dir);
|
|
47
|
+
const sizes = await Promise.all(
|
|
48
|
+
entries.filter((f) => f.endsWith(".safetensors")).map(async (f) => {
|
|
49
|
+
const s = await stat(join(dir, f));
|
|
50
|
+
return s.size;
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
return sizes.reduce((a, b) => a + b, 0);
|
|
54
|
+
} catch {
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Recursive MLX scanner ─────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Recursively scan a directory for MLX model directories.
|
|
63
|
+
* Searches up to maxDepth levels deep. Does NOT collect GGUF (that's scan.mjs).
|
|
64
|
+
*/
|
|
65
|
+
async function scanDirRecursiveForMlx(rootDir, sourceLabel, maxDepth = 3) {
|
|
66
|
+
if (!existsSync(rootDir)) return [];
|
|
67
|
+
const models = [];
|
|
68
|
+
|
|
69
|
+
async function walk(dir, depth) {
|
|
70
|
+
if (depth > maxDepth) return;
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
74
|
+
} catch {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Is this directory itself an MLX model dir? (don't recurse into it)
|
|
79
|
+
if (depth > 0 && await isMlxModelDir(dir)) {
|
|
80
|
+
const sizeBytes = await getMlxDirSizeBytes(dir);
|
|
81
|
+
if (sizeBytes < MIN_MODEL_SIZE_BYTES) return;
|
|
82
|
+
if (await isEmbeddingMlxModel(join(dir, "config.json"))) return;
|
|
83
|
+
const caps = await detectMlxCapabilities(dir);
|
|
84
|
+
models.push(makeMlxModel(dir, basename(dir), sizeBytes, sourceLabel, rootDir, caps.contextLength));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (entry.name.startsWith(".") || entry.name === "README.md" || entry.name === ".gitattributes") continue;
|
|
90
|
+
const fullPath = join(dir, entry.name);
|
|
91
|
+
if (entry.isDirectory()) {
|
|
92
|
+
if (await isMlxModelDir(fullPath)) {
|
|
93
|
+
const sizeBytes = await getMlxDirSizeBytes(fullPath);
|
|
94
|
+
if (sizeBytes < MIN_MODEL_SIZE_BYTES) continue;
|
|
95
|
+
if (await isEmbeddingMlxModel(join(fullPath, "config.json"))) continue;
|
|
96
|
+
const caps = await detectMlxCapabilities(fullPath);
|
|
97
|
+
models.push(makeMlxModel(fullPath, entry.name, sizeBytes, sourceLabel, rootDir, caps.contextLength));
|
|
98
|
+
} else {
|
|
99
|
+
await walk(fullPath, depth + 1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await walk(rootDir, 0);
|
|
106
|
+
return models;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── HuggingFace Hub layout ────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/** True if dir looks like an HF Hub cache (has models--* subdirs). */
|
|
112
|
+
async function looksLikeHfHub(dir) {
|
|
113
|
+
if (!existsSync(dir)) return false;
|
|
114
|
+
try {
|
|
115
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
116
|
+
return entries.some((e) => e.isDirectory() && e.name.startsWith("models--"));
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Scan an HF Hub cache dir for MLX model dirs.
|
|
124
|
+
* HF layout: models--org--name/snapshots/hash/files
|
|
125
|
+
*/
|
|
126
|
+
async function scanHfHubForMlx(dir, sourceLabel) {
|
|
127
|
+
if (!existsSync(dir)) return [];
|
|
128
|
+
const models = [];
|
|
129
|
+
try {
|
|
130
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
if (!entry.isDirectory() || !entry.name.startsWith("models--")) continue;
|
|
133
|
+
const parts = entry.name.slice("models--".length).split("--");
|
|
134
|
+
const label = parts.join("/");
|
|
135
|
+
const snapshotsDir = join(dir, entry.name, "snapshots");
|
|
136
|
+
if (!existsSync(snapshotsDir)) continue;
|
|
137
|
+
const snapshots = await readdir(snapshotsDir, { withFileTypes: true });
|
|
138
|
+
// Follow symlinks (HF hub uses them; test imports use them too). A model
|
|
139
|
+
// dir can have several snapshots — some incomplete/empty. Check EACH
|
|
140
|
+
// snapshot and use the first that is a valid MLX model dir, rather than
|
|
141
|
+
// giving up on the whole model if the first snapshot happens to be empty.
|
|
142
|
+
const candidates = snapshots.filter((s) => s.isDirectory() || s.isSymbolicLink());
|
|
143
|
+
let snapshotPath = null;
|
|
144
|
+
for (const snap of candidates) {
|
|
145
|
+
const sp = join(snapshotsDir, snap.name);
|
|
146
|
+
const st = await stat(sp).catch(() => null);
|
|
147
|
+
if (st?.isDirectory() && await isMlxModelDir(sp)) { snapshotPath = sp; break; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!snapshotPath) continue;
|
|
151
|
+
const sizeBytes = await getMlxDirSizeBytes(snapshotPath);
|
|
152
|
+
if (sizeBytes < MIN_MODEL_SIZE_BYTES) continue;
|
|
153
|
+
if (await isEmbeddingMlxModel(join(snapshotPath, "config.json"))) continue;
|
|
154
|
+
models.push({
|
|
155
|
+
id: `${sourceLabel}:${entry.name}`,
|
|
156
|
+
label,
|
|
157
|
+
path: snapshotPath,
|
|
158
|
+
filePath: snapshotPath,
|
|
159
|
+
sizeBytes,
|
|
160
|
+
contextLength: (await detectMlxCapabilities(snapshotPath)).contextLength,
|
|
161
|
+
backend: "mlx-vlm",
|
|
162
|
+
format: "mlx",
|
|
163
|
+
source: sourceLabel,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
// Can't read — return what we have.
|
|
168
|
+
}
|
|
169
|
+
return models;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Embedding model filtering for MLX ─────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
async function isEmbeddingMlxModel(configPath) {
|
|
175
|
+
if (!existsSync(configPath)) return false;
|
|
176
|
+
try {
|
|
177
|
+
const config = JSON.parse(await readFile(configPath, "utf-8"));
|
|
178
|
+
const textConfig = config.text_config ?? config;
|
|
179
|
+
const modelType = String(textConfig.model_type ?? "").toLowerCase();
|
|
180
|
+
if (EMBEDDING_MODEL_TYPES.has(modelType)) return true;
|
|
181
|
+
const arch = Array.isArray(config.architectures) ? config.architectures[0] : "";
|
|
182
|
+
const lowerArch = String(arch).toLowerCase();
|
|
183
|
+
return EMBEDDING_MODEL_TYPES.has(lowerArch) || lowerArch.includes("bert");
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── MLX model entry builder ───────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
function makeMlxModel(dir, label, sizeBytes, sourceLabel, rootDir, contextLength = null) {
|
|
192
|
+
return {
|
|
193
|
+
id: `${sourceLabel}:${dir.replace(rootDir + "/", "")}`,
|
|
194
|
+
label,
|
|
195
|
+
path: dir,
|
|
196
|
+
filePath: dir,
|
|
197
|
+
sizeBytes,
|
|
198
|
+
contextLength,
|
|
199
|
+
backend: "mlx-vlm",
|
|
200
|
+
format: "mlx",
|
|
201
|
+
source: sourceLabel,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Discover all MLX models across the configured scan directories.
|
|
209
|
+
* Reads scan dirs from config.mjs getModelScanDirs() — same paths GGUF uses
|
|
210
|
+
* (LM Studio, HF hub, user-added). Returns a flat, deduplicated list.
|
|
211
|
+
*/
|
|
212
|
+
export async function scanMlxModels(dirs) {
|
|
213
|
+
// mlx-vlm scans every configured dir EXCEPT the oMLX folder (oMLX-exclusive).
|
|
214
|
+
const scanDirs = (dirs ?? await getModelScanDirs()).filter((d) => !isOmlxFolder(d));
|
|
215
|
+
const results = await Promise.all(
|
|
216
|
+
scanDirs.map(async (dir) => {
|
|
217
|
+
const label = inferSourceLabel(dir);
|
|
218
|
+
if (await looksLikeHfHub(dir)) return scanHfHubForMlx(dir, label);
|
|
219
|
+
return scanDirRecursiveForMlx(dir, label);
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
const all = results.flat();
|
|
223
|
+
// Deduplicate by filePath (same model may appear in multiple paths).
|
|
224
|
+
const seen = new Set();
|
|
225
|
+
return all.filter((m) => {
|
|
226
|
+
if (seen.has(m.filePath)) return false;
|
|
227
|
+
seen.add(m.filePath);
|
|
228
|
+
return true;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── MLX capability detection ─────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Detect MLX model capabilities from its config.json.
|
|
236
|
+
* Returns { architecture, thinking, vision, contextLength }.
|
|
237
|
+
*/
|
|
238
|
+
export async function detectMlxCapabilities(modelDir) {
|
|
239
|
+
const configPath = join(modelDir, "config.json");
|
|
240
|
+
if (!existsSync(configPath)) return { thinking: false, vision: false, contextLength: null, architecture: null };
|
|
241
|
+
try {
|
|
242
|
+
const config = JSON.parse(await readFile(configPath, "utf-8"));
|
|
243
|
+
return detectMlxCapabilitiesFromConfig(config, modelDir);
|
|
244
|
+
} catch {
|
|
245
|
+
return { thinking: false, vision: false, contextLength: null, architecture: null };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function detectMlxCapabilitiesFromConfig(config, modelDir) {
|
|
250
|
+
const textConfig = config.text_config ?? config;
|
|
251
|
+
const rawName = config._name_or_path ?? basename(modelDir ?? "");
|
|
252
|
+
const name = String(rawName).toLowerCase();
|
|
253
|
+
const label = String(rawName);
|
|
254
|
+
|
|
255
|
+
const modelType = String(config.model_type ?? "").toLowerCase();
|
|
256
|
+
const textModelType = String(textConfig.model_type ?? "").toLowerCase();
|
|
257
|
+
|
|
258
|
+
const vision = Boolean(
|
|
259
|
+
config.vision_config ||
|
|
260
|
+
config.image_token_id != null ||
|
|
261
|
+
config.video_token_id != null ||
|
|
262
|
+
config.vision_start_token_id != null ||
|
|
263
|
+
modelType.includes("vl") ||
|
|
264
|
+
modelType.includes("vision") ||
|
|
265
|
+
textModelType.includes("vl") ||
|
|
266
|
+
textModelType.includes("vision")
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const thinking = /qwen3|gemma-4|gemma4|deepseek-r[12]/i.test(name + " " + label);
|
|
270
|
+
|
|
271
|
+
const architectures = Array.isArray(config.architectures) ? config.architectures : [];
|
|
272
|
+
const architecture = architectures[0] ?? null;
|
|
273
|
+
|
|
274
|
+
const candidates = [
|
|
275
|
+
textConfig.max_position_embeddings,
|
|
276
|
+
textConfig.sliding_window,
|
|
277
|
+
config.max_position_embeddings,
|
|
278
|
+
config.sliding_window,
|
|
279
|
+
].filter((v) => typeof v === "number" && v > 0);
|
|
280
|
+
const contextLength = candidates.length > 0 ? Math.max(...candidates) : null;
|
|
281
|
+
|
|
282
|
+
return { thinking, vision, contextLength, architecture };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Pick a sensible default context length for an MLX model, capping by RAM.
|
|
287
|
+
*/
|
|
288
|
+
export function defaultMlxContextLength(trainedCtx, ramGb) {
|
|
289
|
+
if (!trainedCtx || trainedCtx <= 0) return 8192;
|
|
290
|
+
if (ramGb < 12) return Math.min(trainedCtx, 4096);
|
|
291
|
+
if (ramGb < 16) return Math.min(trainedCtx, 8192);
|
|
292
|
+
if (ramGb < 32) return Math.min(trainedCtx, 16384);
|
|
293
|
+
return trainedCtx;
|
|
294
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// mlx-vlm server flag computation — pure functions, no side effects.
|
|
2
|
+
// Ported from deprecated-offgrid-desktop/src/main/server-flags.ts (MLX subset).
|
|
3
|
+
//
|
|
4
|
+
// Benchmark-informed decisions (see sidequests/mlx-backend-benchmark/RESULTS.md):
|
|
5
|
+
// - mlx-vlm requires APC_ENABLED=1 env var (86x TTFT improvement) — set at spawn
|
|
6
|
+
// time in process.mjs, NOT here (this module only computes args).
|
|
7
|
+
// - mlx-vlm uses a strict=False wrapper script for shared-KV architectures
|
|
8
|
+
// (Gemma 4-class). Safe for all models — strict=False is a no-op for models
|
|
9
|
+
// that load fine with strict=True.
|
|
10
|
+
// - mlx-vlm uses --enable-thinking for thinking-mode control.
|
|
11
|
+
// - mlx-vlm uses --max-kv-size for the KV cache / context window.
|
|
12
|
+
//
|
|
13
|
+
// Only the mlx-vlm-relevant logic is ported here. offgrid-ai's existing GGUF
|
|
14
|
+
// flag logic (autodetect.mjs / profile-setup.mjs / estimate.mjs) is unchanged.
|
|
15
|
+
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
const MB = 1024 ** 2;
|
|
20
|
+
|
|
21
|
+
/** Default port for the local model server. Matches the desktop's DEFAULT_PORT. */
|
|
22
|
+
export const DEFAULT_PORT = 18080;
|
|
23
|
+
|
|
24
|
+
/** Resolved path to the bundled strict=False wrapper script (sibling of src/). */
|
|
25
|
+
export const MLX_VLM_WRAPPER = join(dirname(fileURLToPath(import.meta.url)), "..", "resources", "mlxvlm-server-wrapper.py");
|
|
26
|
+
|
|
27
|
+
/** Overhead multiplier for mlx-vlm: weights × 1.5 (covers KV cache, activations, APC cache; benchmark-validated). */
|
|
28
|
+
const MLX_VLM_OVERHEAD_MULTIPLIER = 1.5;
|
|
29
|
+
|
|
30
|
+
/** Server process overhead in MB. */
|
|
31
|
+
const PROCESS_OVERHEAD_MB = 200;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Estimate mlx-vlm memory usage (MB): model weights × 1.5 + process overhead.
|
|
35
|
+
*
|
|
36
|
+
* The 1.5 multiplier covers KV cache, activations, and APC cache overhead
|
|
37
|
+
* (benchmark-validated; see sidequests/mlx-backend-benchmark/RESULTS.md).
|
|
38
|
+
* GGUF/llama-server estimation uses the detailed path in estimate.mjs.
|
|
39
|
+
*
|
|
40
|
+
* @param {number} fileSizeBytes - model size on disk (sum of MLX safetensors).
|
|
41
|
+
* @returns {number} estimated memory in MB.
|
|
42
|
+
*/
|
|
43
|
+
export function estimateMemoryMb(fileSizeBytes) {
|
|
44
|
+
return Math.round((fileSizeBytes / MB) * MLX_VLM_OVERHEAD_MULTIPLIER + PROCESS_OVERHEAD_MB);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compute mlx-vlm server arguments.
|
|
49
|
+
*
|
|
50
|
+
* mlx-vlm is the MLX-native server (benchmark-validated best throughput + memory
|
|
51
|
+
* efficiency on Apple Silicon). Invoked via the strict=False wrapper script for
|
|
52
|
+
* compatibility with shared-KV architectures (Gemma 4-class).
|
|
53
|
+
*
|
|
54
|
+
* The APC_ENABLED=1 env var is MANDATORY but is set at spawn time in
|
|
55
|
+
* process.mjs, not in args.
|
|
56
|
+
*
|
|
57
|
+
* The wrapper script (resources/mlxvlm-server-wrapper.py) applies strict=False
|
|
58
|
+
* model loading + the BatchRotatingKVCache.merge() fix, both required for
|
|
59
|
+
* shared-KV architectures (Gemma 4-class). It is resolved to a real path via
|
|
60
|
+
* MLX_VLM_WRAPPER; there is intentionally no raw-mlx_vlm.server path.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} modelPath - path to the MLX model directory.
|
|
63
|
+
* @param {object} [options]
|
|
64
|
+
* @param {number} [options.port] - port (default DEFAULT_PORT).
|
|
65
|
+
* @param {number} [options.ctxSize] - context window (passed as --max-kv-size).
|
|
66
|
+
* @param {boolean} [options.thinkingEnabled=true] - whether to enable thinking.
|
|
67
|
+
* @returns {{ args: string[], port: number }}
|
|
68
|
+
*/
|
|
69
|
+
export function computeMlxVlmFlags(modelPath, options = {}) {
|
|
70
|
+
const port = options.port ?? DEFAULT_PORT;
|
|
71
|
+
const ctxSize = options.ctxSize;
|
|
72
|
+
const thinkingEnabled = options.thinkingEnabled ?? true;
|
|
73
|
+
|
|
74
|
+
// The binary is "python3" (resolved by backendBinaryFor in backends.mjs); the
|
|
75
|
+
// wrapper path is the first arg.
|
|
76
|
+
const args = [
|
|
77
|
+
MLX_VLM_WRAPPER,
|
|
78
|
+
"--model", modelPath,
|
|
79
|
+
"--host", "127.0.0.1",
|
|
80
|
+
"--port", String(port),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
if (thinkingEnabled) {
|
|
84
|
+
args.push("--enable-thinking");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Context size: mlx-vlm uses --max-kv-size for the KV cache / context window.
|
|
88
|
+
if (ctxSize && ctxSize > 0) {
|
|
89
|
+
args.push("--max-kv-size", String(ctxSize));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { args, port };
|
|
93
|
+
}
|