offgrid-ai 0.10.2 → 0.11.0
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 +1 -1
- package/src/backends.mjs +3 -2
- package/src/mlx-discovery.mjs +16 -5
- package/src/model-catalog.mjs +23 -0
- package/src/model-name.mjs +12 -12
- package/src/model-presenters.mjs +14 -5
- package/src/scan.mjs +39 -2
package/package.json
CHANGED
package/src/backends.mjs
CHANGED
|
@@ -103,13 +103,14 @@ async function scanOmlxModels() {
|
|
|
103
103
|
.filter((model) => isChatOmlxModel(model))
|
|
104
104
|
.map((model) => {
|
|
105
105
|
const sizeFromDisk = lookupOmlxModelSize(model.id, sizeMap);
|
|
106
|
+
const parsed = parseModelName(model.id, "omlx");
|
|
106
107
|
return {
|
|
107
108
|
id: model.id,
|
|
108
|
-
label:
|
|
109
|
+
label: parsed.display,
|
|
109
110
|
aliasSuggestion: model.id,
|
|
110
111
|
sizeBytes: sizeFromDisk ?? (model.size ?? 0),
|
|
111
112
|
contextLength: model.max_model_len ?? null,
|
|
112
|
-
quant:
|
|
113
|
+
quant: parsed.quant,
|
|
113
114
|
family: null,
|
|
114
115
|
backend: "omlx",
|
|
115
116
|
source: "omlx",
|
package/src/mlx-discovery.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
package/src/model-catalog.mjs
CHANGED
|
@@ -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
|
}
|
package/src/model-name.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 ──────────────────────────────────────────────────
|
package/src/model-presenters.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
}
|