offgrid-ai 0.8.11 → 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.11",
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",
@@ -1,7 +1,7 @@
1
1
  import { ensureDirs } from "../config.mjs";
2
2
  import { backendFor, BACKENDS } from "../backends.mjs";
3
3
  import { createProfileFromModel, readProfile, saveProfile, deleteProfile, profileJsonPath } from "../profiles.mjs";
4
- import { isProfileRunning, stopProfile } from "../process.mjs";
4
+ import { isProfileRunning, isProfileServerUp, stopProfile } from "../process.mjs";
5
5
  import { syncPiConfig, removeFromPiConfig } from "../harness-pi.mjs";
6
6
  import { configureLocalProfile } from "../profile-setup.mjs";
7
7
  import { pc, startInteractive, createPrompt } from "../ui.mjs";
@@ -40,17 +40,46 @@ export async function modelCommandCenter(initialCatalog) {
40
40
  }
41
41
 
42
42
  const runningProfilesNow = [];
43
+ const serverUpIds = new Set();
43
44
  for (const profile of normalized.profiles) {
44
- if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
45
+ if (await isProfileRunning(profile)) {
46
+ runningProfilesNow.push(profile);
47
+ continue;
48
+ }
49
+ if (backendFor(profile.backend).type === "managed-server" && await isProfileServerUp(profile)) serverUpIds.add(profile.id);
45
50
  }
46
- printWorkspaceHeader(normalized, runningProfilesNow);
51
+ printWorkspaceHeader(normalized, runningProfilesNow, serverUpIds);
47
52
  await printBenchmarkLine();
48
53
 
49
54
  const nameWidth = modelNameWidth(allItems);
50
55
 
56
+ const statusFor = (item) => {
57
+ if (item.type === "profile") {
58
+ if (item.fileMissing) return "missing";
59
+ if (runningProfilesNow.some((profile) => profile.id === item.profile.id)) return "running";
60
+ if (serverUpIds.has(item.profile.id)) return "serverup";
61
+ return "ready";
62
+ }
63
+ return "setup";
64
+ };
65
+
66
+ const groupOrder = ["running", "serverup", "ready", "setup", "missing"];
67
+ const grouped = new Map(groupOrder.map((key) => [key, []]));
68
+ for (const item of allItems) grouped.get(statusFor(item)).push(item);
69
+
70
+ const choices = [];
71
+ for (const group of groupOrder) {
72
+ const bucket = grouped.get(group);
73
+ if (!bucket || bucket.length === 0) continue;
74
+ for (const item of bucket) {
75
+ const opt = modelSelectOption(item, { runningProfilesNow, serverUpIds, nameWidth });
76
+ choices.push({ value: opt.value, label: opt.label });
77
+ }
78
+ }
79
+
51
80
  const prompt = createPrompt();
52
81
  try {
53
- const selected = await prompt.choice("Select a model", allItems.map((item) => modelSelectOption(item, { runningProfilesNow, nameWidth })));
82
+ const selected = await prompt.choice("Select a model", choices);
54
83
  if (!selected) return;
55
84
  const item = allItems.find((candidate) => itemKey(candidate) === selected);
56
85
  if (!item) return;
@@ -37,10 +37,24 @@ export function itemKey(item) {
37
37
  return `managed:${item.backendId}:${item.model.id}`;
38
38
  }
39
39
 
40
+ function profileRecency(item) {
41
+ const updated = item.profile?.updatedAt ?? item.profile?.createdAt;
42
+ const ts = updated ? Date.parse(updated) : NaN;
43
+ return Number.isFinite(ts) ? ts : 0;
44
+ }
45
+
46
+ function compareRecency(a, b) {
47
+ const diff = profileRecency(b) - profileRecency(a);
48
+ if (diff !== 0) return diff;
49
+ return String(a.label ?? "").localeCompare(String(b.label ?? ""));
50
+ }
51
+
40
52
  export function buildCatalogItems(normalized) {
41
53
  const { profiles, newModels, managedItems, drafters } = normalized;
54
+ const profileItems = profiles.map((profile) => ({ type: "profile", profile, label: profile.label, fileMissing: isProfileFileMissing(profile) }));
55
+ profileItems.sort(compareRecency);
42
56
  return [
43
- ...profiles.map((profile) => ({ type: "profile", profile, label: profile.label, fileMissing: isProfileFileMissing(profile) })),
57
+ ...profileItems,
44
58
  ...newModels.map((model) => ({ type: "new", model, label: model.label, drafter: matchDrafter(model.path, drafters) })),
45
59
  ...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label })),
46
60
  ];
