offgrid-ai 0.10.0 → 0.10.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Privacy-first CLI for running local LLMs — discover, configure, run, benchmark",
5
5
  "author": "Eeshan Srivastava (https://eeshans.com)",
6
6
  "type": "module",
package/src/backends.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { findLlamaServer } from "./config.mjs";
2
2
  import { scanGgufModels } from "./scan.mjs";
3
3
  import { parseModelName } from "./model-name.mjs";
4
- import { scanMlxModels } from "./mlx-discovery.mjs";
4
+ import { scanMlxModels, scanOmlxModelSizes, lookupOmlxModelSize } from "./mlx-discovery.mjs";
5
5
  import { DEFAULT_PORT as MLX_VLM_PORT } from "./mlx-flags.mjs";
6
6
 
7
7
  // ── Backend definitions ────────────────────────────────────────────────────
@@ -95,18 +95,26 @@ async function scanOmlxModels() {
95
95
  }
96
96
  const body = await response.json();
97
97
  if (!Array.isArray(body?.data)) return [];
98
+
99
+ // The oMLX API doesn't return model sizes — look them up from disk.
100
+ const sizeMap = await scanOmlxModelSizes();
101
+
98
102
  return body.data
99
103
  .filter((model) => isChatOmlxModel(model))
100
- .map((model) => ({
101
- id: model.id,
102
- label: parseModelName(model.id, "omlx").display,
103
- aliasSuggestion: model.id,
104
- sizeBytes: model.size ?? 0,
105
- quant: null,
106
- family: null,
107
- backend: "omlx",
108
- source: "omlx",
109
- })).sort((a, b) => a.label.localeCompare(b.label));
104
+ .map((model) => {
105
+ const sizeFromDisk = lookupOmlxModelSize(model.id, sizeMap);
106
+ return {
107
+ id: model.id,
108
+ label: parseModelName(model.id, "omlx").display,
109
+ aliasSuggestion: model.id,
110
+ sizeBytes: sizeFromDisk ?? (model.size ?? 0),
111
+ contextLength: model.max_model_len ?? null,
112
+ quant: null,
113
+ family: null,
114
+ backend: "omlx",
115
+ source: "omlx",
116
+ };
117
+ }).sort((a, b) => a.label.localeCompare(b.label));
110
118
  }
111
119
 
112
120
  // ── Labels ──────────────────────────────────────────────────────────────
