offgrid-ai 0.8.12 → 0.8.13

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.8.12",
3
+ "version": "0.8.13",
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",
@@ -46,7 +46,7 @@ export async function modelCommandCenter(initialCatalog) {
46
46
  runningProfilesNow.push(profile);
47
47
  continue;
48
48
  }
49
- if (await isProfileServerUp(profile)) serverUpIds.add(profile.id);
49
+ if (backendFor(profile.backend).type === "managed-server" && await isProfileServerUp(profile)) serverUpIds.add(profile.id);
50
50
  }
51
51
  printWorkspaceHeader(normalized, runningProfilesNow, serverUpIds);
52
52
  await printBenchmarkLine();
@@ -63,22 +63,14 @@ export async function modelCommandCenter(initialCatalog) {
63
63
  return "setup";
64
64
  };
65
65
 
66
- const groupOrder = [
67
- { key: "running", label: pc.green(" Running") },
68
- { key: "serverup", label: pc.yellow(" Server up · model not loaded") },
69
- { key: "ready", label: pc.blue(" Ready to chat") },
70
- { key: "setup", label: pc.yellow(" Need setup") },
71
- { key: "missing", label: pc.red(" File missing") },
72
- ];
73
- const grouped = new Map(groupOrder.map((g) => [g.key, []]));
66
+ const groupOrder = ["running", "serverup", "ready", "setup", "missing"];
67
+ const grouped = new Map(groupOrder.map((key) => [key, []]));
74
68
  for (const item of allItems) grouped.get(statusFor(item)).push(item);
75
69
 
76
- const sectionSentinel = "__section__";
77
70
  const choices = [];
