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/README.md +4 -4
- package/package.json +1 -1
- package/src/autodetect.mjs +1 -1
- package/src/backends.mjs +4 -41
- package/src/benchmark/flow.mjs +14 -13
- package/src/benchmark/metrics.mjs +14 -20
- package/src/commands/main.mjs +7 -7
- package/src/commands/models.mjs +8 -21
- package/src/commands/onboard.mjs +10 -43
- package/src/commands/run.mjs +1 -1
- package/src/commands/status.mjs +19 -0
- package/src/config.mjs +48 -2
- package/src/harness-pi.mjs +5 -7
- package/src/managed.mjs +3 -3
- package/src/mlx-discovery.mjs +77 -258
- package/src/model-catalog.mjs +9 -14
- package/src/model-presenters.mjs +0 -30
- package/src/omlx-runtime.mjs +232 -0
- package/src/process.mjs +87 -48
- package/src/profile-setup.mjs +50 -113
- package/src/profiles.mjs +12 -28
- package/src/ui.mjs +2 -19
- package/resources/mlxvlm-server-wrapper.py +0 -112
- package/src/mlx-flags.mjs +0 -100
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
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
}
|