@@ -1,6 +1,7 @@
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";
@@ -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,7 +58,7 @@ 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) {
@@ -74,7 +74,7 @@ export async function modelCommandCenter(initialCatalog) {
74
74
  const bucket = grouped.get(group);
75
75
  if (!bucket || bucket.length === 0) continue;
76
76
  for (const item of bucket) {
77
- const opt = modelSelectOption(item, { runningProfilesNow, modelMissingIds, nameWidth, managedModels: catalog.managedModels });
77
+ const opt = modelSelectOption(item, { runningProfilesNow, modelMissingIds, nameWidth });
78
78
  choices.push({ value: opt.value, label: opt.label, hint: opt.hint });
79
79
  }
80
80
  }
@@ -80,7 +80,8 @@ async function scanDirRecursiveForMlx(rootDir, sourceLabel, maxDepth = 3) {
80
80
  const sizeBytes = await getMlxDirSizeBytes(dir);
81
81
  if (sizeBytes < MIN_MODEL_SIZE_BYTES) return;
82
82
  if (await isEmbeddingMlxModel(join(dir, "config.json"))) return;
83
- models.push(makeMlxModel(dir, basename(dir), sizeBytes, sourceLabel, rootDir));
83
+ const caps = await detectMlxCapabilities(dir);
84
+ models.push(makeMlxModel(dir, basename(dir), sizeBytes, sourceLabel, rootDir, caps.contextLength));
84
85
  return;
85
86
  }
86
87
 
@@ -92,7 +93,8 @@ async function scanDirRecursiveForMlx(rootDir, sourceLabel, maxDepth = 3) {
92
93
  const sizeBytes = await getMlxDirSizeBytes(fullPath);
93
94
  if (sizeBytes < MIN_MODEL_SIZE_BYTES) continue;
94
95
  if (await isEmbeddingMlxModel(join(fullPath, "config.json"))) continue;
95
- models.push(makeMlxModel(fullPath, entry.name, sizeBytes, sourceLabel, rootDir));
96
+ const caps = await detectMlxCapabilities(fullPath);
97
+ models.push(makeMlxModel(fullPath, entry.name, sizeBytes, sourceLabel, rootDir, caps.contextLength));
96
98
  } else {
97
99
  await walk(fullPath, depth + 1);
98
100
  }
@@ -155,6 +157,7 @@ async function scanHfHubForMlx(dir, sourceLabel) {
155
157
  path: snapshotPath,
156
158
  filePath: snapshotPath,
157
159
  sizeBytes,
160
+ contextLength: (await detectMlxCapabilities(snapshotPath)).contextLength,
158
161
  backend: "mlx-vlm",
159
162
  format: "mlx",
160
163
  source: sourceLabel,
@@ -185,13 +188,14 @@ async function isEmbeddingMlxModel(configPath) {
185
188
 
186
189
  // ── MLX model entry builder ───────────────────────────────────────────────
187
190
 
188
- function makeMlxModel(dir, label, sizeBytes, sourceLabel, rootDir) {
191
+ function makeMlxModel(dir, label, sizeBytes, sourceLabel, rootDir, contextLength = null) {
189
192
  return {
190
193
  id: `${sourceLabel}:${dir.replace(rootDir + "/", "")}`,
191
194
  label,
192
195
  path: dir,
193
196
  filePath: dir,
194
197
  sizeBytes,
198
+ contextLength,
195
199
  backend: "mlx-vlm",
196
200
  format: "mlx",
197
201
  source: sourceLabel,
@@ -287,4 +291,51 @@ export function defaultMlxContextLength(trainedCtx, ramGb) {
287
291
  if (ramGb < 16) return Math.min(trainedCtx, 8192);
288
292
  if (ramGb < 32) return Math.min(trainedCtx, 16384);
289
293
  return trainedCtx;
294
+ }
295
+
296
+ // ── oMLX model size lookup (from disk) ────────────────────────────────────
297
+
298
+ /**
299
+ * Scan the oMLX models directory (~/.omlx/models/) for MLX model directories
300
+ * and return a Map of basename → sizeBytes. The oMLX API doesn't return model
301
+ * sizes, so we compute them from the safetensors files on disk.
302
+ */
303
+ export async function scanOmlxModelSizes() {
304
+ if (!existsSync(OMLX_MODELS_DIR)) return new Map();
305
+ const sizeByBasename = new Map();
306
+
307
+ async function walk(dir) {
308
+ let entries;
309
+ try {
310
+ entries = await readdir(dir, { withFileTypes: true });
311
+ } catch {
312
+ return;
313
+ }
314
+ for (const entry of entries) {
315
+ if (!entry.isDirectory()) continue;
316
+ const fullPath = join(dir, entry.name);
317
+ if (await isMlxModelDir(fullPath)) {
318
+ const sizeBytes = await getMlxDirSizeBytes(fullPath);
319
+ if (sizeBytes > 0) sizeByBasename.set(entry.name, sizeBytes);
320
+ } else {
321
+ await walk(fullPath);
322
+ }
323
+ }
324
+ }
325
+
326
+ await walk(OMLX_MODELS_DIR);
327
+ return sizeByBasename;
328
+ }
329
+
330
+ /**
331
+ * Look up a model's size by its oMLX API id. Tries exact match, then the
332
+ * segment after `--` (oMLX org--name format), then after `/` (HF format).
333
+ */
334
+ export function lookupOmlxModelSize(modelId, sizeMap) {
335
+ if (sizeMap.has(modelId)) return sizeMap.get(modelId);
336
+ const dashIdx = modelId.indexOf("--");
337
+ if (dashIdx >= 0 && sizeMap.has(modelId.slice(dashIdx + 2))) return sizeMap.get(modelId.slice(dashIdx + 2));
338
+ const slashIdx = modelId.indexOf("/");
339
+ if (slashIdx >= 0 && sizeMap.has(modelId.slice(slashIdx + 1))) return sizeMap.get(modelId.slice(slashIdx + 1));
340
+ return null;
290
341
  }
@@ -3,6 +3,7 @@ import { loadProfiles, normalizeProfile, sanitizeProfileId } from "./profiles.mj
3
3
  import { scanManagedModels } from "./managed.mjs";
4
4
  import { scanMlxModels } from "./mlx-discovery.mjs";
5
5
  import { isProfileFileMissing } from "./model-summary.mjs";
6
+ import { backendFor } from "./backends.mjs";
6
7
 
7
8
  export async function loadModelCatalog() {
8
9
  const [profiles, { models: ggufModels, drafters }, managedModels, mlxModels] = await Promise.all([
@@ -34,7 +35,7 @@ export function normalizeCatalog(catalog) {
34
35
  if (!profiledAliases.has(`${backendId}:${model.id}`)) managedItems.push({ model, backendId });
35
36
  }
36
37
  }
37
- return { profiles, ggufModels, drafters, managedModels, newModels, managedItems };
38
+ return { profiles, ggufModels, drafters, managedModels, mlxModels, newModels, managedItems };
38
39
  }
39
40
 
40
41
  export function itemKey(item) {
@@ -56,13 +57,73 @@ function compareRecency(a, b) {
56
57
  }
57
58
 
58
59
  export function buildCatalogItems(normalized) {
59
- const { profiles, newModels, managedItems, drafters } = normalized;
60
- const profileItems = profiles.map((profile) => ({ type: "profile", profile, label: profile.label, fileMissing: isProfileFileMissing(profile) }));
60
+ const { profiles, newModels, managedItems, drafters, ggufModels = [], mlxModels = [], managedModels = [] } = normalized;
61
+
62
+ // Lookup maps for enriching profile items with scan data (size + context).
63
+ const scanByPath = new Map();
64
+ for (const m of ggufModels) scanByPath.set(m.path, m);
65
+ for (const m of mlxModels) scanByPath.set(m.filePath ?? m.path, m);
66
+
67
+ const managedByKey = new Map();
68
+ for (const { backendId, models } of managedModels) {
69
+ for (const m of models) managedByKey.set(`${backendId}:${m.id}`, m);
70
+ }
71
+
72
+ const profileItems = profiles.map((profile) => {
73
+ const item = { type: "profile", profile, label: profile.label, fileMissing: isProfileFileMissing(profile) };
74
+
75
+ // Resolve size: profile.modelSizeBytes → scan lookup → managed lookup
76
+ let sizeBytes = profile.modelSizeBytes || 0;
77
+ if (!sizeBytes && profile.modelPath) {
78
+ const scanModel = scanByPath.get(profile.modelPath);
79
+ if (scanModel?.sizeBytes) sizeBytes = scanModel.sizeBytes;
80
+ }
81
+ if (!sizeBytes) {
82
+ const backend = backendFor(profile.backend);
83
+ if (backend.type === "managed-server" && profile.omlxModel) {
84
+ const managedModel = managedByKey.get(`${profile.backend}:${profile.omlxModel}`);
85
+ if (managedModel?.sizeBytes) sizeBytes = managedModel.sizeBytes;
86
+ }
87
+ }
88
+ item.sizeBytes = sizeBytes || null;
89
+
90
+ // Resolve context: flags.ctxSize (configured) → capabilities.ctxSize (trained) → scan → managed
91
+ let contextLength = profile.flags?.ctxSize ?? null;
92
+ if (!contextLength) contextLength = profile.capabilities?.ctxSize ?? null;
93
+ if (!contextLength && profile.modelPath) {
94
+ const scanModel = scanByPath.get(profile.modelPath);
95
+ if (scanModel?.contextLength) contextLength = scanModel.contextLength;
96
+ }
97
+ if (!contextLength) {
98
+ const backend = backendFor(profile.backend);
99
+ if (backend.type === "managed-server" && profile.omlxModel) {
100
+ const managedModel = managedByKey.get(`${profile.backend}:${profile.omlxModel}`);
101
+ if (managedModel?.contextLength) contextLength = managedModel.contextLength;
102
+ }
103
+ }
104
+ item.contextLength = contextLength;
105
+
106
+ return item;
107
+ });
61
108
  profileItems.sort(compareRecency);
62
109
  return [
63
110
  ...profileItems,
64
- ...newModels.map((model) => ({ type: "new", model, label: model.label, drafter: matchDrafter(model.path, drafters) })),
65
- ...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label })),
111
+ ...newModels.map((model) => ({
112
+ type: "new",
113
+ model,
114
+ label: model.label,
115
+ drafter: matchDrafter(model.path, drafters),
116
+ sizeBytes: model.sizeBytes || null,
117
+ contextLength: model.contextLength ?? null,
118
+ })),
119
+ ...managedItems.map(({ model, backendId }) => ({
120
+ type: "managed",
121
+ model,
122
+ backendId,
123
+ label: model.label,
124
+ sizeBytes: model.sizeBytes || null,
125
+ contextLength: model.contextLength ?? null,
126
+ })),
66
127
  ];
67
128
  }
68
129
 
@@ -11,7 +11,7 @@ import { DATA_DIR } from "./config.mjs";
11
11
  import { findBenchmarkRepo } from "./benchmark.mjs";
12
12
 
13
13
  const OPTION_SEPARATOR = pc.dim(" │ ");
14
- const OPTION_STATUS_WIDTH = 10;
14
+ const OPTION_STATUS_WIDTH = 12;
15
15
  const OPTION_BACKEND_WIDTH = 14;
16
16
  const OPTION_SOURCE_WIDTH = 14;
17
17
  const OPTION_CTX_WIDTH = 5;
@@ -30,7 +30,7 @@ function optionStatusTag(kind) {
30
30
  serverup: ["READY", pc.blue],
31
31
  ready: ["READY", pc.blue],
32
32
  missing: ["MISSING", pc.red],
33
- setup: ["SETUP", pc.yellow],
33
+ setup: ["NEEDS SETUP", pc.yellow],
34
34
  };
35
35
  const [text, color] = statuses[kind] ?? [kind, pc.dim];
36
36
  return optionPad(text, color, OPTION_STATUS_WIDTH);
@@ -101,32 +101,18 @@ function discoverySourceForItem(item) {
101
101
  }
102
102
 
103
103
  function optionCtxLabel(item) {
104
- if (item.type === "profile" && item.profile.flags?.ctxSize) {
105
- return optionPad(`${(item.profile.flags.ctxSize / 1000).toFixed(0)}k`, null, OPTION_CTX_WIDTH);
104
+ // Context window is a configured value — only profiles (READY/RUNNING)
105
+ // have one. SETUP items (new/managed) show "—".
106
+ if (item.type !== "profile") return optionPad("—", null, OPTION_CTX_WIDTH);
107
+ if (item.contextLength) {
108
+ return optionPad(`${(item.contextLength / 1000).toFixed(0)}k`, null, OPTION_CTX_WIDTH);
106
109
  }
107
110
  return optionPad("—", null, OPTION_CTX_WIDTH);
108
111
  }
109
112
 
110
- function optionSizeLabel(item, managedModels) {
111
- if (item.type === "profile") {
112
- if (item.fileMissing) return "—";
113
- if (item.profile.modelSizeBytes) return formatBytes(item.profile.modelSizeBytes);
114
- if (item.profile.modelPath && existsSync(item.profile.modelPath)) {
115
- const s = statSync(item.profile.modelPath);
116
- // Only stat regular files — a modelPath that is a directory (MLX)
117
- // reports the dir entry size, not the model size.
118
- if (s.isFile()) return formatBytes(s.size);
119
- }
120
- const managedSize = managedProfileSizeBytes(item.profile, managedModels);
121
- if (managedSize) return formatBytes(managedSize);
122
- return "—";
123
- }
124
- if (item.type === "new") {
125
- return formatBytes(item.model.sizeBytes);
126
- }
127
- // managed
128
- if (item.model.sizeBytes) return formatBytes(item.model.sizeBytes);
129
- if (item.model.quant) return item.model.quant;
113
+ function optionSizeLabel(item) {
114
+ if (item.type === "profile" && item.fileMissing) return "—";
115
+ if (item.sizeBytes) return formatBytes(item.sizeBytes);
130
116
  return "—";
131
117
  }
132
118
 
@@ -141,7 +127,7 @@ function optionLabel({ status, backend, source, name, ctx, size, nameWidth }) {
141
127
  return [status, backend, source, pc.bold(optionPad(name, null, nameWidth)), ctx, pc.dim(size)].join(OPTION_SEPARATOR);
142
128
  }
143
129
 
144
- export function modelSelectOption(item, { runningProfilesNow, modelMissingIds, nameWidth, managedModels }) {
130
+ export function modelSelectOption(item, { runningProfilesNow, modelMissingIds, nameWidth }) {
145
131
  const sourceId = discoverySourceForItem(item) ?? "unknown";
146
132
  const backendId = inferBackendId(item);
147
133
  if (item.type === "profile") {
@@ -162,7 +148,7 @@ export function modelSelectOption(item, { runningProfilesNow, modelMissingIds, n
162
148
  name: item.profile.label,
163
149
  nameWidth,
164
150
  ctx: optionCtxLabel(item),
165
- size: optionSizeLabel(item, managedModels),
151
+ size: optionSizeLabel(item),
166
152
  }),
167
153
  ...(hint ? { hint: pc.red(hint) } : {}),
168
154
  };
@@ -177,7 +163,7 @@ export function modelSelectOption(item, { runningProfilesNow, modelMissingIds, n
177
163
  name: item.model.label,
178
164
  nameWidth,
179
165
  ctx: optionCtxLabel(item),
180
- size: optionSizeLabel(item, managedModels),
166
+ size: optionSizeLabel(item),
181
167
  }),
182
168
  };
183
169
  }
@@ -190,22 +176,11 @@ export function modelSelectOption(item, { runningProfilesNow, modelMissingIds, n
190
176
  name: item.model.label,
191
177
  nameWidth,
192
178
  ctx: optionCtxLabel(item),
193
- size: optionSizeLabel(item, managedModels),
179
+ size: optionSizeLabel(item),
194
180
  }),
195
181
  };
196
182
  }
197
183
 
198
- function managedProfileSizeBytes(profile, managedModels) {
199
- if (!managedModels || !Array.isArray(managedModels)) return null;
200
- const backend = backendFor(profile.backend);
201
- if (backend.type !== "managed-server") return null;
202
- const backendModels = managedModels.find((m) => m.backendId === profile.backend)?.models ?? [];
203
- const modelId = profile.omlxModel ?? null;
204
- if (!modelId) return null;
205
- const model = backendModels.find((m) => m.id === modelId);
206
- return model?.sizeBytes || null;
207
- }
208
-
209
184
  function inferBackendId(item) {
210
185
  if (item.type === "profile") return item.profile.backend;
211
186
  if (item.type === "managed") return item.backendId;
@@ -266,7 +241,7 @@ export async function printProfileDetails(profile) {
266
241
  detailRows.push(
267
242
  ["Local file", fileMissing ? pc.red(`${profile.modelPath} (not found)`) : profile.modelPath ?? "unknown"],
268
243
  ["Vision file", profile.mmprojPath ? (existsSync(profile.mmprojPath) ? profile.mmprojPath : pc.red(`${profile.mmprojPath} (not found)`)) : "none"],
269
- ["Model size", profile.modelPath && existsSync(profile.modelPath) ? formatBytes(statSync(profile.modelPath).size) : "unknown"],
244
+ ["Model size", profile.modelSizeBytes ? formatBytes(profile.modelSizeBytes) : (profile.modelPath && existsSync(profile.modelPath) && statSync(profile.modelPath).isFile() ? formatBytes(statSync(profile.modelPath).size) : "unknown")],
270
245
  );
271
246
  if (profile.drafterPath) {
272
247
  detailRows.push(["Drafter", existsSync(profile.drafterPath) ? profile.drafterPath : pc.red(`${profile.drafterPath} (not found)`)]);
package/src/scan.mjs CHANGED
@@ -57,6 +57,9 @@ async function scanOneDir(root, sourceLabel = "local-gguf") {
57
57
  // Read GGUF metadata to detect drafter architecture and embeddings
58
58
  const meta = safeReadGgufMetadata(path);
59
59
  const architecture = typeof meta["general.architecture"] === "string" ? meta["general.architecture"] : null;
60
+ const contextLength = architecture && typeof meta[`${architecture}.context_length`] === "number"
61
+ ? meta[`${architecture}.context_length`]
62
+ : null;
60
63
 
61
64
  if (isEmbeddingArchitecture(architecture, name)) continue;
62
65
 
@@ -81,6 +84,7 @@ async function scanOneDir(root, sourceLabel = "local-gguf") {
81
84
  aliasSuggestion: parsed.id,
82
85
  quant: parsed.quant,
83
86
  sizeBytes,
87
+ contextLength,
84
88
  backend: "llama-cpp",
85
89
  source: sourceLabel,
86
90
  });