offgrid-ai 0.10.2 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.10.2",
3
+ "version": "0.11.1",
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, scanOmlxModelSizes, lookupOmlxModelSize } from "./mlx-discovery.mjs";
4
+ import { scanMlxModels, scanOmlxModelSizes, lookupOmlxModelInfo } from "./mlx-discovery.mjs";
5
5
  import { DEFAULT_PORT as MLX_VLM_PORT } from "./mlx-flags.mjs";
6
6
 
7
7
  // ── Backend definitions ────────────────────────────────────────────────────
@@ -96,20 +96,25 @@ async function scanOmlxModels() {
96
96
  const body = await response.json();
97
97
  if (!Array.isArray(body?.data)) return [];
98
98
 
99
- // The oMLX API doesn't return model sizes — look them up from disk.
100
- const sizeMap = await scanOmlxModelSizes();
99
+ // The oMLX API doesn't return model sizes or publishers — look them up from disk.
100
+ const infoMap = await scanOmlxModelSizes();
101
101
 
102
102
  return body.data
103
103
  .filter((model) => isChatOmlxModel(model))
104
104
  .map((model) => {
105
- const sizeFromDisk = lookupOmlxModelSize(model.id, sizeMap);
105
+ const info = lookupOmlxModelInfo(model.id, infoMap);
106
+ // If the API ID doesn't already include a publisher (no / or --),
107
+ // prepend the publisher found on disk.
108
+ const hasPublisher = model.id.includes("/") || model.id.includes("--");
109
+ const fullName = (!hasPublisher && info?.publisher) ? `${info.publisher}/${model.id}` : model.id;
110
+ const parsed = parseModelName(fullName, "omlx");
106
111
  return {
107
112
  id: model.id,
108
- label: parseModelName(model.id, "omlx").display,
113
+ label: parsed.display,
109
114
  aliasSuggestion: model.id,
110
- sizeBytes: sizeFromDisk ?? (model.size ?? 0),
115
+ sizeBytes: info?.sizeBytes ?? (model.size ?? 0),
111
116
  contextLength: model.max_model_len ?? null,
112
- quant: null,
117
+ quant: parsed.quant,
113
118
  family: null,
114
119
  backend: "omlx",
115
120
  source: "omlx",
@@ -16,6 +16,7 @@ import { join, basename } from "node:path";
16
16
  import { homedir } from "node:os";
17
17
  import { getModelScanDirs } from "./config.mjs";
18
18
  import { inferSourceLabel, MIN_MODEL_SIZE_BYTES, EMBEDDING_MODEL_TYPES } from "./discovery-shared.mjs";
19
+ import { parseModelName } from "./model-name.mjs";
19
20
 
20
21
  // ── Folder → backend mapping ──────────────────────────────────────────────
21
22
  // The oMLX folder is oMLX-exclusive: models there are served by the oMLX
@@ -81,7 +82,8 @@ async function scanDirRecursiveForMlx(rootDir, sourceLabel, maxDepth = 3) {
81
82
  if (sizeBytes < MIN_MODEL_SIZE_BYTES) return;
82
83
  if (await isEmbeddingMlxModel(join(dir, "config.json"))) return;
83
84
  const caps = await detectMlxCapabilities(dir);
84
- models.push(makeMlxModel(dir, basename(dir), sizeBytes, sourceLabel, rootDir, caps.contextLength));
85
+ const { display, quant } = parseModelName(basename(dir), sourceLabel);
86
+ models.push(makeMlxModel(dir, display, sizeBytes, sourceLabel, rootDir, caps.contextLength, quant));
85
87
  return;
86
88
  }
87
89
 
@@ -94,7 +96,12 @@ async function scanDirRecursiveForMlx(rootDir, sourceLabel, maxDepth = 3) {
94
96
  if (sizeBytes < MIN_MODEL_SIZE_BYTES) continue;
95
97
  if (await isEmbeddingMlxModel(join(fullPath, "config.json"))) continue;
96
98
  const caps = await detectMlxCapabilities(fullPath);
97
- models.push(makeMlxModel(fullPath, entry.name, sizeBytes, sourceLabel, rootDir, caps.contextLength));
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));
98
105
  } else {
99
106
  await walk(fullPath, depth + 1);
100
107
  }
@@ -151,13 +158,16 @@ async function scanHfHubForMlx(dir, sourceLabel) {
151
158
  const sizeBytes = await getMlxDirSizeBytes(snapshotPath);
152
159
  if (sizeBytes < MIN_MODEL_SIZE_BYTES) continue;
153
160
  if (await isEmbeddingMlxModel(join(snapshotPath, "config.json"))) continue;
161
+ const caps = await detectMlxCapabilities(snapshotPath);
162
+ const { display, quant } = parseModelName(label, sourceLabel);
154
163
  models.push({
155
164
  id: `${sourceLabel}:${entry.name}`,
156
- label,
165
+ label: display,
157
166
  path: snapshotPath,
158
167
  filePath: snapshotPath,
159
168
  sizeBytes,
160
- contextLength: (await detectMlxCapabilities(snapshotPath)).contextLength,
169
+ contextLength: caps.contextLength,
170
+ quant,
161
171
  backend: "mlx-vlm",
162
172
  format: "mlx",
163
173
  source: sourceLabel,
@@ -188,7 +198,7 @@ async function isEmbeddingMlxModel(configPath) {
188
198
 
189
199
  // ── MLX model entry builder ───────────────────────────────────────────────
190
200
 
191
- function makeMlxModel(dir, label, sizeBytes, sourceLabel, rootDir, contextLength = null) {
201
+ function makeMlxModel(dir, label, sizeBytes, sourceLabel, rootDir, contextLength = null, quant = null) {
192
202
  return {
193
203
  id: `${sourceLabel}:${dir.replace(rootDir + "/", "")}`,
194
204
  label,
@@ -196,6 +206,7 @@ function makeMlxModel(dir, label, sizeBytes, sourceLabel, rootDir, contextLength
196
206
  filePath: dir,
197
207
  sizeBytes,
198
208
  contextLength,
209
+ quant,
199
210
  backend: "mlx-vlm",
200
211
  format: "mlx",
201
212
  source: sourceLabel,
@@ -297,14 +308,14 @@ export function defaultMlxContextLength(trainedCtx, ramGb) {
297
308
 
298
309
  /**
299
310
  * 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.
311
+ * and return a Map of basename → { sizeBytes, publisher }. The oMLX API
312
+ * doesn't return model sizes or publishers, so we compute them from disk.
302
313
  */
303
314
  export async function scanOmlxModelSizes() {
304
315
  if (!existsSync(OMLX_MODELS_DIR)) return new Map();
305
- const sizeByBasename = new Map();
316
+ const infoByBasename = new Map();
306
317
 
307
- async function walk(dir) {
318
+ async function walk(dir, publisher) {
308
319
  let entries;
309
320
  try {
310
321
  entries = await readdir(dir, { withFileTypes: true });
@@ -316,26 +327,27 @@ export async function scanOmlxModelSizes() {
316
327
  const fullPath = join(dir, entry.name);
317
328
  if (await isMlxModelDir(fullPath)) {
318
329
  const sizeBytes = await getMlxDirSizeBytes(fullPath);
319
- if (sizeBytes > 0) sizeByBasename.set(entry.name, sizeBytes);
330
+ if (sizeBytes > 0) infoByBasename.set(entry.name, { sizeBytes, publisher });
320
331
  } else {
321
- await walk(fullPath);
332
+ // First-level directories under ~/.omlx/models/ are publishers
333
+ await walk(fullPath, publisher ?? entry.name);
322
334
  }
323
335
  }
324
336
  }
325
337
 
326
- await walk(OMLX_MODELS_DIR);
327
- return sizeByBasename;
338
+ await walk(OMLX_MODELS_DIR, null);
339
+ return infoByBasename;
328
340
  }
329
341
 
330
342
  /**
331
- * Look up a model's size by its oMLX API id. Tries exact match, then the
343
+ * Look up a model's info by its oMLX API id. Tries exact match, then the
332
344
  * segment after `--` (oMLX org--name format), then after `/` (HF format).
333
345
  */
334
- export function lookupOmlxModelSize(modelId, sizeMap) {
335
- if (sizeMap.has(modelId)) return sizeMap.get(modelId);
346
+ export function lookupOmlxModelInfo(modelId, infoMap) {
347
+ if (infoMap.has(modelId)) return infoMap.get(modelId);
336
348
  const dashIdx = modelId.indexOf("--");
337
- if (dashIdx >= 0 && sizeMap.has(modelId.slice(dashIdx + 2))) return sizeMap.get(modelId.slice(dashIdx + 2));
349
+ if (dashIdx >= 0 && infoMap.has(modelId.slice(dashIdx + 2))) return infoMap.get(modelId.slice(dashIdx + 2));
338
350
  const slashIdx = modelId.indexOf("/");
339
- if (slashIdx >= 0 && sizeMap.has(modelId.slice(slashIdx + 1))) return sizeMap.get(modelId.slice(slashIdx + 1));
351
+ if (slashIdx >= 0 && infoMap.has(modelId.slice(slashIdx + 1))) return infoMap.get(modelId.slice(slashIdx + 1));
340
352
  return null;
341
353
  }
@@ -72,6 +72,27 @@ export function buildCatalogItems(normalized) {
72
72
  const profileItems = profiles.map((profile) => {
73
73
  const item = { type: "profile", profile, label: profile.label, fileMissing: isProfileFileMissing(profile) };
74
74
 
75
+ // Resolve label + quant from scan data (re-parse for consistency)
76
+ let quant = profile.capabilities?.quant ?? null;
77
+ if (profile.modelPath) {
78
+ const scanModel = scanByPath.get(profile.modelPath);
79
+ if (scanModel) {
80
+ item.label = scanModel.label; // re-parsed label (publisher/model-name)
81
+ if (scanModel.quant) quant = scanModel.quant;
82
+ }
83
+ }
84
+ if (!quant) {
85
+ const backend = backendFor(profile.backend);
86
+ if (backend.type === "managed-server" && profile.omlxModel) {
87
+ const managedModel = managedByKey.get(`${profile.backend}:${profile.omlxModel}`);
88
+ if (managedModel) {
89
+ item.label = managedModel.label;
90
+ if (managedModel.quant) quant = managedModel.quant;
91
+ }
92
+ }
93
+ }
94
+ item.quant = quant;
95
+
75
96
  // Resolve size: profile.modelSizeBytes → scan lookup → managed lookup
76
97
  let sizeBytes = profile.modelSizeBytes || 0;
77
98
  if (!sizeBytes && profile.modelPath) {
@@ -115,6 +136,7 @@ export function buildCatalogItems(normalized) {
115
136
  drafter: matchDrafter(model.path, drafters),
116
137
  sizeBytes: model.sizeBytes || null,
117
138
  contextLength: model.contextLength ?? null,
139
+ quant: model.quant ?? null,
118
140
  })),
119
141
  ...managedItems.map(({ model, backendId }) => ({
120
142
  type: "managed",
@@ -123,6 +145,7 @@ export function buildCatalogItems(normalized) {
123
145
  label: model.label,
124
146
  sizeBytes: model.sizeBytes || null,
125
147
  contextLength: model.contextLength ?? null,
148
+ quant: model.quant ?? null,
126
149
  })),
127
150
  ];
128
151
  }
@@ -54,6 +54,7 @@ const QUANT_PATTERNS = [
54
54
  /[-_]Q\d_[01]/i,
55
55
  /[-_]F(?:16|32)/i,
56
56
  /[-_]BF16/i,
57
+ /[-_]\d+bit\b/i,
57
58
  ];
58
59
 
59
60
  // ── Tag tokens extracted from the name ──────────────────────────────────
@@ -77,13 +78,20 @@ const TAG_TOKENS = [
77
78
  export function parseModelName(rawId, source) {
78
79
  const id = rawId; // never modify the raw id
79
80
 
80
- // 1. Extract publisher (anything before the first /)
81
+ // 1. Extract publisher (anything before the first /, or -- for oMLX)
81
82
  let publisher = null;
82
83
  let name = rawId;
83
84
  const slashIdx = rawId.indexOf("/");
84
85
  if (slashIdx !== -1) {
85
86
  publisher = rawId.slice(0, slashIdx);
86
87
  name = rawId.slice(slashIdx + 1);
88
+ } else if (source === "omlx") {
89
+ // oMLX uses org--name format
90
+ const dashIdx = rawId.indexOf("--");
91
+ if (dashIdx !== -1) {
92
+ publisher = rawId.slice(0, dashIdx);
93
+ name = rawId.slice(dashIdx + 2);
94
+ }
87
95
  }
88
96
 
89
97
  // 2. Extract quant (GGUF quantization suffix)
@@ -125,7 +133,7 @@ export function parseModelName(rawId, source) {
125
133
  const params = extractParams(model);
126
134
 
127
135
  // 7. Build display string
128
- const display = buildDisplay(publisher, model, tags, quant);
136
+ const display = buildDisplay(publisher, model, tags);
129
137
 
130
138
  // 8. Build sort key (lowercase, no publisher, for alphabetical ordering)
131
139
  const sort = model.toLowerCase().replace(/[-_]/g, " ");
@@ -135,20 +143,12 @@ export function parseModelName(rawId, source) {
135
143
 
136
144
  // ── Display builder ────────────────────────────────────────────────────
137
145
 
138
- function buildDisplay(publisher, model, tags, quant) {
139
- const parts = [];
140
- if (publisher) {
141
- parts.push(publisher);
142
- }
146
+ function buildDisplay(publisher, model, tags) {
143
147
  let modelPart = model;
144
148
  if (tags.length > 0) {
145
149
  modelPart += ` (${tags.join(", ")})`;
146
150
  }
147
- parts.push(modelPart);
148
- if (quant) {
149
- parts.push(quant);
150
- }
151
- return parts.join(" › ");
151
+ return publisher ? `${publisher}/${modelPart}` : modelPart;
152
152
  }
153
153
 
154
154
  // ── Params extraction ──────────────────────────────────────────────────
@@ -195,6 +195,9 @@ function titleCaseModel(name) {
195
195
  // Title-case standalone aXb patterns (A3B, A12B — active parameters)
196
196
  result = result.replace(/\ba(\d+)\s*b\b/gi, (_, num) => `A${num}B`);
197
197
 
198
+ // Title-case eXb patterns (E2B, E12B — effective parameters)
199
+ result = result.replace(/\be(\d+)\s*b\b/gi, (_, num) => `E${num}B`);
200
+
198
201
  // Clean up extra spaces
199
202
  result = result.replace(/\s{2,}/g, " ").trim();
200
203
 
@@ -14,6 +14,7 @@ const OPTION_SEPARATOR = pc.dim(" │ ");
14
14
  const OPTION_STATUS_WIDTH = 12;
15
15
  const OPTION_BACKEND_WIDTH = 14;
16
16
  const OPTION_SOURCE_WIDTH = 14;
17
+ const OPTION_QUANT_WIDTH = 10;
17
18
  const OPTION_CTX_WIDTH = 5;
18
19
 
19
20
  const { stripVTControlCharacters } = await import("node:util");
@@ -100,6 +101,11 @@ function discoverySourceForItem(item) {
100
101
  return item.model?.source ?? null;
101
102
  }
102
103
 
104
+ function optionQuantLabel(item) {
105
+ if (item.quant) return optionPad(item.quant, null, OPTION_QUANT_WIDTH);
106
+ return optionPad("—", null, OPTION_QUANT_WIDTH);
107
+ }
108
+
103
109
  function optionCtxLabel(item) {
104
110
  // Context window is a configured value — only profiles (READY/RUNNING)
105
111
  // have one. SETUP items (new/managed) show "—".
@@ -123,8 +129,8 @@ export function modelNameWidth(items) {
123
129
  return Math.max(20, maxName + 2);
124
130
  }
125
131
 
126
- function optionLabel({ status, backend, source, name, ctx, size, nameWidth }) {
127
- return [status, backend, source, pc.bold(optionPad(name, null, nameWidth)), ctx, pc.dim(size)].join(OPTION_SEPARATOR);
132
+ function optionLabel({ status, backend, source, name, quant, ctx, size, nameWidth }) {
133
+ return [status, backend, source, pc.bold(optionPad(name, null, nameWidth)), quant, ctx, pc.dim(size)].join(OPTION_SEPARATOR);
128
134
  }
129
135
 
130
136
  export function modelSelectOption(item, { runningProfilesNow, modelMissingIds, nameWidth }) {
@@ -145,8 +151,9 @@ export function modelSelectOption(item, { runningProfilesNow, modelMissingIds, n
145
151
  status: optionStatusTag(status),
146
152
  backend: optionBackendTag(backendId),
147
153
  source: optionSourceTag(sourceId),
148
- name: item.profile.label,
154
+ name: item.label,
149
155
  nameWidth,
156
+ quant: optionQuantLabel(item),
150
157
  ctx: optionCtxLabel(item),
151
158
  size: optionSizeLabel(item),
152
159
  }),
@@ -160,8 +167,9 @@ export function modelSelectOption(item, { runningProfilesNow, modelMissingIds, n
160
167
  status: optionStatusTag("setup"),
161
168
  backend: optionBackendTag(backendId),
162
169
  source: optionSourceTag(sourceId),
163
- name: item.model.label,
170
+ name: item.label,
164
171
  nameWidth,
172
+ quant: optionQuantLabel(item),
165
173
  ctx: optionCtxLabel(item),
166
174
  size: optionSizeLabel(item),
167
175
  }),
@@ -173,8 +181,9 @@ export function modelSelectOption(item, { runningProfilesNow, modelMissingIds, n
173
181
  status: optionStatusTag("setup"),
174
182
  backend: optionBackendTag(backendId),
175
183
  source: optionSourceTag(sourceId),
176
- name: item.model.label,
184
+ name: item.label,
177
185
  nameWidth,
186
+ quant: optionQuantLabel(item),
178
187
  ctx: optionCtxLabel(item),
179
188
  size: optionSizeLabel(item),
180
189
  }),
package/src/scan.mjs CHANGED
@@ -52,8 +52,6 @@ async function scanOneDir(root, sourceLabel = "local-gguf") {
52
52
  const name = basename(path).replace(/\.gguf$/i, "");
53
53
  const sizeBytes = statSync(path).size;
54
54
  if (sizeBytes < MIN_MODEL_SIZE_BYTES) continue;
55
- const parsed = parseModelName(name, "local-gguf");
56
-
57
55
  // Read GGUF metadata to detect drafter architecture and embeddings
58
56
  const meta = safeReadGgufMetadata(path);
59
57
  const architecture = typeof meta["general.architecture"] === "string" ? meta["general.architecture"] : null;
@@ -61,6 +59,12 @@ async function scanOneDir(root, sourceLabel = "local-gguf") {
61
59
  ? meta[`${architecture}.context_length`]
62
60
  : null;
63
61
 
62
+ // Extract publisher from GGUF metadata (repo_url or quantized_by),
63
+ // falling back to directory structure (LM Studio: publisher/model-dir/file).
64
+ const publisher = publisherFromGgufMeta(meta) ?? publisherFromPath(path, root, sourceLabel);
65
+
66
+ const parsed = parseModelName(publisher ? `${publisher}/${name}` : name, sourceLabel);
67
+
64
68
  if (isEmbeddingArchitecture(architecture, name)) continue;
65
69
 
66
70
  if (architecture === "gemma4-assistant" || architecture === "gemma4_assistant") {
@@ -189,4 +193,37 @@ function safeReadGgufMetadata(path) {
189
193
  } catch {
190
194
  return {};
191
195
  }
196
+ }
197
+
198
+ // ── Publisher extraction ─────────────────────────────────────────────────
199
+
200
+ /** Extract publisher from GGUF metadata (repo_url or quantized_by). */
201
+ function publisherFromGgufMeta(meta) {
202
+ const repoUrl = meta["general.repo_url"];
203
+ if (typeof repoUrl === "string") {
204
+ const match = repoUrl.match(/huggingface\.co\/([^/?#]+)/i);
205
+ if (match) return match[1];
206
+ }
207
+ const quantizedBy = meta["general.quantized_by"];
208
+ if (typeof quantizedBy === "string" && quantizedBy.trim()) {
209
+ return quantizedBy.trim().toLowerCase();
210
+ }
211
+ return null;
212
+ }
213
+
214
+ /** Extract publisher from directory structure relative to the scan root. */
215
+ function publisherFromPath(filePath, scanRoot, sourceLabel) {
216
+ const rel = filePath.slice(scanRoot.length + 1).replace(/\\/g, "/");
217
+ const parts = rel.split("/");
218
+ if (parts.length === 0) return null;
219
+ // HF hub: models--org--name/snapshots/hash/file
220
+ if (parts[0]?.startsWith("models--")) {
221
+ const after = parts[0].slice("models--".length);
222
+ const dashIdx = after.indexOf("--");
223
+ if (dashIdx > 0) return after.slice(0, dashIdx);
224
+ return null;
225
+ }
226
+ // LM Studio: publisher/model-dir/file.gguf (3+ parts)
227
+ if (sourceLabel === "lmstudio" && parts.length >= 3) return parts[0];
228
+ return null;
192
229
  }