offgrid-ai 0.9.6 → 0.10.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/src/profiles.mjs CHANGED
@@ -4,6 +4,8 @@ import { join } from "node:path";
4
4
  import { PROFILE_DIR, RUN_DIR, LOG_DIR } from "./config.mjs";
5
5
  import { backendFor, baseUrlForFlags, defaultFlagsForBackend } from "./backends.mjs";
6
6
  import { computeFlags } from "./autodetect.mjs";
7
+ import { detectMlxCapabilities, defaultMlxContextLength } from "./mlx-discovery.mjs";
8
+ import { detectHardware } from "./hardware.mjs";
7
9
  import { readJson, writeJson } from "./json.mjs";
8
10
 
9
11
  // ── Path helpers ───────────────────────────────────────────────────────────
@@ -161,6 +163,34 @@ export async function createProfileFromModel(model, backendId, drafterPath) {
161
163
  });
162
164
  }
163
165
 
166
+ // ── Auto-create profile from a discovered MLX model ────────────────────────
167
+
168
+ export async function createProfileFromMlxModel(model) {
169
+ const { computeMlxVlmFlags, DEFAULT_PORT } = await import("./mlx-flags.mjs");
170
+ const caps = await detectMlxCapabilities(model.filePath);
171
+ const ctxSize = defaultMlxContextLength(caps.contextLength, detectHardware().totalRamBytes / (1024 ** 3));
172
+ const { args } = computeMlxVlmFlags(model.filePath, {
173
+ port: DEFAULT_PORT,
174
+ ctxSize,
175
+ thinkingEnabled: caps.thinking,
176
+ });
177
+ return normalizeProfile({
178
+ id: slugFromLabel(model.label),
179
+ label: model.label,
180
+ backend: "mlx-vlm",
181
+ providerId: "mlx-vlm",
182
+ modelAlias: model.label,
183
+ source: model.source,
184
+ modelPath: model.filePath,
185
+ mmprojPath: null,
186
+ drafterPath: null,
187
+ modelSizeBytes: model.sizeBytes,
188
+ capabilities: caps,
189
+ flags: { host: "127.0.0.1", port: DEFAULT_PORT, ctxSize },
190
+ commandArgv: args,
191
+ });
192
+ }
193
+
164
194
  function summarizeCapabilities(caps) {
165
195
  return {
166
196
  architecture: caps.architecture,
@@ -1,17 +1,59 @@
1
- import { totalmem } from "node:os";
2
-
3
- const MODEL_TIERS = [
4
- { maxGB: 8, lms: "google/gemma-4-e2b", ollama: "gemma4:e2b", label: "Gemma 4 E2B (2B effective)" },
5
- { maxGB: 16, lms: "google/gemma-4-e4b", ollama: "gemma4:e4b", label: "Gemma 4 E4B (4B effective)" },
6
- { maxGB: 32, lms: "qwen/qwen3.5-9b", ollama: "qwen3.5:9b-q4_K_M", label: "Qwen 3.5 9B" },
7
- { maxGB: Infinity, lms: "qwen/qwen3.6-35b-a3b", ollama: "qwen3.6:35b-a3b", label: "Qwen 3.6 35B-A3B" },
8
- ];
9
-
10
- export function recommendedModel() {
11
- const gb = totalmem() / (1024 ** 3);
12
- return MODEL_TIERS.find((tier) => gb <= tier.maxGB) ?? MODEL_TIERS[MODEL_TIERS.length - 1];
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { detectHardware } from "./hardware.mjs";
5
+
6
+ const GB = 1024 ** 3;
7
+
8
+ const RECOMMENDATIONS_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "resources", "recommendations.json");
9
+
10
+ function loadRecommendations() {
11
+ try {
12
+ const raw = readFileSync(RECOMMENDATIONS_PATH, "utf8");
13
+ return JSON.parse(raw).models ?? [];
14
+ } catch {
15
+ return [];
16
+ }
17
+ }
18
+
19
+ /** All curated model entries. */
20
+ export function getModelEntries() {
21
+ return loadRecommendations();
22
+ }
23
+
24
+ /** Recommend models that fit the detected hardware (max tier first). */
25
+ export function recommendModels(hardware) {
26
+ const entries = loadRecommendations();
27
+ const fitting = entries.filter((e) => e.minRamGb * GB <= hardware.totalRamBytes);
28
+ if (fitting.length === 0) return [];
29
+ const maxTier = Math.max(...fitting.map((e) => e.minRamGb));
30
+ // All models at the top fitting tier are genuine alternatives; sort by label
31
+ // so the pick is deterministic regardless of JSON order.
32
+ return fitting
33
+ .filter((e) => e.minRamGb === maxTier)
34
+ .sort((a, b) => a.label.localeCompare(b.label));
35
+ }
36
+
37
+ /** Pick the best format for the platform. */
38
+ export function selectFormat(entry, hardware) {
39
+ if (hardware.platform === "darwin" && hardware.arch === "arm64") {
40
+ if (entry.mlx) return "mlx";
41
+ if (entry.gguf) return "gguf";
42
+ } else {
43
+ if (entry.gguf) return "gguf";
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /** Primary recommendation for this machine. */
49
+ export function recommendedModel(hardware) {
50
+ const fitting = recommendModels(hardware ?? detectHardware());
51
+ return fitting[0] ?? null;
13
52
  }
14
53
 
15
- export function installedRamGB() {
16
- return (totalmem() / (1024 ** 3)).toFixed(0);
54
+ /** All models that fit, sorted best-first (tier desc, then label). */
55
+ export function allFittingModels(hardware) {
56
+ const entries = loadRecommendations();
57
+ const fitting = entries.filter((e) => e.minRamGb * GB <= hardware.totalRamBytes);
58
+ return fitting.sort((a, b) => b.minRamGb - a.minRamGb || a.label.localeCompare(b.label));
17
59
  }
package/src/scan.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  import { statSync } from "node:fs";
2
- import { readdir } from "node:fs/promises";
2
+ import { readdir, stat } 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
6
  import { parseModelName } from "./model-name.mjs";
7
+ import { inferSourceLabel, MIN_MODEL_SIZE_BYTES, EMBEDDING_MODEL_TYPES } from "./discovery-shared.mjs";
7
8
 
8
9
  // ── Scan for GGUF models and MTP drafters ────────────────────────────────
9
10
 
@@ -13,7 +14,8 @@ export async function scanGgufModels(dirs) {
13
14
  const allDrafters = [];
14
15
 
15
16
  for (const root of scanDirs) {
16
- const { models, drafters } = await scanOneDir(root);
17
+ const sourceLabel = inferSourceLabel(root);
18
+ const { models, drafters } = await scanOneDir(root, sourceLabel);
17
19
  allModels.push(...models);
18
20
  allDrafters.push(...drafters);
19
21
  }
@@ -36,7 +38,7 @@ export async function scanGgufModels(dirs) {
36
38
  return { models, drafters };
37
39
  }
38
40
 
39
- async function scanOneDir(root) {
41
+ async function scanOneDir(root, sourceLabel = "local-gguf") {
40
42
  const files = await findFiles(root, (path) => path.toLowerCase().endsWith(".gguf"));
41
43
  const mmprojs = files.filter((path) => basename(path).toLowerCase().includes("mmproj"));
42
44
  const candidates = files.filter((path) => !basename(path).toLowerCase().includes("mmproj"));
@@ -49,12 +51,15 @@ async function scanOneDir(root) {
49
51
  const mmprojPath = mmprojs.find((candidate) => dirname(candidate) === dir) ?? null;
50
52
  const name = basename(path).replace(/\.gguf$/i, "");
51
53
  const sizeBytes = statSync(path).size;
54
+ if (sizeBytes < MIN_MODEL_SIZE_BYTES) continue;
52
55
  const parsed = parseModelName(name, "local-gguf");
53
56
 
54
- // Read GGUF metadata to detect drafter architecture
57
+ // Read GGUF metadata to detect drafter architecture and embeddings
55
58
  const meta = safeReadGgufMetadata(path);
56
59
  const architecture = typeof meta["general.architecture"] === "string" ? meta["general.architecture"] : null;
57
60
 
61
+ if (isEmbeddingArchitecture(architecture, name)) continue;
62
+
58
63
  if (architecture === "gemma4-assistant" || architecture === "gemma4_assistant") {
59
64
  // This is an MTP drafter model, not a main model
60
65
  drafters.push({
@@ -66,7 +71,7 @@ async function scanOneDir(root) {
66
71
  architecture,
67
72
  targetHint: drafterTargetHint(name),
68
73
  backend: "llama-cpp",
69
- source: "local-gguf",
74
+ source: sourceLabel,
70
75
  });
71
76
  } else {
72
77
  models.push({
@@ -77,7 +82,7 @@ async function scanOneDir(root) {
77
82
  quant: parsed.quant,
78
83
  sizeBytes,
79
84
  backend: "llama-cpp",
80
- source: "local-gguf",
85
+ source: sourceLabel,
81
86
  });
82
87
  }
83
88
  }
@@ -85,6 +90,26 @@ async function scanOneDir(root) {
85
90
  return { models, drafters };
86
91
  }
87
92
 
93
+ // ── Embedding model filtering ─────────────────────────────────────────────
94
+
95
+ const EMBEDDING_FILENAME_PATTERNS = [
96
+ /(?:^|[-_])bge[-_]/i,
97
+ /(?:^|[-_])jina[-_]/i,
98
+ /(?:^|[-_])e5[-_]/i,
99
+ /(?:^|[-_])gte[-_]/i,
100
+ /(?:^|[-_])all[-_]minilm/i,
101
+ /(?:^|[-_])mpnet/i,
102
+ /(?:^|[-_])nomic[-_]embed/i,
103
+ /(?:^|[-_])embed/i,
104
+ /(?:^|[-_])rerank/i,
105
+ ];
106
+
107
+ export function isEmbeddingArchitecture(architecture, filename = "") {
108
+ if (architecture && EMBEDDING_MODEL_TYPES.has(architecture.toLowerCase())) return true;
109
+ const lowerName = filename.toLowerCase();
110
+ return EMBEDDING_FILENAME_PATTERNS.some((pattern) => pattern.test(lowerName));
111
+ }
112
+
88
113
  // ── Match drafters to target models ────────────────────────────────────
89
114
 
90
115
  // Map a drafter filename to a regex that matches its target model filenames.
@@ -137,8 +162,14 @@ async function findFiles(root, predicate) {
137
162
  }
138
163
  for (const entry of entries) {
139
164
  const path = join(dir, entry.name);
140
- if (entry.isDirectory()) await walk(path);
141
- else if (entry.isFile() && predicate(path)) result.push(path);
165
+ if (entry.isDirectory() || entry.isSymbolicLink()) {
166
+ // Follow symlinks (HF cache uses them) and avoid recursion loops.
167
+ const stats = await stat(path).catch(() => null);
168
+ if (stats?.isDirectory()) await walk(path);
169
+ else if (stats?.isFile() && predicate(path)) result.push(path);
170
+ } else if (entry.isFile() && predicate(path)) {
171
+ result.push(path);
172
+ }
142
173
  }
143
174
  }
144
175
  await walk(root);