@@ -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";
@@ -25,6 +25,7 @@ function optionPad(text, color, width) {
25
25
  function optionStatusTag(kind) {
26
26
  const statuses = {
27
27
  running: ["RUNNING", pc.green],
28
+ serverup: ["SERVER UP", pc.yellow],
28
29
  ready: ["READY", pc.blue],
29
30
  missing: ["MISSING", pc.red],
30
31
  setup: ["SETUP", pc.yellow],
@@ -79,14 +80,16 @@ function optionLabel({ status, source, name, ctx, size, nameWidth }) {
79
80
  return [status, source, pc.bold(optionPad(name, null, nameWidth)), ctx, pc.dim(size)].join(OPTION_SEPARATOR);
80
81
  }
81
82
 
82
- export function modelSelectOption(item, { runningProfilesNow, nameWidth }) {
83
+ export function modelSelectOption(item, { runningProfilesNow, serverUpIds, nameWidth }) {
83
84
  if (item.type === "profile") {
84
85
  const backend = backendFor(item.profile.backend);
85
86
  const running = runningProfilesNow.some((profile) => profile.id === item.profile.id);
87
+ const serverUp = !running && !item.fileMissing && serverUpIds?.has(item.profile.id);
88
+ const status = item.fileMissing ? "missing" : running ? "running" : serverUp ? "serverup" : "ready";
86
89
  return {
87
90
  value: itemKey(item),
88
91
  label: optionLabel({
89
- status: optionStatusTag(item.fileMissing ? "missing" : running ? "running" : "ready"),
92
+ status: optionStatusTag(status),
90
93
  source: optionSourceTag(item.profile.backend, backend.label),
91
94
  name: item.profile.label,
92
95
  nameWidth,
@@ -122,15 +125,19 @@ export function modelSelectOption(item, { runningProfilesNow, nameWidth }) {
122
125
  };
123
126
  }
124
127
 
125
- export function printWorkspaceHeader(normalized, runningProfilesNow) {
128
+ export function printWorkspaceHeader(normalized, runningProfilesNow, serverUpIds = new Set()) {
126
129
  const profiles = normalized.profiles;
127
- const readyCount = profiles.filter((p) => !isProfileFileMissing(p) && !runningProfilesNow.some((r) => r.id === p.id)).length;
130
+ const isRunning = (p) => runningProfilesNow.some((r) => r.id === p.id);
131
+ const isMissing = (p) => isProfileFileMissing(p);
132
+ const readyCount = profiles.filter((p) => !isMissing(p) && !isRunning(p) && !serverUpIds.has(p.id)).length;
128
133
  const runningCount = runningProfilesNow.length;
129
- const missingCount = profiles.filter((p) => isProfileFileMissing(p)).length;
134
+ const serverUpCount = profiles.filter((p) => !isMissing(p) && serverUpIds.has(p.id) && !isRunning(p)).length;
135
+ const missingCount = profiles.filter(isMissing).length;
130
136
  const setupCount = normalized.newModels.length + normalized.managedItems.length;
131
137
 
132
138
  const countParts = [];
133
139
  if (runningCount > 0) countParts.push(pc.green(`${runningCount} running`));
140
+ if (serverUpCount > 0) countParts.push(pc.yellow(`${serverUpCount} server up, model not loaded`));
134
141
  if (readyCount > 0) countParts.push(pc.blue(`${readyCount} model${readyCount === 1 ? "" : "s"} ready`));
135
142
  if (missingCount > 0) countParts.push(pc.red(`${missingCount} model${missingCount === 1 ? "" : "s"} missing`));
136
143
  if (setupCount > 0) countParts.push(pc.yellow(`${setupCount} model${setupCount === 1 ? "" : "s"} need${setupCount === 1 ? "s" : ""} setup`));
@@ -153,10 +160,11 @@ export async function printProfileDetails(profile) {
153
160
  const backend = backendFor(profile.backend);
154
161
  const isManaged = backend.type === "managed-server";
155
162
  const running = await isProfileRunning(profile);
163
+ const serverUp = !running && isManaged && await isProfileServerUp(profile);
156
164
  const fileMissing = !isManaged && isProfileFileMissing(profile);
157
165
  console.log("\n" + renderSection("Model overview", renderRows([
158
166
  ["Name", pc.bold(profile.label)],
159
- ["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")],
160
168
  ["Details", profileDetailParts(profile, { fileMissing }).join(pc.dim(" · "))],
161
169
  ["Server", fileMissing ? pc.red(profile.baseUrl) : profile.baseUrl],
162
170
  ])));
package/src/process.mjs CHANGED
@@ -113,16 +113,31 @@ export async function stopProfile(profile) {
113
113
 
114
114
  export async function isProfileRunning(profile) {
115
115
  const backend = backendFor(profile.backend);
116
- if (backend.type === "managed-server") return await serverReady(profile.baseUrl);
116
+ if (backend.type === "managed-server") {
117
+ return await serverReady(profile.baseUrl) && (await modelLoadedOnServer(profile));
118
+ }
117
119
  const state = await readState(profile.id);
118
120
  return Boolean(state?.pid && pidAlive(state.pid));
119
121
  }
120
122
 
123
+ export async function isProfileServerUp(profile) {
124
+ return await serverReady(profile.baseUrl);
125
+ }
126
+
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));
131
+ const { matches } = await serverMatchesProfile(profile);
132
+ return matches;
133
+ }
134
+
121
135
  export async function profileRuntimeStatus(profile) {
122
136
  const backend = backendFor(profile.backend);
123
137
  if (backend.type === "managed-server") {
124
138
  const ready = await serverReady(profile.baseUrl);
125
- 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 };
126
141
  }
127
142
  const state = await readState(profile.id);
128
143
  const running = Boolean(state?.pid && pidAlive(state.pid));
@@ -180,18 +195,79 @@ export async function waitForReady(profile, pid, rawLogPath) {
180
195
  // ── Internals ──────────────────────────────────────────────────────────────
181
196
 
182
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) {
183
229
  try {
184
- const response = await fetch(`${baseUrl.replace(/\/$/, "")}/models`, { signal: AbortSignal.timeout(1000) });
185
- if (!response.ok) return [];
186
- const body = await response.json();
187
- return (Array.isArray(body?.data) ? body.data : [])
188
- .map((model) => String(model?.id ?? "").trim())
189
- .filter(Boolean);
230
+ const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
231
+ if (!response.ok) return null;
232
+ return await response.json();
190
233
  } catch {
191
- return [];
234
+ return null;
192
235
  }
193
236
  }
194
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
+
195
271
  function expectedModelIds(profile) {
196
272
  const fileName = profile.modelPath ? basename(profile.modelPath) : null;
197
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() {},