offgrid-ai 0.15.9 → 0.16.3
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 +4 -4
- package/package.json +1 -1
- package/src/autodetect.mjs +1 -1
- package/src/backends.mjs +4 -41
- package/src/benchmark/flow.mjs +14 -13
- package/src/benchmark/metrics.mjs +14 -20
- package/src/commands/main.mjs +7 -7
- package/src/commands/models.mjs +8 -21
- package/src/commands/onboard.mjs +10 -43
- package/src/commands/run.mjs +1 -1
- package/src/commands/status.mjs +19 -0
- package/src/config.mjs +48 -2
- package/src/harness-pi.mjs +5 -7
- package/src/managed.mjs +3 -3
- package/src/mlx-discovery.mjs +77 -258
- package/src/model-catalog.mjs +9 -14
- package/src/model-presenters.mjs +0 -30
- package/src/omlx-runtime.mjs +232 -0
- package/src/process.mjs +87 -48
- package/src/profile-setup.mjs +50 -113
- package/src/profiles.mjs +12 -28
- package/src/ui.mjs +2 -19
- package/resources/mlxvlm-server-wrapper.py +0 -112
- package/src/mlx-flags.mjs +0 -100
package/src/harness-pi.mjs
CHANGED
|
@@ -8,13 +8,11 @@ import pc from "picocolors";
|
|
|
8
8
|
// ── Pi model id ─────────────────────────────────────────────────────────────
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* The model id Pi must send in requests.
|
|
12
|
-
*
|
|
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.
|
|
11
|
+
* The model id Pi must send in requests. All backends use the friendly
|
|
12
|
+
* modelAlias as the API model id.
|
|
15
13
|
*/
|
|
16
14
|
export function piApiModelId(profile) {
|
|
17
|
-
return profile.
|
|
15
|
+
return profile.modelAlias;
|
|
18
16
|
}
|
|
19
17
|
|
|
20
18
|
// ── Sync Pi config ─────────────────────────────────────────────────────────
|
|
@@ -87,7 +85,7 @@ async function activeProviderProfiles(currentProfile) {
|
|
|
87
85
|
const byAlias = new Map();
|
|
88
86
|
for (const item of [...allProfiles, currentProfile]) {
|
|
89
87
|
if (item.providerId !== currentProfile.providerId) continue;
|
|
90
|
-
if (item.backend !== "llama-cpp"
|
|
88
|
+
if (item.backend !== "llama-cpp") {
|
|
91
89
|
byAlias.set(item.modelAlias, item);
|
|
92
90
|
continue;
|
|
93
91
|
}
|
|
@@ -135,7 +133,7 @@ export function modelReasoning(profile) {
|
|
|
135
133
|
}
|
|
136
134
|
|
|
137
135
|
export function modelFamily(profile) {
|
|
138
|
-
return [profile.id, profile.label, profile.modelAlias, profile.
|
|
136
|
+
return [profile.id, profile.label, profile.modelAlias, profile.omlxModel].filter(Boolean).join(" ").toLowerCase();
|
|
139
137
|
}
|
|
140
138
|
|
|
141
139
|
function piApiKey() {
|
package/src/managed.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { BACKENDS } from "./backends.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { hasOmlx } from "./omlx-runtime.mjs";
|
|
4
4
|
|
|
5
5
|
export const MANAGED_BACKEND_IDS = ["omlx"];
|
|
6
6
|
|
|
@@ -22,6 +22,6 @@ export function hasLmStudioInstalled() {
|
|
|
22
22
|
return existsSync("/Applications/LM Studio.app");
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export function hasOmlxInstalled() {
|
|
26
|
-
return
|
|
25
|
+
export async function hasOmlxInstalled() {
|
|
26
|
+
return await hasOmlx();
|
|
27
27
|
}
|
package/src/mlx-discovery.mjs
CHANGED
|
@@ -1,36 +1,14 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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.
|
|
1
|
+
// oMLX model size lookup — scans ~/.omlx/models/ for MLX model directories
|
|
2
|
+
// to compute sizes and publishers. The oMLX API doesn't return these, so we
|
|
3
|
+
// read them from disk.
|
|
12
4
|
|
|
13
5
|
import { readdir, stat, readFile } from "node:fs/promises";
|
|
14
6
|
import { existsSync } from "node:fs";
|
|
15
|
-
import { join
|
|
7
|
+
import { join } from "node:path";
|
|
16
8
|
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
|
-
import { parseModelName } from "./model-name.mjs";
|
|
20
9
|
|
|
21
|
-
// ── Folder → backend mapping ──────────────────────────────────────────────
|
|
22
|
-
// The oMLX folder is oMLX-exclusive: models there are served by the oMLX
|
|
23
|
-
// managed backend, NOT by mlx-vlm. Every OTHER scan dir is format-based
|
|
24
|
-
// (GGUF → llama.cpp, MLX → mlx-vlm). So mlx-vlm scans all configured dirs
|
|
25
|
-
// EXCEPT the oMLX folder.
|
|
26
10
|
const OMLX_MODELS_DIR = join(homedir(), ".omlx", "models");
|
|
27
|
-
function isOmlxFolder(p) {
|
|
28
|
-
return p === OMLX_MODELS_DIR || p.startsWith(OMLX_MODELS_DIR + "/");
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// ── MLX directory detection ───────────────────────────────────────────────
|
|
32
11
|
|
|
33
|
-
/** True if dir contains config.json + at least one .safetensors file. */
|
|
34
12
|
async function isMlxModelDir(dir) {
|
|
35
13
|
if (!existsSync(join(dir, "config.json"))) return false;
|
|
36
14
|
try {
|
|
@@ -41,7 +19,6 @@ async function isMlxModelDir(dir) {
|
|
|
41
19
|
}
|
|
42
20
|
}
|
|
43
21
|
|
|
44
|
-
/** Sum the size of all .safetensors files in an MLX model dir (bytes). */
|
|
45
22
|
async function getMlxDirSizeBytes(dir) {
|
|
46
23
|
try {
|
|
47
24
|
const entries = await readdir(dir);
|
|
@@ -57,290 +34,132 @@ async function getMlxDirSizeBytes(dir) {
|
|
|
57
34
|
}
|
|
58
35
|
}
|
|
59
36
|
|
|
60
|
-
// ── Recursive MLX scanner ─────────────────────────────────────────────────
|
|
61
|
-
|
|
62
37
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
38
|
+
* Scan ~/.omlx/models/ for MLX model directories and return a Map of
|
|
39
|
+
* basename → { sizeBytes, publisher }.
|
|
65
40
|
*/
|
|
66
|
-
async function
|
|
67
|
-
if (!existsSync(
|
|
68
|
-
const
|
|
41
|
+
export async function scanOmlxModelSizes() {
|
|
42
|
+
if (!existsSync(OMLX_MODELS_DIR)) return new Map();
|
|
43
|
+
const infoByBasename = new Map();
|
|
69
44
|
|
|
70
|
-
async function walk(dir,
|
|
71
|
-
if (depth > maxDepth) return;
|
|
45
|
+
async function walk(dir, publisher) {
|
|
72
46
|
let entries;
|
|
73
47
|
try {
|
|
74
48
|
entries = await readdir(dir, { withFileTypes: true });
|
|
75
49
|
} catch {
|
|
76
50
|
return;
|
|
77
51
|
}
|
|
78
|
-
|
|
79
|
-
// Is this directory itself an MLX model dir? (don't recurse into it)
|
|
80
|
-
if (depth > 0 && await isMlxModelDir(dir)) {
|
|
81
|
-
const sizeBytes = await getMlxDirSizeBytes(dir);
|
|
82
|
-
if (sizeBytes < MIN_MODEL_SIZE_BYTES) return;
|
|
83
|
-
if (await isEmbeddingMlxModel(join(dir, "config.json"))) return;
|
|
84
|
-
const caps = await detectMlxCapabilities(dir);
|
|
85
|
-
const { display, quant } = parseModelName(basename(dir), sourceLabel);
|
|
86
|
-
models.push(makeMlxModel(dir, display, sizeBytes, sourceLabel, rootDir, caps.contextLength, quant));
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
52
|
for (const entry of entries) {
|
|
91
|
-
if (entry.
|
|
53
|
+
if (!entry.isDirectory()) continue;
|
|
92
54
|
const fullPath = join(dir, entry.name);
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const caps = await detectMlxCapabilities(fullPath);
|
|
99
|
-
// Extract publisher from parent dir (LM Studio: publisher/model-dir)
|
|
100
|
-
const relParts = fullPath.slice(rootDir.length + 1).split("/");
|
|
101
|
-
const publisher = (sourceLabel === "lmstudio" && relParts.length >= 2) ? relParts[0] : null;
|
|
102
|
-
const rawLabel = publisher ? `${publisher}/${entry.name}` : entry.name;
|
|
103
|
-
const { display, quant } = parseModelName(rawLabel, sourceLabel);
|
|
104
|
-
models.push(makeMlxModel(fullPath, display, sizeBytes, sourceLabel, rootDir, caps.contextLength, quant));
|
|
105
|
-
} else {
|
|
106
|
-
await walk(fullPath, depth + 1);
|
|
107
|
-
}
|
|
55
|
+
if (await isMlxModelDir(fullPath)) {
|
|
56
|
+
const sizeBytes = await getMlxDirSizeBytes(fullPath);
|
|
57
|
+
if (sizeBytes > 0) infoByBasename.set(entry.name, { sizeBytes, publisher });
|
|
58
|
+
} else {
|
|
59
|
+
await walk(fullPath, publisher ?? entry.name);
|
|
108
60
|
}
|
|
109
61
|
}
|
|
110
62
|
}
|
|
111
63
|
|
|
112
|
-
await walk(
|
|
113
|
-
return
|
|
64
|
+
await walk(OMLX_MODELS_DIR, null);
|
|
65
|
+
return infoByBasename;
|
|
114
66
|
}
|
|
115
67
|
|
|
116
|
-
// ──
|
|
68
|
+
// ── MTP capability detection ─────────────────────────────────────────────
|
|
69
|
+
// oMLX supports native MTP for Qwen 3.5/3.6 (dense + MoE) and DeepSeek-V4
|
|
70
|
+
// models that ship MTP heads in their weights. The check mirrors oMLX's own
|
|
71
|
+
// _mtp_compat_for_model: config must declare mtp_num_hidden_layers, model_type
|
|
72
|
+
// must be on the whitelist, and the safetensors index must contain mtp.* keys.
|
|
117
73
|
|
|
118
|
-
|
|
119
|
-
async function looksLikeHfHub(dir) {
|
|
120
|
-
if (!existsSync(dir)) return false;
|
|
121
|
-
try {
|
|
122
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
123
|
-
return entries.some((e) => e.isDirectory() && e.name.startsWith("models--"));
|
|
124
|
-
} catch {
|
|
125
|
-
return false;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
74
|
+
const MTP_MODEL_TYPES = ["qwen3_5", "qwen3_5_moe", "qwen3_6", "qwen3_6_moe", "deepseek_v4"];
|
|
128
75
|
|
|
129
76
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
77
|
+
* Check if an oMLX model directory supports native MTP.
|
|
78
|
+
* Returns { compatible: boolean, reason: string }.
|
|
132
79
|
*/
|
|
133
|
-
async function
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
try {
|
|
137
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
138
|
-
for (const entry of entries) {
|
|
139
|
-
if (!entry.isDirectory() || !entry.name.startsWith("models--")) continue;
|
|
140
|
-
const parts = entry.name.slice("models--".length).split("--");
|
|
141
|
-
const label = parts.join("/");
|
|
142
|
-
const snapshotsDir = join(dir, entry.name, "snapshots");
|
|
143
|
-
if (!existsSync(snapshotsDir)) continue;
|
|
144
|
-
const snapshots = await readdir(snapshotsDir, { withFileTypes: true });
|
|
145
|
-
// Follow symlinks (HF hub uses them; test imports use them too). A model
|
|
146
|
-
// dir can have several snapshots — some incomplete/empty. Check EACH
|
|
147
|
-
// snapshot and use the first that is a valid MLX model dir, rather than
|
|
148
|
-
// giving up on the whole model if the first snapshot happens to be empty.
|
|
149
|
-
const candidates = snapshots.filter((s) => s.isDirectory() || s.isSymbolicLink());
|
|
150
|
-
let snapshotPath = null;
|
|
151
|
-
for (const snap of candidates) {
|
|
152
|
-
const sp = join(snapshotsDir, snap.name);
|
|
153
|
-
const st = await stat(sp).catch(() => null);
|
|
154
|
-
if (st?.isDirectory() && await isMlxModelDir(sp)) { snapshotPath = sp; break; }
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (!snapshotPath) continue;
|
|
158
|
-
const sizeBytes = await getMlxDirSizeBytes(snapshotPath);
|
|
159
|
-
if (sizeBytes < MIN_MODEL_SIZE_BYTES) continue;
|
|
160
|
-
if (await isEmbeddingMlxModel(join(snapshotPath, "config.json"))) continue;
|
|
161
|
-
const caps = await detectMlxCapabilities(snapshotPath);
|
|
162
|
-
const { display, quant } = parseModelName(label, sourceLabel);
|
|
163
|
-
models.push({
|
|
164
|
-
id: `${sourceLabel}:${entry.name}`,
|
|
165
|
-
label: display,
|
|
166
|
-
path: snapshotPath,
|
|
167
|
-
filePath: snapshotPath,
|
|
168
|
-
sizeBytes,
|
|
169
|
-
contextLength: caps.contextLength,
|
|
170
|
-
quant,
|
|
171
|
-
backend: "mlx-vlm",
|
|
172
|
-
format: "mlx",
|
|
173
|
-
source: sourceLabel,
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
} catch {
|
|
177
|
-
// Can't read — return what we have.
|
|
178
|
-
}
|
|
179
|
-
return models;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// ── Embedding model filtering for MLX ─────────────────────────────────────
|
|
80
|
+
export async function detectOmlxMtpCapability(modelDir) {
|
|
81
|
+
const configPath = join(modelDir, "config.json");
|
|
82
|
+
if (!existsSync(configPath)) return { compatible: false, reason: "config.json not found" };
|
|
183
83
|
|
|
184
|
-
|
|
185
|
-
if (!existsSync(configPath)) return false;
|
|
84
|
+
let config;
|
|
186
85
|
try {
|
|
187
|
-
|
|
188
|
-
const textConfig = config.text_config ?? config;
|
|
189
|
-
const modelType = String(textConfig.model_type ?? "").toLowerCase();
|
|
190
|
-
if (EMBEDDING_MODEL_TYPES.has(modelType)) return true;
|
|
191
|
-
const arch = Array.isArray(config.architectures) ? config.architectures[0] : "";
|
|
192
|
-
const lowerArch = String(arch).toLowerCase();
|
|
193
|
-
return EMBEDDING_MODEL_TYPES.has(lowerArch) || lowerArch.includes("bert");
|
|
86
|
+
config = JSON.parse(await readFile(configPath, "utf8"));
|
|
194
87
|
} catch {
|
|
195
|
-
return false;
|
|
88
|
+
return { compatible: false, reason: "failed to read config.json" };
|
|
196
89
|
}
|
|
197
|
-
}
|
|
198
90
|
|
|
199
|
-
|
|
91
|
+
const mtpLayers = config.mtp_num_hidden_layers;
|
|
92
|
+
if (!mtpLayers || mtpLayers <= 0) {
|
|
93
|
+
return { compatible: false, reason: "model has no MTP heads in config" };
|
|
94
|
+
}
|
|
200
95
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
path: dir,
|
|
206
|
-
filePath: dir,
|
|
207
|
-
sizeBytes,
|
|
208
|
-
contextLength,
|
|
209
|
-
quant,
|
|
210
|
-
backend: "mlx-vlm",
|
|
211
|
-
format: "mlx",
|
|
212
|
-
source: sourceLabel,
|
|
213
|
-
};
|
|
214
|
-
}
|
|
96
|
+
const modelType = config.model_type;
|
|
97
|
+
if (!MTP_MODEL_TYPES.some((t) => modelType === t || modelType?.startsWith(t))) {
|
|
98
|
+
return { compatible: false, reason: `model_type=${modelType} is not on the MTP whitelist (supported: ${MTP_MODEL_TYPES.join(", ")})` };
|
|
99
|
+
}
|
|
215
100
|
|
|
216
|
-
//
|
|
101
|
+
// Check for mtp.* weight tensors in the safetensors index
|
|
102
|
+
const hasWeights = await modelHasMtpWeights(modelDir);
|
|
103
|
+
if (!hasWeights) {
|
|
104
|
+
return { compatible: false, reason: "Config declares MTP layers but the converted weights are missing mtp.* tensors. Re-convert from HF with a converter that preserves MTP weights." };
|
|
105
|
+
}
|
|
217
106
|
|
|
218
|
-
|
|
219
|
-
* Discover all MLX models across the configured scan directories.
|
|
220
|
-
* Reads scan dirs from config.mjs getModelScanDirs() — same paths GGUF uses
|
|
221
|
-
* (LM Studio, HF hub, user-added). Returns a flat, deduplicated list.
|
|
222
|
-
*/
|
|
223
|
-
export async function scanMlxModels(dirs) {
|
|
224
|
-
// mlx-vlm scans every configured dir EXCEPT the oMLX folder (oMLX-exclusive).
|
|
225
|
-
const scanDirs = (dirs ?? await getModelScanDirs()).filter((d) => !isOmlxFolder(d));
|
|
226
|
-
const results = await Promise.all(
|
|
227
|
-
scanDirs.map(async (dir) => {
|
|
228
|
-
const label = inferSourceLabel(dir);
|
|
229
|
-
if (await looksLikeHfHub(dir)) return scanHfHubForMlx(dir, label);
|
|
230
|
-
return scanDirRecursiveForMlx(dir, label);
|
|
231
|
-
}),
|
|
232
|
-
);
|
|
233
|
-
const all = results.flat();
|
|
234
|
-
// Deduplicate by filePath (same model may appear in multiple paths).
|
|
235
|
-
const seen = new Set();
|
|
236
|
-
return all.filter((m) => {
|
|
237
|
-
if (seen.has(m.filePath)) return false;
|
|
238
|
-
seen.add(m.filePath);
|
|
239
|
-
return true;
|
|
240
|
-
});
|
|
107
|
+
return { compatible: true, reason: "" };
|
|
241
108
|
}
|
|
242
109
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
110
|
+
async function modelHasMtpWeights(modelDir) {
|
|
111
|
+
const indexPath = join(modelDir, "model.safetensors.index.json");
|
|
112
|
+
if (existsSync(indexPath)) {
|
|
113
|
+
try {
|
|
114
|
+
const index = JSON.parse(await readFile(indexPath, "utf8"));
|
|
115
|
+
const weightMap = index.weight_map ?? {};
|
|
116
|
+
return Object.keys(weightMap).some((key) => key.includes("mtp."));
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Single-shard fallback: can't easily read safetensors keys in Node without
|
|
122
|
+
// the safetensors library. Check for an mtp-specific safetensors file.
|
|
252
123
|
try {
|
|
253
|
-
const
|
|
254
|
-
return
|
|
124
|
+
const entries = await readdir(modelDir);
|
|
125
|
+
return entries.some((f) => f.endsWith(".safetensors") && /mtp/i.test(f));
|
|
255
126
|
} catch {
|
|
256
|
-
return
|
|
127
|
+
return false;
|
|
257
128
|
}
|
|
258
129
|
}
|
|
259
130
|
|
|
260
|
-
export function detectMlxCapabilitiesFromConfig(config, modelDir) {
|
|
261
|
-
const textConfig = config.text_config ?? config;
|
|
262
|
-
const rawName = config._name_or_path ?? basename(modelDir ?? "");
|
|
263
|
-
const name = String(rawName).toLowerCase();
|
|
264
|
-
const label = String(rawName);
|
|
265
|
-
|
|
266
|
-
const modelType = String(config.model_type ?? "").toLowerCase();
|
|
267
|
-
const textModelType = String(textConfig.model_type ?? "").toLowerCase();
|
|
268
|
-
|
|
269
|
-
const vision = Boolean(
|
|
270
|
-
config.vision_config ||
|
|
271
|
-
config.image_token_id != null ||
|
|
272
|
-
config.video_token_id != null ||
|
|
273
|
-
config.vision_start_token_id != null ||
|
|
274
|
-
modelType.includes("vl") ||
|
|
275
|
-
modelType.includes("vision") ||
|
|
276
|
-
textModelType.includes("vl") ||
|
|
277
|
-
textModelType.includes("vision")
|
|
278
|
-
);
|
|
279
|
-
|
|
280
|
-
const thinking = /qwen3|gemma-4|gemma4|deepseek-r[12]/i.test(name + " " + label);
|
|
281
|
-
|
|
282
|
-
const architectures = Array.isArray(config.architectures) ? config.architectures : [];
|
|
283
|
-
const architecture = architectures[0] ?? null;
|
|
284
|
-
|
|
285
|
-
const candidates = [
|
|
286
|
-
textConfig.max_position_embeddings,
|
|
287
|
-
textConfig.sliding_window,
|
|
288
|
-
config.max_position_embeddings,
|
|
289
|
-
config.sliding_window,
|
|
290
|
-
].filter((v) => typeof v === "number" && v > 0);
|
|
291
|
-
const contextLength = candidates.length > 0 ? Math.max(...candidates) : null;
|
|
292
|
-
|
|
293
|
-
return { thinking, vision, contextLength, architecture };
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* Pick a sensible default context length for an MLX model, capping by RAM.
|
|
298
|
-
*/
|
|
299
|
-
export function defaultMlxContextLength(trainedCtx, ramGb) {
|
|
300
|
-
if (!trainedCtx || trainedCtx <= 0) return 8192;
|
|
301
|
-
if (ramGb < 12) return Math.min(trainedCtx, 4096);
|
|
302
|
-
if (ramGb < 16) return Math.min(trainedCtx, 8192);
|
|
303
|
-
if (ramGb < 32) return Math.min(trainedCtx, 16384);
|
|
304
|
-
return trainedCtx;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// ── oMLX model size lookup (from disk) ────────────────────────────────────
|
|
308
|
-
|
|
309
131
|
/**
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
* doesn't return model sizes or publishers, so we compute them from disk.
|
|
132
|
+
* Find the model directory for a given oMLX model ID.
|
|
133
|
+
* Searches ~/.omlx/models/ recursively for a directory matching the model ID.
|
|
313
134
|
*/
|
|
314
|
-
export async function
|
|
315
|
-
if (!existsSync(OMLX_MODELS_DIR)) return
|
|
316
|
-
const
|
|
135
|
+
export async function findOmlxModelDir(modelId) {
|
|
136
|
+
if (!existsSync(OMLX_MODELS_DIR)) return null;
|
|
137
|
+
const basename = modelId.includes("/") ? modelId.slice(modelId.lastIndexOf("/") + 1)
|
|
138
|
+
: modelId.includes("--") ? modelId.slice(modelId.lastIndexOf("--") + 2)
|
|
139
|
+
: modelId;
|
|
317
140
|
|
|
318
|
-
async function walk(dir
|
|
141
|
+
async function walk(dir) {
|
|
319
142
|
let entries;
|
|
320
143
|
try {
|
|
321
144
|
entries = await readdir(dir, { withFileTypes: true });
|
|
322
145
|
} catch {
|
|
323
|
-
return;
|
|
146
|
+
return null;
|
|
324
147
|
}
|
|
325
148
|
for (const entry of entries) {
|
|
326
149
|
if (!entry.isDirectory()) continue;
|
|
327
150
|
const fullPath = join(dir, entry.name);
|
|
328
|
-
if (await isMlxModelDir(fullPath))
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
} else {
|
|
332
|
-
// First-level directories under ~/.omlx/models/ are publishers
|
|
333
|
-
await walk(fullPath, publisher ?? entry.name);
|
|
334
|
-
}
|
|
151
|
+
if (entry.name === basename && await isMlxModelDir(fullPath)) return fullPath;
|
|
152
|
+
const found = await walk(fullPath);
|
|
153
|
+
if (found) return found;
|
|
335
154
|
}
|
|
155
|
+
return null;
|
|
336
156
|
}
|
|
337
157
|
|
|
338
|
-
await walk(OMLX_MODELS_DIR
|
|
339
|
-
return infoByBasename;
|
|
158
|
+
return await walk(OMLX_MODELS_DIR);
|
|
340
159
|
}
|
|
341
160
|
|
|
342
161
|
/**
|
|
343
|
-
* Look up a model's info by its oMLX API id.
|
|
162
|
+
* Look up a model's info by its oMLX API id. Tries exact match, then the
|
|
344
163
|
* segment after `--` (oMLX org--name format), then after `/` (HF format).
|
|
345
164
|
*/
|
|
346
165
|
export function lookupOmlxModelInfo(modelId, infoMap) {
|
package/src/model-catalog.mjs
CHANGED
|
@@ -1,28 +1,23 @@
|
|
|
1
1
|
import { scanGgufModels, matchDrafter } from "./scan.mjs";
|
|
2
2
|
import { loadProfiles, normalizeProfile, sanitizeProfileId } from "./profiles.mjs";
|
|
3
3
|
import { scanManagedModels } from "./managed.mjs";
|
|
4
|
-
import { scanMlxModels } from "./mlx-discovery.mjs";
|
|
5
4
|
import { isProfileFileMissing } from "./model-summary.mjs";
|
|
6
5
|
import { backendFor } from "./backends.mjs";
|
|
7
6
|
|
|
8
7
|
export async function loadModelCatalog() {
|
|
9
|
-
const [profiles, { models: ggufModels, drafters }, managedModels
|
|
8
|
+
const [profiles, { models: ggufModels, drafters }, managedModels] = await Promise.all([
|
|
10
9
|
loadProfiles(),
|
|
11
10
|
scanGgufModels(),
|
|
12
11
|
scanManagedModels(),
|
|
13
|
-
scanMlxModels(),
|
|
14
12
|
]);
|
|
15
|
-
return normalizeCatalog({ profiles, ggufModels, drafters, managedModels
|
|
13
|
+
return normalizeCatalog({ profiles, ggufModels, drafters, managedModels });
|
|
16
14
|
}
|
|
17
15
|
|
|
18
16
|
export function normalizeCatalog(catalog) {
|
|
19
17
|
if (catalog.newModels && catalog.managedItems) return catalog;
|
|
20
|
-
const { profiles, ggufModels, drafters, managedModels
|
|
18
|
+
const { profiles, ggufModels, drafters, managedModels } = catalog;
|
|
21
19
|
const profiledPaths = new Set(profiles.map((profile) => profile.modelPath).filter(Boolean));
|
|
22
|
-
const newModels =
|
|
23
|
-
...ggufModels.filter((model) => !profiledPaths.has(model.path)),
|
|
24
|
-
...mlxModels.filter((model) => !profiledPaths.has(model.path)),
|
|
25
|
-
];
|
|
20
|
+
const newModels = ggufModels.filter((model) => !profiledPaths.has(model.path));
|
|
26
21
|
const managedItems = [];
|
|
27
22
|
for (const { backendId, models, status } of managedModels) {
|
|
28
23
|
if (status === "unavailable") continue;
|
|
@@ -35,9 +30,10 @@ export function normalizeCatalog(catalog) {
|
|
|
35
30
|
if (!profiledAliases.has(`${backendId}:${model.id}`)) managedItems.push({ model, backendId });
|
|
36
31
|
}
|
|
37
32
|
}
|
|
38
|
-
return { profiles, ggufModels, drafters, managedModels,
|
|
33
|
+
return { profiles, ggufModels, drafters, managedModels, newModels, managedItems };
|
|
39
34
|
}
|
|
40
35
|
|
|
36
|
+
|
|
41
37
|
export function itemKey(item) {
|
|
42
38
|
if (item.type === "profile") return `profile:${item.profile.id}`;
|
|
43
39
|
if (item.type === "new") return `new:${item.model.path}`;
|
|
@@ -57,12 +53,11 @@ function compareRecency(a, b) {
|
|
|
57
53
|
}
|
|
58
54
|
|
|
59
55
|
export function buildCatalogItems(normalized) {
|
|
60
|
-
const { profiles, newModels, managedItems, drafters, ggufModels = [],
|
|
56
|
+
const { profiles, newModels, managedItems, drafters, ggufModels = [], managedModels = [] } = normalized;
|
|
61
57
|
|
|
62
58
|
// Lookup maps for enriching profile items with scan data (size + context).
|
|
63
59
|
const scanByPath = new Map();
|
|
64
60
|
for (const m of ggufModels) scanByPath.set(m.path, m);
|
|
65
|
-
for (const m of mlxModels) scanByPath.set(m.filePath ?? m.path, m);
|
|
66
61
|
|
|
67
62
|
const managedByKey = new Map();
|
|
68
63
|
for (const { backendId, models } of managedModels) {
|
|
@@ -77,7 +72,7 @@ export function buildCatalogItems(normalized) {
|
|
|
77
72
|
if (profile.modelPath) {
|
|
78
73
|
const scanModel = scanByPath.get(profile.modelPath);
|
|
79
74
|
if (scanModel) {
|
|
80
|
-
item.label = scanModel.label;
|
|
75
|
+
item.label = scanModel.label;
|
|
81
76
|
if (scanModel.quant) quant = scanModel.quant;
|
|
82
77
|
}
|
|
83
78
|
}
|
|
@@ -160,4 +155,4 @@ export function createManagedProfile(model, backendId) {
|
|
|
160
155
|
modelSizeBytes: model.sizeBytes || 0,
|
|
161
156
|
...(backendId === "omlx" ? { omlxModel: model.id } : {}),
|
|
162
157
|
});
|
|
163
|
-
}
|
|
158
|
+
}
|
package/src/model-presenters.mjs
CHANGED
|
@@ -44,8 +44,6 @@ function optionSourceTag(sourceId) {
|
|
|
44
44
|
omlx: pc.magenta,
|
|
45
45
|
"llama.cpp": pc.cyan,
|
|
46
46
|
gguf: pc.cyan,
|
|
47
|
-
mlx: pc.yellow,
|
|
48
|
-
"mlx-vlm": pc.yellow,
|
|
49
47
|
};
|
|
50
48
|
return optionPad(label, colors[sourceId] ?? pc.dim, OPTION_SOURCE_WIDTH);
|
|
51
49
|
}
|
|
@@ -55,9 +53,7 @@ function optionBackendTag(backendId) {
|
|
|
55
53
|
const label = backend?.label ?? backendId ?? "unknown";
|
|
56
54
|
const colors = {
|
|
57
55
|
"llama-cpp": pc.cyan,
|
|
58
|
-
"llama-cpp-mtp": pc.blue,
|
|
59
56
|
omlx: pc.magenta,
|
|
60
|
-
"mlx-vlm": pc.yellow,
|
|
61
57
|
};
|
|
62
58
|
return optionPad(label, colors[backendId] ?? pc.dim, OPTION_BACKEND_WIDTH);
|
|
63
59
|
}
|
|
@@ -70,8 +66,6 @@ export function formatSourceLabel(sourceId) {
|
|
|
70
66
|
omlx: "oMLX",
|
|
71
67
|
"llama.cpp": "llama.cpp",
|
|
72
68
|
gguf: "GGUF file",
|
|
73
|
-
mlx: "MLX",
|
|
74
|
-
"mlx-vlm": "MLX",
|
|
75
69
|
};
|
|
76
70
|
return map[sourceId] ?? String(sourceId);
|
|
77
71
|
}
|
|
@@ -200,7 +194,6 @@ export function inferBackendId(item) {
|
|
|
200
194
|
if (item.type === "profile") return item.profile.backend;
|
|
201
195
|
if (item.type === "managed") return item.backendId;
|
|
202
196
|
// new model: derive from format
|
|
203
|
-
if (item.model?.format === "mlx") return "mlx-vlm";
|
|
204
197
|
if (item.model?.backend) return item.model.backend;
|
|
205
198
|
return "llama-cpp";
|
|
206
199
|
}
|
|
@@ -297,29 +290,6 @@ export function printGgufModelDetails(model, drafter) {
|
|
|
297
290
|
console.log("\n" + renderSectionRows("Model details", detailRows, { columns: Math.min(process.stdout.columns ?? 110, 140) }));
|
|
298
291
|
}
|
|
299
292
|
|
|
300
|
-
export async function printMlxModelDetails(model) {
|
|
301
|
-
const { detectMlxCapabilities } = await import("./mlx-discovery.mjs");
|
|
302
|
-
const caps = await detectMlxCapabilities(model.filePath ?? model.path);
|
|
303
|
-
const parts = [];
|
|
304
|
-
if (caps.architecture) parts.push(caps.architecture);
|
|
305
|
-
if (caps.thinking) parts.push("thinking");
|
|
306
|
-
if (caps.vision) parts.push("vision");
|
|
307
|
-
const summary = parts.length > 0 ? parts.join(pc.dim(" · ")) : "standard MLX";
|
|
308
|
-
console.log("\n" + renderSectionRows("Downloaded model", [
|
|
309
|
-
["Name", pc.bold(model.label)],
|
|
310
|
-
["Status", pc.yellow("Needs one-time setup")],
|
|
311
|
-
["Details", summary],
|
|
312
|
-
]));
|
|
313
|
-
console.log("\n" + renderSectionRows("Model details", [
|
|
314
|
-
["Model dir", model.path],
|
|
315
|
-
["Backend", "mlx-vlm"],
|
|
316
|
-
["Source", formatSourceLabel(model.source)],
|
|
317
|
-
["Detected", summary],
|
|
318
|
-
["Size", formatBytes(model.sizeBytes)],
|
|
319
|
-
["Context", caps.contextLength ? `${caps.contextLength.toLocaleString()} trained` : "unknown"],
|
|
320
|
-
], { columns: Math.min(process.stdout.columns ?? 110, 140) }));
|
|
321
|
-
}
|
|
322
|
-
|
|
323
293
|
export function printManagedModelDetails(model, backend) {
|
|
324
294
|
console.log("\n" + renderSectionRows(`${backend.label} model`, [
|
|
325
295
|
["Name", pc.bold(model.label)],
|