offgrid-ai 0.8.15 → 0.9.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/src/cli.mjs CHANGED
@@ -10,7 +10,7 @@ import { uninstallCommand } from "./commands/uninstall.mjs";
10
10
 
11
11
  async function offerUpdate(argv) {
12
12
  const invocation = detectInvocation();
13
- const update = await checkForUpdate({ force: false });
13
+ const update = await checkForUpdate();
14
14
  if (!update) return false;
15
15
 
16
16
  const plan = updateCommand(invocation, argv);
@@ -56,7 +56,7 @@ async function printVersion() {
56
56
  const version = currentPackageVersion();
57
57
  console.log(`offgrid-ai v${version}`);
58
58
  const invocation = detectInvocation();
59
- const update = await checkForUpdate({ force: false });
59
+ const update = await checkForUpdate();
60
60
  if (update) {
61
61
  const plan = updateCommand(invocation, ["version"]);
62
62
  console.log(pc.yellow(`Update available: v${update.latest}. Run: ${plan.display}`));
@@ -27,8 +27,8 @@ export async function mainFlow() {
27
27
  const { models: ggufModels, drafters } = await scanGgufModels();
28
28
  const managedModels = await scanManagedModels();
29
29
  const profiles = await loadProfiles();
30
- const hasAnyBackend = llamaBinary || managedModels.some((item) => item.models.length > 0);
31
- const hasAnyModels = ggufModels.length > 0 || managedModels.some((item) => item.models.length > 0);
30
+ 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);
32
32
 
33
33
  const piInstalled = await hasPi();
34
34
  const needsLlama = ggufModels.length > 0 || profiles.some((profile) => backendFor(profile.backend).type === "local-server");
@@ -90,8 +90,12 @@ function printFoundModels(ggufModels, managedModels, llamaBinary) {
90
90
  console.log(pc.green(`✓ Found ${ggufModels.length} GGUF model${ggufModels.length === 1 ? "" : "s"}`));
91
91
  if (!llamaBinary) console.log(pc.yellow("Install the managed llama.cpp runtime to run these GGUF models."));
92
92
  }
93
- for (const { backendId, models } of managedModels) {
94
- if (models.length > 0) console.log(pc.green(`✓ ${BACKENDS[backendId].label}: ${models.length} model${models.length === 1 ? "" : "s"}`));
93
+ for (const { backendId, models, status, reason } of managedModels) {
94
+ if (status === "unavailable") {
95
+ console.log(pc.yellow(`${BACKENDS[backendId].label}: unavailable${reason ? ` — ${reason}` : ""}`));
96
+ } else if (models.length > 0) {
97
+ console.log(pc.green(`✓ ${BACKENDS[backendId].label}: ${models.length} model${models.length === 1 ? "" : "s"}`));
98
+ }
95
99
  }
96
100
  }
97
101
 
package/src/config.mjs CHANGED
@@ -46,8 +46,13 @@ export async function loadConfig() {
46
46
  try {
47
47
  const raw = await readFile(CONFIG_PATH, "utf8");
48
48
  return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
49
- } catch {
50
- return { ...DEFAULT_CONFIG };
49
+ } catch (error) {
50
+ if (error?.code === "ENOENT") return { ...DEFAULT_CONFIG };
51
+ throw new Error(
52
+ `Failed to read config at ${CONFIG_PATH}: ${error.message}. ` +
53
+ `Fix or remove the file, then try again.`,
54
+ { cause: error }
55
+ );
51
56
  }
52
57
  }
53
58
 
@@ -99,6 +104,7 @@ export async function findLlamaServer() {
99
104
  if (existsSync(candidate)) return candidate;
100
105
  } catch { /* Homebrew not installed or llama.cpp not brewed */ }
101
106
 
107
+ // No llama-server found — caller must present actionable error or onboarding.
102
108
  return null;
103
109
  }
104
110
 
@@ -70,7 +70,7 @@ export async function hasPi() {
70
70
  // ── Internals ──────────────────────────────────────────────────────────────
71
71
 
72
72
  async function activeProviderProfiles(currentProfile) {
73
- const allProfiles = await loadProfiles().catch(() => []);
73
+ const allProfiles = await loadProfiles();
74
74
  const byAlias = new Map();
75
75
  for (const item of [...allProfiles, currentProfile]) {
76
76
  if (item.providerId !== currentProfile.providerId) continue;
package/src/managed.mjs CHANGED
@@ -10,9 +10,9 @@ export async function scanManagedModels() {
10
10
  const backend = BACKENDS[backendId];
11
11
  try {
12
12
  const models = await backend.scanModels();
13
- results.push({ backendId, models });
14
- } catch {
15
- // Managed backends are optional and may not be running.
13
+ results.push({ backendId, models, status: "ok" });
14
+ } catch (error) {
15
+ results.push({ backendId, models: [], status: "unavailable", reason: error?.message ?? String(error) });
16
16
  }
17
17
  }
18
18
  return results;
@@ -18,7 +18,8 @@ export function normalizeCatalog(catalog) {
18
18
  const profiledPaths = new Set(profiles.map((profile) => profile.modelPath).filter(Boolean));
19
19
  const newModels = ggufModels.filter((model) => !profiledPaths.has(model.path));
20
20
  const managedItems = [];
21
- for (const { backendId, models } of managedModels) {
21
+ for (const { backendId, models, status } of managedModels) {
22
+ if (status === "unavailable") continue;
22
23
  const profiledAliases = new Set(
23
24
  profiles
24
25
  .filter((profile) => profile.backend === backendId)
@@ -0,0 +1,220 @@
1
+ // ── Single path for parsing and formatting model names ─────────────────────
2
+ //
3
+ // Every model display name in offgrid-ai goes through parseModelName().
4
+ // No other function should format, title-case, or dissect a model name.
5
+ //
6
+ // The returned `id` is always the raw identifier (untouched) and is used for
7
+ // API calls, profile IDs, Pi config matching, and benchmark directory slugs.
8
+ // The returned `display` is the human-readable string shown in pickers, details,
9
+ // and benchmark metadata.
10
+
11
+ // ── Known model families ────────────────────────────────────────────────
12
+ //
13
+ // Mapped to their title-case form. Matched as prefix tokens so "qwen"
14
+ // matches "qwen3", "qwen2.5", etc.
15
+
16
+ const FAMILY_TITLE_CASE = {
17
+ "deepseek-r": "DeepSeek-R",
18
+ "deepseek": "DeepSeek",
19
+ "starcoder2": "StarCoder2",
20
+ "starcoder": "StarCoder",
21
+ "command-r": "Command-R",
22
+ "command": "Command",
23
+ "codestral": "Codestral",
24
+ "mistral": "Mistral",
25
+ "mixtral": "Mixtral",
26
+ "mathstral": "Mathstral",
27
+ "pixtral": "Pixtral",
28
+ "gemma": "Gemma",
29
+ "qwen": "Qwen",
30
+ "llama": "Llama",
31
+ "phi": "Phi",
32
+ "yi": "Yi",
33
+ "zephyr": "Zephyr",
34
+ "internlm": "InternLM",
35
+ "cohere": "Cohere",
36
+ "falcon": "Falcon",
37
+ "baichuan": "Baichuan",
38
+ "mamba": "Mamba",
39
+ "solar": "Solar",
40
+ "granite": "Granite",
41
+ "dbrx": "DBRX",
42
+ "stablelm": "StableLM",
43
+ };
44
+
45
+ // Sort families by length descending so longer families match first
46
+ const SORTED_FAMILIES = Object.keys(FAMILY_TITLE_CASE).sort((a, b) => b.length - a.length);
47
+
48
+ // ── Quant patterns (order matters — longer/more-specific first) ────────
49
+
50
+ const QUANT_PATTERNS = [
51
+ /[-_]UD-[A-Z0-9_]+/i,
52
+ /[-_]IQ[0-9_]+(?:_[A-Z]+)?/i,
53
+ /[-_]Q\d_K_[A-Z]+/i,
54
+ /[-_]Q\d_[01]/i,
55
+ /[-_]F(?:16|32)/i,
56
+ /[-_]BF16/i,
57
+ ];
58
+
59
+ // ── Tag tokens extracted from the name ──────────────────────────────────
60
+
61
+ const TAG_TOKENS = [
62
+ "it", "instruct", "chat", "code", "base", "vision", "mtp",
63
+ "mmproj", "draft", "assistant",
64
+ ];
65
+
66
+ // ── Main entry point ───────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Parse a raw model identifier into a structured display name.
70
+ *
71
+ * @param {string} rawId The raw identifier: GGUF filename (no .gguf),
72
+ * Ollama model name, or oMLX model id.
73
+ * @param {"local-gguf"|"ollama"|"omlx"} source Where this name came from.
74
+ * @returns {{ publisher: string|null, model: string, params: string|null,
75
+ * quant: string|null, tags: string[], display: string,
76
+ * sort: string, id: string }}
77
+ */
78
+ export function parseModelName(rawId, source) {
79
+ const id = rawId; // never modify the raw id
80
+
81
+ // 1. Extract publisher (anything before the first /)
82
+ let publisher = null;
83
+ let name = rawId;
84
+ const slashIdx = rawId.indexOf("/");
85
+ if (slashIdx !== -1) {
86
+ publisher = rawId.slice(0, slashIdx);
87
+ name = rawId.slice(slashIdx + 1);
88
+ }
89
+
90
+ // 2. For Ollama, split on : to separate model from tag (e.g. "gemma3:4b")
91
+ // The tag after : is a model size/variant identifier — not a GGUF quant.
92
+ let ollamaTag = null;
93
+ if (source === "ollama") {
94
+ const colonIdx = name.lastIndexOf(":");
95
+ if (colonIdx !== -1) {
96
+ ollamaTag = name.slice(colonIdx + 1);
97
+ name = name.slice(0, colonIdx);
98
+ }
99
+ }
100
+
101
+ // 3. Extract quant (GGUF quantization suffix)
102
+ let quant = null;
103
+ for (const pattern of QUANT_PATTERNS) {
104
+ const match = name.match(pattern);
105
+ if (match) {
106
+ quant = match[0].replace(/^[-_]/, "");
107
+ name = name.slice(0, match.index) + name.slice(match.index + match[0].length);
108
+ break;
109
+ }
110
+ }
111
+
112
+ // 4. Extract known tags as hyphen/underscore-delimited tokens
113
+ const tags = [];
114
+ for (const tag of TAG_TOKENS) {
115
+ const tagRegex = new RegExp(`(?:^|[-_])${tag}(?:$|[-_])`, "i");
116
+ if (tagRegex.test(name)) {
117
+ tags.push(tag);
118
+ // Remove the tag token from the name
119
+ name = name.replace(new RegExp(`(?:^|[-_])${tag}(?=[-_]|$)`, "i"), (m) => {
120
+ // Preserve the leading hyphen/underscore boundary
121
+ return m.startsWith("-") || m.startsWith("_") ? "" : "";
122
+ });
123
+ }
124
+ }
125
+ // Clean up leftover separators
126
+ name = name.replace(/[-_]{2,}/g, "-").replace(/^[-_]+|[-_]+$/g, "");
127
+
128
+ // 5. For Ollama, re-attach the tag as part of the model name
129
+ // (Ollama tags like "4b" or "30b-a3b" are size variants, not quants)
130
+ if (ollamaTag) {
131
+ name = name + "-" + ollamaTag;
132
+ }
133
+
134
+ // 6. Title-case the remaining model name
135
+ let model = titleCaseModel(name);
136
+
137
+ // If nothing is left after parsing, fall back to the raw name
138
+ if (!model || model.trim() === "") {
139
+ model = rawId.includes("/") ? rawId : rawId.replace(/[-_]/g, " ");
140
+ }
141
+
142
+ // 7. Extract params (size like 30B, 12B) for sort/filter convenience
143
+ const params = extractParams(model);
144
+
145
+ // 8. Build display string
146
+ const display = buildDisplay(publisher, model, tags, quant);
147
+
148
+ // 9. Build sort key (lowercase, no publisher, for alphabetical ordering)
149
+ const sort = model.toLowerCase().replace(/[-_]/g, " ");
150
+
151
+ return { publisher, model, params, quant, tags, display, sort, id };
152
+ }
153
+
154
+ // ── Display builder ────────────────────────────────────────────────────
155
+
156
+ function buildDisplay(publisher, model, tags, quant) {
157
+ const parts = [];
158
+ if (publisher) {
159
+ parts.push(publisher);
160
+ }
161
+ let modelPart = model;
162
+ if (tags.length > 0) {
163
+ modelPart += ` (${tags.join(", ")})`;
164
+ }
165
+ parts.push(modelPart);
166
+ if (quant) {
167
+ parts.push(quant);
168
+ }
169
+ return parts.join(" › ");
170
+ }
171
+
172
+ // ── Params extraction ──────────────────────────────────────────────────
173
+
174
+ function extractParams(model) {
175
+ const match = model.match(/\b(\d+(?:\.\d+)?)\s*B\b/);
176
+ return match ? match[1] + "B" : null;
177
+ }
178
+
179
+ // ── Title-case model names ────────────────────────────────────────────
180
+
181
+ function titleCaseModel(name) {
182
+ // Replace hyphens and underscores with spaces
183
+ let result = name.replace(/[-_]/g, " ");
184
+
185
+ // Title-case known families (prefix match so "qwen" matches "qwen3", etc.)
186
+ // Insert a space between the family name and a following digit/version.
187
+ for (const family of SORTED_FAMILIES) {
188
+ const pattern = new RegExp(`\\b${family}(?=[0-9])`, "gi");
189
+ result = result.replace(pattern, FAMILY_TITLE_CASE[family] + " ");
190
+ // Also match family at end of word (no digit following)
191
+ const patternEnd = new RegExp(`\\b${family}(?![a-z0-9])`, "gi");
192
+ result = result.replace(patternEnd, FAMILY_TITLE_CASE[family]);
193
+ }
194
+
195
+ // Title-case param sizes (30b → 30B, 12b → 12B, 0.5b → 0.5B)
196
+ result = result.replace(/\b(\d+(?:\.\d+)?)\s*[bB]\b/g, (_, num) => {
197
+ return num + "B";
198
+ });
199
+
200
+ // Title-case version numbers that follow a family name (Gemma 3, Qwen 2.5)
201
+ // Pattern: family name followed by a space then a bare digit sequence
202
+ // that's not a param size (not followed by B/b).
203
+ // We already have "Gemma 4", "Qwen 3" etc. from family + spacing.
204
+ // Just ensure the numbers look clean.
205
+
206
+ // Title-case "it" and "instruct" if they survived tag extraction
207
+ result = result.replace(/\bit\b/g, "IT");
208
+ result = result.replace(/\binstruct\b/gi, "Instruct");
209
+
210
+ // Title-case "r1", "r2" etc. (DeepSeek-R1, etc.)
211
+ result = result.replace(/\br(\d+)\b/gi, (_, num) => `R${num}`);
212
+
213
+ // Title-case standalone aXb patterns (A3B, A12B — active parameters)
214
+ result = result.replace(/\ba(\d+)\s*b\b/gi, (_, num) => `A${num}B`);
215
+
216
+ // Clean up extra spaces
217
+ result = result.replace(/\s{2,}/g, " ").trim();
218
+
219
+ return result;
220
+ }
package/src/process.mjs CHANGED
@@ -195,47 +195,55 @@ export async function waitForReady(profile, pid, rawLogPath) {
195
195
  // ── Internals ──────────────────────────────────────────────────────────────
196
196
 
197
197
  async function serverModelIds(baseUrl) {
198
- const body = await fetchJson(`${baseUrl.replace(/\/$/, "")}/models`);
199
- return (Array.isArray(body?.data) ? body.data : [])
198
+ const result = await fetchJson(`${baseUrl.replace(/\/+$/u, "")}/models`);
199
+ if (!result.ok) return [];
200
+ return (Array.isArray(result.data?.data) ? result.data.data : [])
200
201
  .map((model) => String(model?.id ?? "").trim())
201
202
  .filter(Boolean);
202
203
  }
203
204
 
204
205
  async function ollamaLoadedModelIds(profile) {
205
- const body = await fetchJson(`${apiRootUrl(profile.baseUrl)}/api/ps`);
206
- return (Array.isArray(body?.models) ? body.models : [])
206
+ const result = await fetchJson(`${apiRootUrl(profile.baseUrl)}/api/ps`);
207
+ if (!result.ok) return [];
208
+ return (Array.isArray(result.data?.models) ? result.data.models : [])
207
209
  .flatMap((model) => [model?.name, model?.model])
208
210
  .map((id) => String(id ?? "").trim())
209
211
  .filter(Boolean);
210
212
  }
211
213
 
212
214
  async function omlxLoadedModelIds(profile) {
213
- const status = await fetchJson(`${profile.baseUrl.replace(/\/$/, "")}/models/status`);
214
- const fromStatus = (Array.isArray(status?.models) ? status.models : [])
215
- .filter((model) => model?.loaded === true)
216
- .flatMap((model) => [model?.id, model?.name, model?.model, model?.alias])
217
- .map((id) => String(id ?? "").trim())
218
- .filter(Boolean);
219
- if (Number(status?.loaded_count) === 0) return fromStatus;
220
-
221
- const summary = await fetchJson(`${apiRootUrl(profile.baseUrl)}/api/status`);
222
- const fromSummary = (Array.isArray(summary?.loaded_models) ? summary.loaded_models : [])
223
- .map((id) => String(id ?? "").trim())
224
- .filter(Boolean);
215
+ const statusResult = await fetchJson(`${profile.baseUrl.replace(/\/+$/u, "")}/models/status`);
216
+ const fromStatus = statusResult.ok
217
+ ? (Array.isArray(statusResult.data?.models) ? statusResult.data.models : [])
218
+ .filter((model) => model?.loaded === true)
219
+ .flatMap((model) => [model?.id, model?.name, model?.model, model?.alias])
220
+ .map((id) => String(id ?? "").trim())
221
+ .filter(Boolean)
222
+ : [];
223
+ if (!statusResult.ok || Number(statusResult.data?.loaded_count) === 0) return fromStatus;
224
+
225
+ const summaryResult = await fetchJson(`${apiRootUrl(profile.baseUrl)}/api/status`);
226
+ const fromSummary = summaryResult.ok
227
+ ? (Array.isArray(summaryResult.data?.loaded_models) ? summaryResult.data.loaded_models : [])
228
+ .map((id) => String(id ?? "").trim())
229
+ .filter(Boolean)
230
+ : [];
225
231
  return [...fromStatus, ...fromSummary];
226
232
  }
227
233
 
228
234
  async function fetchJson(url) {
229
235
  try {
230
236
  const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
231
- if (!response.ok) return null;
232
- return await response.json();
233
- } catch {
234
- return null;
237
+ if (!response.ok) return { ok: false, reason: "http", status: response.status, data: null };
238
+ const data = await response.json();
239
+ return { ok: true, data };
240
+ } catch (error) {
241
+ if (error?.name === "AbortError" || error?.name === "TimeoutError") return { ok: false, reason: "timeout", data: null };
242
+ return { ok: false, reason: "network", data: null };
235
243
  }
236
244
  }
237
245
 
238
- function apiRootUrl(baseUrl) {
246
+ export function apiRootUrl(baseUrl) {
239
247
  try {
240
248
  const url = new URL(baseUrl);
241
249
  url.pathname = url.pathname.replace(/\/v1\/?$/u, "") || "/";
package/src/runtime.mjs CHANGED
@@ -33,6 +33,12 @@ export async function offerManagedLlamaRuntimeUpdate(prompt, { fetchImpl = globa
33
33
  return true;
34
34
  }
35
35
 
36
+ /**
37
+ * Check for the latest llama.cpp release on GitHub.
38
+ * Returns null if the check fails (network error, timeout, etc.).
39
+ * Callers must treat null as "check failed, skip prompt" — do not
40
+ * treat null as "no update available."
41
+ */
36
42
  export async function latestLlamaRelease(fetchImpl = globalThis.fetch) {
37
43
  try {
38
44
  const response = await fetchImpl(RELEASE_API, { signal: AbortSignal.timeout(5000) });
@@ -47,6 +53,11 @@ export async function latestLlamaRelease(fetchImpl = globalThis.fetch) {
47
53
  }
48
54
  }
49
55
 
56
+ /**
57
+ * Read the installed runtime version from disk.
58
+ * Returns null if not installed or if the version file is missing/corrupt.
59
+ * Callers must treat null as "runtime not installed" — not as a hidden default.
60
+ */
50
61
  export async function installedRuntime() {
51
62
  try {
52
63
  return JSON.parse(await readFile(VERSION_PATH, "utf8"));
package/src/scan.mjs CHANGED
@@ -3,6 +3,7 @@ import { readdir } from "node:fs/promises";
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import { getModelScanDirs } from "./config.mjs";
5
5
  import { readGgufMetadata } from "./gguf.mjs";
6
+ import { parseModelName } from "./model-name.mjs";
6
7
 
7
8
  // ── Scan for GGUF models and MTP drafters ────────────────────────────────
8
9
 
@@ -48,6 +49,7 @@ async function scanOneDir(root) {
48
49
  const mmprojPath = mmprojs.find((candidate) => dirname(candidate) === dir) ?? null;
49
50
  const name = basename(path).replace(/\.gguf$/i, "");
50
51
  const sizeBytes = statSync(path).size;
52
+ const parsed = parseModelName(name, "local-gguf");
51
53
 
52
54
  // Read GGUF metadata to detect drafter architecture
53
55
  const meta = safeReadGgufMetadata(path);
@@ -57,9 +59,9 @@ async function scanOneDir(root) {
57
59
  // This is an MTP drafter model, not a main model
58
60
  drafters.push({
59
61
  path,
60
- label: labelFromName(name),
61
- aliasSuggestion: aliasFromName(name),
62
- quant: quantFromName(name),
62
+ label: parsed.display,
63
+ aliasSuggestion: parsed.id,
64
+ quant: parsed.quant,
63
65
  sizeBytes,
64
66
  architecture,
65
67
  targetHint: drafterTargetHint(name),
@@ -70,9 +72,9 @@ async function scanOneDir(root) {
70
72
  models.push({
71
73
  path,
72
74
  mmprojPath,
73
- label: labelFromName(name),
74
- aliasSuggestion: aliasFromName(name),
75
- quant: quantFromName(name),
75
+ label: parsed.display,
76
+ aliasSuggestion: parsed.id,
77
+ quant: parsed.quant,
76
78
  sizeBytes,
77
79
  backend: "llama-cpp",
78
80
  source: "local-gguf",
@@ -143,20 +145,7 @@ async function findFiles(root, predicate) {
143
145
  return result;
144
146
  }
145
147
 
146
- function labelFromName(name) {
147
- return name
148
- .replace(/-/g, " ")
149
- .replace(/\bqwen/i, "Qwen")
150
- .replace(/q4_k_m/i, "Q4_K_M");
151
- }
152
-
153
- function aliasFromName(name) {
154
- return name.replace(/-Q4_K_M$/i, "-GGUF");
155
- }
156
-
157
- function quantFromName(name) {
158
- return name.match(/(Q\d_K_[A-Z]+|Q\d_[01]|UD-[A-Z0-9_]+)/)?.[1];
159
- }
148
+ // (labelFromName, aliasFromName, quantFromName removed — parseModelName in model-name.mjs is the single path)
160
149
 
161
150
 
162
151
  function safeReadGgufMetadata(path) {