offgrid-ai 0.10.1 → 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.1",
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,19 +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
- contextLength: model.max_model_len ?? null,
106
- quant: null,
107
- family: null,
108
- backend: "omlx",
109
- source: "omlx",
110
- })).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));
111
118
  }
112
119
 
113
120
  // ── Labels ──────────────────────────────────────────────────────────────
@@ -291,4 +291,51 @@ export function defaultMlxContextLength(trainedCtx, ramGb) {
291
291
  if (ramGb < 16) return Math.min(trainedCtx, 8192);
292
292
  if (ramGb < 32) return Math.min(trainedCtx, 16384);
293
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;
294
341
  }
@@ -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,6 +101,9 @@ function discoverySourceForItem(item) {
101
101
  }
102
102
 
103
103
  function optionCtxLabel(item) {
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);
104
107
  if (item.contextLength) {
105
108
  return optionPad(`${(item.contextLength / 1000).toFixed(0)}k`, null, OPTION_CTX_WIDTH);
106
109
  }