offgrid-ai 0.15.9 → 0.16.3

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
@@ -2,10 +2,8 @@ import { existsSync } from "node:fs";
2
2
  import { mkdir, readdir, rm, unlink, writeFile, readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { PROFILE_DIR, RUN_DIR, LOG_DIR } from "./config.mjs";
5
- import { backendFor, baseUrlForFlags, defaultFlagsForBackend } from "./backends.mjs";
5
+ import { backendFor, baseUrlForFlags, defaultFlagsForBackend, BACKENDS } from "./backends.mjs";
6
6
  import { computeFlags } from "./autodetect.mjs";
7
- import { detectMlxCapabilities, defaultMlxContextLength } from "./mlx-discovery.mjs";
8
- import { detectHardware } from "./hardware.mjs";
9
7
  import { readJson, writeJson } from "./json.mjs";
10
8
 
11
9
  // ── Path helpers ───────────────────────────────────────────────────────────
@@ -42,7 +40,15 @@ export async function loadProfiles() {
42
40
  .filter((e) => e.isDirectory() && existsSync(profileJsonPath(e.name)))
43
41
  .map((e) => e.name)
44
42
  .sort();
45
- return Promise.all(ids.map((id) => readProfile(id)));
43
+ return (await Promise.all(ids.map((id) => readProfile(id))))
44
+ .map((p) => {
45
+ // Migrate legacy llama-cpp-mtp backend → llama-cpp with mtp capability
46
+ if (p.backend === "llama-cpp-mtp") {
47
+ return { ...p, backend: "llama-cpp", providerId: "llama-cpp", capabilities: { ...(p.capabilities ?? {}), mtp: true } };
48
+ }
49
+ return p;
50
+ })
51
+ .filter((p) => BACKENDS[p.backend]);
46
52
  }
47
53
 
48
54
  export async function readProfile(id) {
@@ -51,7 +57,7 @@ export async function readProfile(id) {
51
57
  return JSON.parse(await readFile(path, "utf8"));
52
58
  }
53
59
 
54
- export async function saveProfile(profile, options = {}) {
60
+ export async function saveProfile(profile) {
55
61
  const id = sanitizeProfileId(profile.id);
56
62
  const dir = profileDir(id);
57
63
  await mkdir(dir, { recursive: true });
@@ -128,7 +134,7 @@ export async function createProfileFromModel(model, backendId, drafterPath) {
128
134
  const caps = detectCapabilities(model.path, model.mmprojPath);
129
135
  // If a drafter is provided, this model supports MTP regardless of filename
130
136
  const hasMtp = caps.mtp || Boolean(drafterPath);
131
- const backend = backendId ?? (hasMtp ? "llama-cpp-mtp" : "llama-cpp");
137
+ const backend = backendId ?? "llama-cpp";
132
138
  const { flags } = computeFlags(
133
139
  { ...caps, mtp: hasMtp },
134
140
  model.path,
@@ -152,28 +158,6 @@ export async function createProfileFromModel(model, backendId, drafterPath) {
152
158
  });
153
159
  }
154
160
 
155
- // ── Auto-create profile from a discovered MLX model ────────────────────────
156
-
157
- export async function createProfileFromMlxModel(model) {
158
- const { DEFAULT_PORT } = await import("./mlx-flags.mjs");
159
- const caps = await detectMlxCapabilities(model.filePath);
160
- const ctxSize = defaultMlxContextLength(caps.contextLength, detectHardware().totalRamBytes / (1024 ** 3));
161
- return normalizeProfile({
162
- id: slugFromLabel(model.label),
163
- label: model.label,
164
- backend: "mlx-vlm",
165
- providerId: "mlx-vlm",
166
- modelAlias: model.label,
167
- source: model.source,
168
- modelPath: model.filePath,
169
- mmprojPath: null,
170
- drafterPath: null,
171
- modelSizeBytes: model.sizeBytes,
172
- capabilities: caps,
173
- flags: { host: "127.0.0.1", port: DEFAULT_PORT, ctxSize },
174
- });
175
- }
176
-
177
161
  function summarizeCapabilities(caps) {
178
162
  return {
179
163
  architecture: caps.architecture,
package/src/ui.mjs CHANGED
@@ -106,25 +106,7 @@ export function renderCard(title, body, options = {}) {
106
106
  }
107
107
 
108
108
  function wrapVisible(text, width) {
109
- const words = String(text).split(/(\s+)/u);
110
- const lines = [];
111
- let current = "";
112
- for (let word of words) {
113
- // If a single word exceeds the width, hard-break it
114
- while (visibleLen(word) > width) {
115
- if (current.trim()) { lines.push(current.trimEnd()); current = ""; }
116
- lines.push(word.slice(0, width));
117
- word = word.slice(width);
118
- }
119
- if (visibleLen(current + word) > width && current.trim()) {
120
- lines.push(current.trimEnd());
121
- current = word.trimStart();
122
- } else {
123
- current += word;
124
- }
125
- }
126
- if (current.trim()) lines.push(current.trimEnd());
127
- return lines.length > 0 ? lines : [text];
109
+ return wrapText(text, width);
128
110
  }
129
111
 
130
112
  export function renderSectionRows(title, rows, options = {}) {
@@ -221,6 +203,7 @@ export function createPrompt() {
221
203
  if (!Number.isFinite(input) || input < min || input > max) {
222
204
  return `Enter a number from ${min} to ${max}.`;
223
205
  }
206
+ return true;
224
207
  },
225
208
  });
226
209
  return Number(value);
@@ -1,112 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- mlx-vlm server wrapper with strict=False model loading + APC merge fix.
4
-
5
- Two monkey-patches are applied before the server starts:
6
-
7
- 1. strict=False model loading — needed for architectures with shared-KV weight
8
- schemes (e.g. Gemma 4). Most models (Qwen, Llama, Mistral, Phi) load fine
9
- with strict=True — strict=False is a no-op for them.
10
-
11
- 2. BatchRotatingKVCache.merge() shape-mismatch fix — upstream mlx-lm bug
12
- (ml-explore/mlx-lm PR #1116, Blaizzy/mlx-vlm Issue #923). The merge() method
13
- crashes with `ValueError: [broadcast_shapes] Shapes (1,1,28,256) and
14
- (1,1,512,256) cannot be broadcast` when APC merges exact-cache entries with
15
- different fill levels. This affects all sliding-window attention models
16
- (Gemma 4, Mistral, Mixtral). The fix uses explicit slicing instead of
17
- negative indexing to guarantee exactly `l` elements are extracted.
18
-
19
- This patch can be removed once mlx-lm fixes merge() upstream (not fixed in
20
- 0.31.2 or 0.31.3 — the merge() method is identical in both).
21
-
22
- Benchmark finding: mlx-vlm clears Metal cache after every request (GitHub Issue
23
- #999) unless APC_ENABLED=1 is set. The env var is set by the Electron app at
24
- spawn time, not in this wrapper.
25
-
26
- Usage:
27
- python3 mlxvlm-server-wrapper.py --model <path> --host 127.0.0.1 --port <port>
28
- """
29
- import sys
30
-
31
- # ── Patch 1: strict=False model loading ──────────────────────────────────────
32
-
33
- import mlx_vlm.utils as _utils
34
- _orig_load_model = _utils.load_model
35
-
36
- def _patched_load_model(model_path, lazy=False, strict=True, **kwargs):
37
- return _orig_load_model(model_path, lazy=lazy, strict=False, **kwargs)
38
-
39
- _utils.load_model = _patched_load_model
40
-
41
- # ── Patch 2: BatchRotatingKVCache.merge() shape-mismatch fix ──────────────────
42
- #
43
- # Upstream bug: _temporal_order() can return a buffer whose seq dimension differs
44
- # from c.size(). The negative slice [..., -l:, :] then produces a mismatched shape,
45
- # crashing with ValueError: [broadcast_shapes].
46
- #
47
- # Fix: use explicit slicing to extract exactly `l` elements, right-aligning within
48
- # the target slice when the buffer is shorter than `l` (left-padded by zeros from
49
- # the pre-allocated target tensor).
50
-
51
- import mlx.core as mx
52
- from mlx_lm.models import cache as _lm_cache
53
-
54
- _orig_merge = _lm_cache.BatchRotatingKVCache.merge
55
-
56
- @classmethod
57
- def _patched_merge(cls, caches):
58
- if not all(c.max_size == caches[0].max_size for c in caches):
59
- raise ValueError(
60
- "BatchRotatingKVCache can only merge caches with the same maximum size"
61
- )
62
-
63
- offsets = [c.offset for c in caches]
64
- lengths = [c.size() for c in caches]
65
- max_length = max(lengths)
66
-
67
- if max_length == 0:
68
- return cls(caches[0].max_size, [0] * len(caches))
69
-
70
- padding = [max_length - l for l in lengths]
71
- B = len(caches)
72
- H = max(c.keys.shape[1] for c in caches if c.keys is not None)
73
- Dk = max(c.keys.shape[3] for c in caches if c.keys is not None)
74
- Dv = max(c.values.shape[3] for c in caches if c.values is not None)
75
- dt = next(iter(c.keys.dtype for c in caches if c.keys is not None))
76
-
77
- keys = mx.zeros((B, H, max_length, Dk), dtype=dt)
78
- values = mx.zeros((B, H, max_length, Dv), dtype=dt)
79
- for i, (p, l, c) in enumerate(zip(padding, lengths, caches)):
80
- if c.keys is None:
81
- continue
82
- ordered_k = c._temporal_order(c.keys)
83
- ordered_v = c._temporal_order(c.values)
84
- seq_len = ordered_k.shape[2]
85
- if seq_len >= l:
86
- # Normal case: extract the last `l` tokens.
87
- start = seq_len - l
88
- keys[i : i + 1, :, p : p + l] = ordered_k[..., start : start + l, :]
89
- values[i : i + 1, :, p : p + l] = ordered_v[..., start : start + l, :]
90
- else:
91
- # Buffer shorter than l: right-align within the slice (left-padded
92
- # by zeros from the pre-allocated target tensor).
93
- gap = l - seq_len
94
- keys[i : i + 1, :, p + gap : p + l] = ordered_k
95
- values[i : i + 1, :, p + gap : p + l] = ordered_v
96
-
97
- cache = cls(caches[0].max_size, padding)
98
- cache.keys = keys
99
- cache.values = values
100
- cache.offset = mx.array(offsets)
101
- cache._idx = keys.shape[2]
102
- cache._offset = keys.shape[2]
103
-
104
- return cache
105
-
106
- _lm_cache.BatchRotatingKVCache.merge = _patched_merge
107
-
108
- # ── Run the server ────────────────────────────────────────────────────────────
109
- # main() parses sys.argv for --model, --host, --port, etc.
110
- from mlx_vlm.server import main
111
- main()
112
-
package/src/mlx-flags.mjs DELETED
@@ -1,100 +0,0 @@
1
- // mlx-vlm server flag computation — pure functions, no side effects.
2
- // Ported from deprecated-offgrid-desktop/src/main/server-flags.ts (MLX subset).
3
- //
4
- // Benchmark-informed decisions (see sidequests/mlx-backend-benchmark/RESULTS.md):
5
- // - mlx-vlm requires APC_ENABLED=1 env var (86x TTFT improvement) — set at spawn
6
- // time in process.mjs, NOT here (this module only computes args).
7
- // - mlx-vlm uses a strict=False wrapper script for shared-KV architectures
8
- // (Gemma 4-class). Safe for all models — strict=False is a no-op for models
9
- // that load fine with strict=True.
10
- // - mlx-vlm uses --enable-thinking for thinking-mode control.
11
- // - mlx-vlm uses --max-kv-size for the KV cache / context window.
12
- //
13
- // Only the mlx-vlm-relevant logic is ported here. offgrid-ai's existing GGUF
14
- // flag logic (autodetect.mjs / profile-setup.mjs / estimate.mjs) is unchanged.
15
-
16
- import { fileURLToPath } from "node:url";
17
- import { dirname, join } from "node:path";
18
-
19
- const MB = 1024 ** 2;
20
-
21
- /** Default port for the local model server. Matches the desktop's DEFAULT_PORT. */
22
- export const DEFAULT_PORT = 18080;
23
-
24
- /** Resolved path to the bundled strict=False wrapper script (sibling of src/). */
25
- export const MLX_VLM_WRAPPER = join(dirname(fileURLToPath(import.meta.url)), "..", "resources", "mlxvlm-server-wrapper.py");
26
-
27
- /** Overhead multiplier for mlx-vlm: weights × 1.5 (covers KV cache, activations, APC cache; benchmark-validated). */
28
- const MLX_VLM_OVERHEAD_MULTIPLIER = 1.5;
29
-
30
- /** Server process overhead in MB. */
31
- const PROCESS_OVERHEAD_MB = 200;
32
-
33
- /**
34
- * Estimate mlx-vlm memory usage (MB): model weights × 1.5 + process overhead.
35
- *
36
- * The 1.5 multiplier covers KV cache, activations, and APC cache overhead
37
- * (benchmark-validated; see sidequests/mlx-backend-benchmark/RESULTS.md).
38
- * GGUF/llama-server estimation uses the detailed path in estimate.mjs.
39
- *
40
- * @param {number} fileSizeBytes - model size on disk (sum of MLX safetensors).
41
- * @returns {number} estimated memory in MB.
42
- */
43
- export function estimateMemoryMb(fileSizeBytes) {
44
- return Math.round((fileSizeBytes / MB) * MLX_VLM_OVERHEAD_MULTIPLIER + PROCESS_OVERHEAD_MB);
45
- }
46
-
47
- /**
48
- * Compute mlx-vlm server arguments.
49
- *
50
- * mlx-vlm is the MLX-native server (benchmark-validated best throughput + memory
51
- * efficiency on Apple Silicon). Invoked via the strict=False wrapper script for
52
- * compatibility with shared-KV architectures (Gemma 4-class).
53
- *
54
- * The APC_ENABLED=1 env var is MANDATORY but is set at spawn time in
55
- * process.mjs, not in args.
56
- *
57
- * The wrapper script (resources/mlxvlm-server-wrapper.py) applies strict=False
58
- * model loading + the BatchRotatingKVCache.merge() fix, both required for
59
- * shared-KV architectures (Gemma 4-class). It is resolved to a real path via
60
- * MLX_VLM_WRAPPER; there is intentionally no raw-mlx_vlm.server path.
61
- *
62
- * @param {string} modelPath - path to the MLX model directory.
63
- * @param {object} [options]
64
- * @param {number} [options.port] - port (default DEFAULT_PORT).
65
- * @param {number} [options.ctxSize] - context window (passed as --max-kv-size).
66
- * @param {boolean} [options.thinkingEnabled=true] - whether to enable thinking.
67
- * @returns {{ args: string[], port: number }}
68
- */
69
- export function computeMlxVlmFlags(modelPath, options = {}) {
70
- const port = options.port ?? DEFAULT_PORT;
71
- const ctxSize = options.ctxSize;
72
- const thinkingEnabled = options.thinkingEnabled ?? true;
73
-
74
- // The binary is "python3" (resolved by backendBinaryFor in backends.mjs); the
75
- // wrapper path is the first arg.
76
- const args = [
77
- MLX_VLM_WRAPPER,
78
- "--model", modelPath,
79
- "--host", "127.0.0.1",
80
- "--port", String(port),
81
- ];
82
-
83
- if (thinkingEnabled) {
84
- args.push("--enable-thinking");
85
- }
86
-
87
- // Context size: mlx-vlm uses --max-kv-size for the KV cache / context window.
88
- if (ctxSize && ctxSize > 0) {
89
- args.push("--max-kv-size", String(ctxSize));
90
- }
91
-
92
- // Default max output tokens — used when the client doesn't specify max_tokens
93
- // in the request. Pi's OpenAI completions provider never sends max_tokens
94
- // (it doesn't fall back to model.maxTokens like the Anthropic provider does).
95
- // llama-server defaults high; mlx-vlm defaults to 2048 which is too low for
96
- // coding tasks. Set a generous server-side default.
97
- args.push("--max-tokens", "16384");
98
-
99
- return { args, port };
100
- }