78
71
  for (const group of groupOrder) {
79
- const bucket = grouped.get(group.key);
72
+ const bucket = grouped.get(group);
80
73
  if (!bucket || bucket.length === 0) continue;
81
- choices.push({ value: `${sectionSentinel}:${group.key}`, label: `── ${group.label} (${bucket.length}) ──`, disabled: true });
82
74
  for (const item of bucket) {
83
75
  const opt = modelSelectOption(item, { runningProfilesNow, serverUpIds, nameWidth });
84
76
  choices.push({ value: opt.value, label: opt.label });
@@ -88,7 +80,7 @@ export async function modelCommandCenter(initialCatalog) {
88
80
  const prompt = createPrompt();
89
81
  try {
90
82
  const selected = await prompt.choice("Select a model", choices);
91
- if (!selected || selected.startsWith(`${sectionSentinel}:`)) return;
83
+ if (!selected) return;
92
84
  const item = allItems.find((candidate) => itemKey(candidate) === selected);
93
85
  if (!item) return;
94
86
 
@@ -1,7 +1,7 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { BACKENDS, backendFor } from "./backends.mjs";
3
3
  import { readCommandArgv } from "./profiles.mjs";
4
- import { isProfileRunning } from "./process.mjs";
4
+ import { isProfileRunning, isProfileServerUp } from "./process.mjs";
5
5
  import { buildPrettyCommand } from "./command.mjs";
6
6
  import { pc, formatBytes, renderRows, renderSection } from "./ui.mjs";
7
7
  import { capabilitySummary, ggufDetailParts, isProfileFileMissing, profileDetailParts } from "./model-summary.mjs";
@@ -160,10 +160,11 @@ export async function printProfileDetails(profile) {
160
160
  const backend = backendFor(profile.backend);
161
161
  const isManaged = backend.type === "managed-server";
162
162
  const running = await isProfileRunning(profile);
163
+ const serverUp = !running && isManaged && await isProfileServerUp(profile);
163
164
  const fileMissing = !isManaged && isProfileFileMissing(profile);
164
165
  console.log("\n" + renderSection("Model overview", renderRows([
165
166
  ["Name", pc.bold(profile.label)],
166
- ["Status", fileMissing ? pc.red("File missing") : running ? pc.green("Running now") : pc.blue("Ready")],
167
+ ["Status", fileMissing ? pc.red("File missing") : running ? pc.green("Running now") : serverUp ? pc.yellow("Server up, model not loaded") : pc.blue("Ready")],
167
168
  ["Details", profileDetailParts(profile, { fileMissing }).join(pc.dim(" · "))],
168
169
  ["Server", fileMissing ? pc.red(profile.baseUrl) : profile.baseUrl],
169
170
  ])));
package/src/process.mjs CHANGED
@@ -125,6 +125,9 @@ export async function isProfileServerUp(profile) {
125
125
  }
126
126
 
127
127
  export async function modelLoadedOnServer(profile) {
128
+ const backend = backendFor(profile.backend);
129
+ if (backend.id === "ollama") return modelIdsMatch(await ollamaLoadedModelIds(profile), expectedModelIds(profile));
130
+ if (backend.id === "omlx") return modelIdsMatch(await omlxLoadedModelIds(profile), expectedModelIds(profile));
128
131
  const { matches } = await serverMatchesProfile(profile);
129
132
  return matches;
130
133
  }
@@ -133,7 +136,8 @@ export async function profileRuntimeStatus(profile) {
133
136
  const backend = backendFor(profile.backend);
134
137
  if (backend.type === "managed-server") {
135
138
  const ready = await serverReady(profile.baseUrl);
136
- return { state: null, pid: null, running: ready, ready, rssBytes: null, startedAt: null };
139
+ const modelLoaded = ready ? await modelLoadedOnServer(profile) : false;
140
+ return { state: null, pid: null, running: ready && modelLoaded, ready, serverUp: ready, modelLoaded, rssBytes: null, startedAt: null };
137
141
  }
138
142
  const state = await readState(profile.id);
139
143
  const running = Boolean(state?.pid && pidAlive(state.pid));
@@ -191,18 +195,79 @@ export async function waitForReady(profile, pid, rawLogPath) {
191
195
  // ── Internals ──────────────────────────────────────────────────────────────
192
196
 
193
197
  async function serverModelIds(baseUrl) {
198
+ const body = await fetchJson(`${baseUrl.replace(/\/$/, "")}/models`);
199
+ return (Array.isArray(body?.data) ? body.data : [])
200
+ .map((model) => String(model?.id ?? "").trim())
201
+ .filter(Boolean);
202
+ }
203
+
204
+ async function ollamaLoadedModelIds(profile) {
205
+ const body = await fetchJson(`${apiRootUrl(profile.baseUrl)}/api/ps`);
206
+ return (Array.isArray(body?.models) ? body.models : [])
207
+ .flatMap((model) => [model?.name, model?.model])
208
+ .map((id) => String(id ?? "").trim())
209
+ .filter(Boolean);
210
+ }
211
+
212
+ 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);
225
+ return [...fromStatus, ...fromSummary];
226
+ }
227
+
228
+ async function fetchJson(url) {
194
229
  try {
195
- const response = await fetch(`${baseUrl.replace(/\/$/, "")}/models`, { signal: AbortSignal.timeout(1000) });
196
- if (!response.ok) return [];
197
- const body = await response.json();
198
- return (Array.isArray(body?.data) ? body.data : [])
199
- .map((model) => String(model?.id ?? "").trim())
200
- .filter(Boolean);
230
+ const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
231
+ if (!response.ok) return null;
232
+ return await response.json();
201
233
  } catch {
202
- return [];
234
+ return null;
203
235
  }
204
236
  }
205
237
 
238
+ function apiRootUrl(baseUrl) {
239
+ try {
240
+ const url = new URL(baseUrl);
241
+ url.pathname = url.pathname.replace(/\/v1\/?$/u, "") || "/";
242
+ url.search = "";
243
+ url.hash = "";
244
+ return url.toString().replace(/\/$/u, "");
245
+ } catch {
246
+ return String(baseUrl).replace(/\/v1\/?$/u, "").replace(/\/$/u, "");
247
+ }
248
+ }
249
+
250
+ function modelIdsMatch(actualIds, expectedIds) {
251
+ const actual = normalizedModelIds(actualIds);
252
+ const expected = normalizedModelIds(expectedIds);
253
+ return [...expected].some((id) => actual.has(id));
254
+ }
255
+
256
+ function normalizedModelIds(ids) {
257
+ const normalized = new Set();
258
+ for (const id of ids) {
259
+ const value = normalizeModelId(id);
260
+ if (!value) continue;
261
+ normalized.add(value);
262
+ if (value.endsWith(":latest")) normalized.add(value.slice(0, -":latest".length));
263
+ }
264
+ return normalized;
265
+ }
266
+
267
+ function normalizeModelId(id) {
268
+ return String(id ?? "").trim().toLowerCase();
269
+ }
270
+
206
271
  function expectedModelIds(profile) {
207
272
  const fileName = profile.modelPath ? basename(profile.modelPath) : null;
208
273
  return [
package/src/ui.mjs CHANGED
@@ -36,7 +36,7 @@ export function createPrompt() {
36
36
  async choice(label, choices, defaultValue) {
37
37
  return handleCancel(await select({
38
38
  message: label, initialValue: defaultValue,
39
- options: choices.map((c) => ({ value: c.value, label: c.label ?? c.value, hint: c.hint })),
39
+ options: choices.map((c) => ({ value: c.value, label: c.label ?? c.value, hint: c.hint, disabled: c.disabled })),
40
40
  }));
41
41
  },
42
42
  close() {},