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
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// oMLX runtime management — discovery, version checking, update prompts,
|
|
2
|
+
// and installation. Mirrors the llama.cpp runtime pattern in runtime.mjs.
|
|
3
|
+
//
|
|
4
|
+
// oMLX is installed either via the macOS app (DMG from GitHub Releases, which
|
|
5
|
+
// installs a ~/.omlx/bin/omlx CLI shim with in-app auto-update) or via Homebrew
|
|
6
|
+
// (brew tap jundot/omlx && brew install omlx). offgrid-ai does NOT download
|
|
7
|
+
// binaries directly — it uses brew for automated installs, and prompts for the
|
|
8
|
+
// DMG when brew is unavailable.
|
|
9
|
+
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
import { compareVersions } from "./updates.mjs";
|
|
16
|
+
import { hasHomebrew, ensureHomebrewFor } from "./config.mjs";
|
|
17
|
+
import { commandExists } from "./exec.mjs";
|
|
18
|
+
import { pc, renderCard, renderRows } from "./ui.mjs";
|
|
19
|
+
|
|
20
|
+
const execFileAsync = promisify(execFile);
|
|
21
|
+
|
|
22
|
+
const OMLX_CLI_SHIM = join(homedir(), ".omlx", "bin", "omlx");
|
|
23
|
+
const RELEASE_API = "https://api.github.com/repos/jundot/omlx/releases/latest";
|
|
24
|
+
|
|
25
|
+
// ── Discovery ──────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Find the oMLX CLI binary. Checks the app-installed shim first (it shadows
|
|
29
|
+
* brew on PATH), then falls back to PATH. Returns the path or null.
|
|
30
|
+
*/
|
|
31
|
+
export async function findOmlx() {
|
|
32
|
+
// 1. macOS app CLI shim (shadows brew on PATH)
|
|
33
|
+
if (existsSync(OMLX_CLI_SHIM)) return OMLX_CLI_SHIM;
|
|
34
|
+
|
|
35
|
+
// 2. PATH (brew install)
|
|
36
|
+
try {
|
|
37
|
+
const { stdout } = await execFileAsync("which", ["omlx"]);
|
|
38
|
+
const path = stdout.trim();
|
|
39
|
+
if (path && existsSync(path)) return path;
|
|
40
|
+
} catch { /* not on PATH */ }
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if oMLX is installed (app shim or on PATH).
|
|
47
|
+
*/
|
|
48
|
+
export async function hasOmlx() {
|
|
49
|
+
return (await findOmlx()) !== null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Detect how oMLX was installed: "app" (macOS app CLI shim) or "brew" (on PATH
|
|
54
|
+
* but not the shim) or null (not installed).
|
|
55
|
+
*/
|
|
56
|
+
export async function omlxInstallMethod() {
|
|
57
|
+
if (existsSync(OMLX_CLI_SHIM)) return "app";
|
|
58
|
+
if (await commandExists("omlx")) return "brew";
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Version checking ───────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get the installed oMLX version by running `omlx --version`.
|
|
66
|
+
* Returns null if oMLX is not installed or the version can't be parsed.
|
|
67
|
+
*/
|
|
68
|
+
export async function installedOmlxVersion() {
|
|
69
|
+
const bin = await findOmlx();
|
|
70
|
+
if (!bin) return null;
|
|
71
|
+
try {
|
|
72
|
+
const { stdout } = await execFileAsync(bin, ["--version"], { timeout: 5000 });
|
|
73
|
+
const match = stdout.trim().match(/(\d+\.\d+\.\d+)/u);
|
|
74
|
+
return match ? match[1] : null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check for the latest oMLX release on GitHub.
|
|
82
|
+
* Returns { tag, version } or null if the check fails.
|
|
83
|
+
* Callers must treat null as "check failed, skip prompt".
|
|
84
|
+
*/
|
|
85
|
+
export async function latestOmlxRelease(fetchImpl = globalThis.fetch) {
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetchImpl(RELEASE_API, { signal: AbortSignal.timeout(5000) });
|
|
88
|
+
if (!response.ok) return null;
|
|
89
|
+
const body = await response.json();
|
|
90
|
+
const tag = typeof body?.tag_name === "string" ? body.tag_name : null;
|
|
91
|
+
if (!tag) return null;
|
|
92
|
+
const version = tag.replace(/^v/u, "");
|
|
93
|
+
return { tag, version };
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Update prompt ──────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Check if an oMLX update is available and offer to install it.
|
|
103
|
+
* Returns true if an update was installed, false otherwise.
|
|
104
|
+
*
|
|
105
|
+
* For brew installs: runs `brew upgrade omlx`.
|
|
106
|
+
* For app installs: the app has in-app auto-update, so we just inform the user.
|
|
107
|
+
*/
|
|
108
|
+
export async function offerManagedOmlxUpdate(prompt, { fetchImpl = globalThis.fetch } = {}) {
|
|
109
|
+
const latest = await latestOmlxRelease(fetchImpl);
|
|
110
|
+
if (!latest) return false;
|
|
111
|
+
|
|
112
|
+
const installed = await installedOmlxVersion();
|
|
113
|
+
if (installed && compareVersions(installed, latest.version) >= 0) return false;
|
|
114
|
+
|
|
115
|
+
const method = await omlxInstallMethod();
|
|
116
|
+
|
|
117
|
+
console.log("\n" + renderCard("oMLX runtime", renderRows([
|
|
118
|
+
["Installed", installed ?? pc.yellow("not installed")],
|
|
119
|
+
["Latest", pc.green(latest.version)],
|
|
120
|
+
["Source", method === "app" ? "macOS app (in-app auto-update)" : "Homebrew"],
|
|
121
|
+
]), { formatBorder: pc.cyan }));
|
|
122
|
+
|
|
123
|
+
// App installs have their own auto-update — just inform the user
|
|
124
|
+
if (method === "app" && installed) {
|
|
125
|
+
console.log(pc.dim(" Update oMLX via the app's in-app updater, then restart offgrid-ai."));
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Not installed, or brew install — offer to install/upgrade
|
|
130
|
+
if (!installed) {
|
|
131
|
+
const shouldInstall = await prompt.yesNo("Install oMLX runtime?", false);
|
|
132
|
+
if (!shouldInstall) return false;
|
|
133
|
+
return await installOmlx(prompt);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Brew install with an update available
|
|
137
|
+
const shouldUpdate = await prompt.yesNo("Update oMLX runtime?", false);
|
|
138
|
+
if (!shouldUpdate) return false;
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const { runCommand } = await import("./exec.mjs");
|
|
142
|
+
console.log(pc.dim("Updating oMLX via Homebrew..."));
|
|
143
|
+
await runCommand("brew", ["update"], { label: "brew update" });
|
|
144
|
+
await runCommand("brew", ["upgrade", "omlx"], { label: "brew upgrade omlx" });
|
|
145
|
+
console.log(pc.green(`✓ Updated oMLX to latest`));
|
|
146
|
+
return true;
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.log(pc.red(`✗ Update failed: ${err.message}`));
|
|
149
|
+
console.log(pc.dim("Update manually: brew update && brew upgrade omlx"));
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Installation ───────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Install oMLX. Uses Homebrew if available (automating tap + install).
|
|
158
|
+
* If Homebrew is not available, prompts to download the DMG from GitHub
|
|
159
|
+
* Releases or install Homebrew first.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} prompt - UI prompt interface (yesNo, choice)
|
|
162
|
+
* @param {function} [run] - runCommand function for verbose command execution
|
|
163
|
+
* @returns {Promise<boolean>} true if installation succeeded
|
|
164
|
+
*/
|
|
165
|
+
export async function installOmlx(prompt, run) {
|
|
166
|
+
const hasBrew = await hasHomebrew();
|
|
167
|
+
|
|
168
|
+
if (!hasBrew) {
|
|
169
|
+
if (!(await ensureHomebrewFor(prompt, run || (async (cmd, args, label) => {
|
|
170
|
+
const { runCommand } = await import("./exec.mjs");
|
|
171
|
+
return runCommand(cmd, args, { label });
|
|
172
|
+
}), "oMLX"))) {
|
|
173
|
+
console.log(pc.dim("Install oMLX manually:"));
|
|
174
|
+
console.log(pc.dim(" brew tap jundot/omlx && brew install omlx"));
|
|
175
|
+
console.log(pc.dim(" — or download the macOS app from https://github.com/jundot/omlx/releases"));
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Install oMLX via Homebrew
|
|
181
|
+
const runner = run || (async (cmd, args, label) => {
|
|
182
|
+
const { runCommand } = await import("./exec.mjs");
|
|
183
|
+
return runCommand(cmd, args, { label });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
console.log(pc.cyan("Installing oMLX via Homebrew..."));
|
|
187
|
+
try {
|
|
188
|
+
await runner("brew", ["tap", "jundot/omlx", "https://github.com/jundot/omlx"], "oMLX tap");
|
|
189
|
+
await runner("brew", ["install", "omlx"], "oMLX");
|
|
190
|
+
console.log(pc.green("✓ oMLX installed"));
|
|
191
|
+
return true;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
console.log(pc.red(`✗ oMLX installation failed: ${err.message}`));
|
|
194
|
+
console.log(pc.dim("Install manually: brew tap jundot/omlx && brew install omlx"));
|
|
195
|
+
console.log(pc.dim(" — or download the macOS app from https://github.com/jundot/omlx/releases"));
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Ensure oMLX runtime is available. For onboarding: if not installed, offer
|
|
202
|
+
* to install it. This is the oMLX equivalent of ensureLlamaRuntime().
|
|
203
|
+
*
|
|
204
|
+
* @param {object} prompt - UI prompt interface
|
|
205
|
+
* @param {function} [run] - runCommand function for verbose output
|
|
206
|
+
* @returns {Promise<boolean>} true if oMLX is available (installed or pre-existing)
|
|
207
|
+
*/
|
|
208
|
+
export async function ensureOmlxRuntime(prompt, run) {
|
|
209
|
+
let omlxBin = await findOmlx();
|
|
210
|
+
if (!omlxBin) {
|
|
211
|
+
console.log(renderCard("oMLX runtime", renderRows([
|
|
212
|
+
["Status", pc.yellow("not installed")],
|
|
213
|
+
["Used for", "local MLX models (Apple Silicon optimized)"],
|
|
214
|
+
["Install", "managed by offgrid-ai via Homebrew"],
|
|
215
|
+
]), { formatBorder: pc.cyan }));
|
|
216
|
+
|
|
217
|
+
const shouldInstall = await prompt.yesNo("Install oMLX runtime?", true);
|
|
218
|
+
if (shouldInstall) {
|
|
219
|
+
await installOmlx(prompt, run);
|
|
220
|
+
omlxBin = await findOmlx();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!omlxBin) {
|
|
224
|
+
console.log(pc.yellow("Skipping oMLX for now. You can still use llama.cpp, or run offgrid-ai again to install."));
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const version = await installedOmlxVersion();
|
|
230
|
+
console.log(pc.green(`✓ oMLX: ${omlxBin}${version ? ` (v${version})` : ""}`));
|
|
231
|
+
return true;
|
|
232
|
+
}
|
package/src/process.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { basename, join } from "node:path";
|
|
|
6
6
|
import { LOG_DIR } from "./config.mjs";
|
|
7
7
|
import { writeState, readState, profileDir } from "./profiles.mjs";
|
|
8
8
|
import { backendFor, backendBinaryFor } from "./backends.mjs";
|
|
9
|
+
import { pc } from "./ui.mjs";
|
|
9
10
|
|
|
10
11
|
const execFileAsync = promisify(execFile);
|
|
11
12
|
|
|
@@ -21,32 +22,17 @@ export async function computeServerCommand(profile) {
|
|
|
21
22
|
const binary = await backendBinaryFor(profile.backend);
|
|
22
23
|
if (!binary) throw new Error("Server binary not found. Run offgrid-ai interactively to install.");
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
argv = result.args;
|
|
34
|
-
extraEnv = { APC_ENABLED: "1", MLX_VLM_MAX_TOKENS: "16384" };
|
|
35
|
-
} else {
|
|
36
|
-
// llama-cpp / llama-cpp-mtp
|
|
37
|
-
const { computeFlags } = await import("./autodetect.mjs");
|
|
38
|
-
const result = computeFlags(
|
|
39
|
-
profile.capabilities ?? {},
|
|
40
|
-
profile.modelPath,
|
|
41
|
-
profile.mmprojPath,
|
|
42
|
-
profile.drafterPath,
|
|
43
|
-
profile.flags ?? {},
|
|
44
|
-
);
|
|
45
|
-
argv = result.argv;
|
|
46
|
-
extraEnv = {};
|
|
47
|
-
}
|
|
25
|
+
// llama-cpp
|
|
26
|
+
const { computeFlags } = await import("./autodetect.mjs");
|
|
27
|
+
const result = computeFlags(
|
|
28
|
+
profile.capabilities ?? {},
|
|
29
|
+
profile.modelPath,
|
|
30
|
+
profile.mmprojPath,
|
|
31
|
+
profile.drafterPath,
|
|
32
|
+
profile.flags ?? {},
|
|
33
|
+
);
|
|
48
34
|
|
|
49
|
-
return { binary, argv, extraEnv, backend };
|
|
35
|
+
return { binary, argv: result.argv, extraEnv: {}, backend };
|
|
50
36
|
}
|
|
51
37
|
|
|
52
38
|
/** Build a runnable start.sh script for the profile. */
|
|
@@ -132,19 +118,83 @@ async function startLocalServer(profile) {
|
|
|
132
118
|
}
|
|
133
119
|
|
|
134
120
|
async function startManagedServer(profile, backend) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
for (let i = 0; i < 60; i++) {
|
|
140
|
-
await sleep(2000);
|
|
141
|
-
if (await serverReady(profile.baseUrl)) break;
|
|
142
|
-
process.stdout.write(".");
|
|
121
|
+
if (await serverReady(profile.baseUrl)) {
|
|
122
|
+
// Apply per-model settings (MTP) even when server is already running.
|
|
123
|
+
if (backend.id === "omlx" && profile.capabilities?.mtp) {
|
|
124
|
+
await ensureOmlxMtpSetting(profile);
|
|
143
125
|
}
|
|
144
|
-
|
|
145
|
-
|
|
126
|
+
return writeManagedState(profile, backend);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Try to start the managed server via CLI
|
|
130
|
+
if (backend.id === "omlx") {
|
|
131
|
+
try {
|
|
132
|
+
const { execFile } = await import("node:child_process");
|
|
133
|
+
const { promisify } = await import("node:util");
|
|
134
|
+
const { findOmlx } = await import("./omlx-runtime.mjs");
|
|
135
|
+
const omlxBin = await findOmlx();
|
|
136
|
+
if (!omlxBin) {
|
|
137
|
+
throw new Error(`${backend.label} is not installed. Run offgrid-ai to install it, or install manually: brew tap jundot/omlx && brew install omlx`);
|
|
138
|
+
}
|
|
139
|
+
await promisify(execFile)(omlxBin, ["start"], { timeout: 10000 });
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (err.message.includes("not installed")) throw err;
|
|
142
|
+
throw new Error(`${backend.label} could not be auto-started: ${err.message}. Run \`omlx start\` manually.`, { cause: err });
|
|
146
143
|
}
|
|
147
144
|
}
|
|
145
|
+
|
|
146
|
+
// Wait for it to come up
|
|
147
|
+
for (let i = 0; i < 60; i++) {
|
|
148
|
+
await sleep(2000);
|
|
149
|
+
if (await serverReady(profile.baseUrl)) break;
|
|
150
|
+
process.stdout.write(".");
|
|
151
|
+
}
|
|
152
|
+
if (!(await serverReady(profile.baseUrl))) {
|
|
153
|
+
throw new Error(`${backend.label} is not responding at ${profile.baseUrl}. Start it and try again.`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Apply per-model settings (MTP) before the model is loaded.
|
|
157
|
+
// oMLX applies MTP patches at load time, so the setting must be in
|
|
158
|
+
// model_settings.json before any request triggers a load.
|
|
159
|
+
if (backend.id === "omlx" && profile.capabilities?.mtp) {
|
|
160
|
+
await ensureOmlxMtpSetting(profile);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return writeManagedState(profile, backend);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Enable MTP on an oMLX model via the admin API before loading.
|
|
168
|
+
* oMLX applies MTP patches at model load time, so the setting must be
|
|
169
|
+
* persisted to model_settings.json before any request triggers a load.
|
|
170
|
+
* If the model is already loaded, oMLX will use the setting on next reload.
|
|
171
|
+
*/
|
|
172
|
+
async function ensureOmlxMtpSetting(profile) {
|
|
173
|
+
const baseUrl = profile.baseUrl?.replace(/\/v1\/?$/u, "") || "";
|
|
174
|
+
const modelId = profile.omlxModel ?? profile.modelAlias ?? profile.id;
|
|
175
|
+
try {
|
|
176
|
+
const response = await fetch(`${baseUrl}/admin/api/models/${encodeURIComponent(modelId)}/settings`, {
|
|
177
|
+
method: "PUT",
|
|
178
|
+
headers: { "Content-Type": "application/json" },
|
|
179
|
+
body: JSON.stringify({ mtp_enabled: true }),
|
|
180
|
+
signal: AbortSignal.timeout(5000),
|
|
181
|
+
});
|
|
182
|
+
if (response.ok) {
|
|
183
|
+
console.log(pc.green(`[mtp] Enabled MTP speculative decoding for ${modelId}`));
|
|
184
|
+
} else if (response.status === 401 || response.status === 403) {
|
|
185
|
+
console.log(pc.yellow(`[mtp] Could not enable MTP: oMLX admin authentication required. Enable skip_api_key_verification in oMLX settings, or enable MTP manually from the admin panel.`));
|
|
186
|
+
} else if (response.status === 404) {
|
|
187
|
+
console.log(pc.yellow(`[mtp] Model ${modelId} not found on oMLX server. MTP setting not applied.`));
|
|
188
|
+
} else {
|
|
189
|
+
const detail = await response.text().catch(() => "");
|
|
190
|
+
console.log(pc.yellow(`[mtp] Could not enable MTP: HTTP ${response.status} ${detail}`));
|
|
191
|
+
}
|
|
192
|
+
} catch (err) {
|
|
193
|
+
console.log(pc.yellow(`[mtp] Could not enable MTP: ${err.message}`));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function writeManagedState(profile, backend) {
|
|
148
198
|
const state = {
|
|
149
199
|
pid: null,
|
|
150
200
|
profileId: profile.id,
|
|
@@ -180,10 +230,7 @@ export async function stopProfile(profile) {
|
|
|
180
230
|
}
|
|
181
231
|
|
|
182
232
|
// Reliably terminate a detached local-server process group: SIGTERM with a
|
|
183
|
-
// grace period for graceful shutdown
|
|
184
|
-
// model), then SIGKILL if still alive. Guarantees the model is unloaded when a
|
|
185
|
-
// profile stops — consistent across backends (llama-server exits on SIGTERM;
|
|
186
|
-
// mlx-vlm/uvicorn often does not, hence the SIGKILL fallback).
|
|
233
|
+
// grace period for graceful shutdown, then SIGKILL if still alive.
|
|
187
234
|
async function terminateProcess(pid) {
|
|
188
235
|
const signalGroup = (sig) => {
|
|
189
236
|
try { process.kill(-pid, sig); }
|
|
@@ -226,9 +273,7 @@ async function processGone(pid) {
|
|
|
226
273
|
export async function unloadModelFromServer(profile) {
|
|
227
274
|
const backend = backendFor(profile.backend);
|
|
228
275
|
|
|
229
|
-
if (backend.id === "llama-cpp"
|
|
230
|
-
// llama.cpp unloads when the server process exits; no HTTP unload API exists.
|
|
231
|
-
// If offgrid-ai started the server, stopProfile already handled it.
|
|
276
|
+
if (backend.id === "llama-cpp") {
|
|
232
277
|
return { unloaded: false, backend: backend.id, reason: "stop server to unload" };
|
|
233
278
|
}
|
|
234
279
|
|
|
@@ -236,12 +281,6 @@ export async function unloadModelFromServer(profile) {
|
|
|
236
281
|
return await unloadOmlxModel(profile);
|
|
237
282
|
}
|
|
238
283
|
|
|
239
|
-
if (backend.id === "mlx-vlm") {
|
|
240
|
-
// mlx-vlm is a local-server backend — stopProfile handles unload by killing
|
|
241
|
-
// the process. No HTTP unload API.
|
|
242
|
-
return { unloaded: false, backend: backend.id, reason: "stop server to unload" };
|
|
243
|
-
}
|
|
244
|
-
|
|
245
284
|
return { unloaded: false, backend: backend.id, reason: "unsupported backend" };
|
|
246
285
|
}
|
|
247
286
|
|
package/src/profile-setup.mjs
CHANGED
|
@@ -3,13 +3,13 @@ import { execFile } from "node:child_process";
|
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
4
|
import { estimateMemory } from "./estimate.mjs";
|
|
5
5
|
import { findLlamaServer } from "./config.mjs";
|
|
6
|
-
import { baseUrlForFlags
|
|
6
|
+
import { baseUrlForFlags } from "./backends.mjs";
|
|
7
7
|
import { pc, formatBytes, renderRows, renderSection } from "./ui.mjs";
|
|
8
8
|
import { detectCapabilities } from "./autodetect.mjs";
|
|
9
9
|
import { matchDrafter } from "./scan.mjs";
|
|
10
10
|
import { scanGgufModels } from "./scan.mjs";
|
|
11
|
-
import { estimateMemoryMb } from "./mlx-flags.mjs";
|
|
12
11
|
import { capabilitySummary } from "./model-summary.mjs";
|
|
12
|
+
import { detectOmlxMtpCapability, findOmlxModelDir } from "./mlx-discovery.mjs";
|
|
13
13
|
|
|
14
14
|
const execFileAsync = promisify(execFile);
|
|
15
15
|
|
|
@@ -50,19 +50,9 @@ export async function configureLocalProfile(prompt, profile) {
|
|
|
50
50
|
}
|
|
51
51
|
const hasMtp = freshCaps.mtp || Boolean(drafterPath);
|
|
52
52
|
const caps = { ...freshCaps, mtp: hasMtp };
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
// If the profile was MTP but the drafter is now gone (and the model isn't
|
|
58
|
-
// natively MTP), switch back to plain llama.cpp so the server can start.
|
|
59
|
-
if (!hasMtp && configured.backend === "llama-cpp-mtp") {
|
|
60
|
-
console.log(pc.yellow("MTP drafter no longer found — switching to llama.cpp without speculative decoding."));
|
|
61
|
-
configured = removeMtpDefaults(configured);
|
|
62
|
-
}
|
|
63
|
-
if (drafterPath && !configured.drafterPath) {
|
|
64
|
-
configured = { ...configured, drafterPath };
|
|
65
|
-
}
|
|
53
|
+
// MTP is a capability, not a separate backend. Just update the profile's
|
|
54
|
+
// capabilities and drafter path — flag computation handles the rest.
|
|
55
|
+
configured = { ...configured, drafterPath: drafterPath ?? null, capabilities: { ...configured.capabilities, mtp: hasMtp } };
|
|
66
56
|
// If vision was previously disabled but mmproj is back, re-enable
|
|
67
57
|
if (configured.disabledMmprojPath && configured.mmprojPath === null && freshCaps.vision) {
|
|
68
58
|
configured = { ...configured, mmprojPath: configured.disabledMmprojPath, disabledMmprojPath: undefined, capabilities: { ...configured.capabilities, vision: true, visionDisabledReason: undefined } };
|
|
@@ -82,8 +72,7 @@ export async function configureLocalProfile(prompt, profile) {
|
|
|
82
72
|
if (caps.mtp) {
|
|
83
73
|
const drafterInfo = configured.drafterPath ? `\n Drafter: ${configured.drafterPath}` : "";
|
|
84
74
|
console.log(renderSection("MTP detected", renderRows([
|
|
85
|
-
["
|
|
86
|
-
["Port", String(LLAMA_CPP_MTP_PORT)],
|
|
75
|
+
["Feature", "Multi-Token Prediction (speculative decoding)"],
|
|
87
76
|
["Flags", `--spec-type draft-mtp --spec-draft-n-max 4${configured.drafterPath ? " --spec-draft-model <drafter>" : ""}`],
|
|
88
77
|
])));
|
|
89
78
|
if (drafterInfo) console.log(pc.dim(drafterInfo));
|
|
@@ -151,24 +140,60 @@ export function applyRuntimeFlagOverrides(profile, overrides) {
|
|
|
151
140
|
}
|
|
152
141
|
|
|
153
142
|
export function applyMtpDefaults(profile) {
|
|
154
|
-
const flags = { ...profile.flags, port: LLAMA_CPP_MTP_PORT };
|
|
155
143
|
return applyProfileFlags({
|
|
156
144
|
...profile,
|
|
157
|
-
backend: "llama-cpp-mtp",
|
|
158
|
-
providerId: "llama-cpp-mtp",
|
|
159
145
|
capabilities: { ...(profile.capabilities ?? {}), mtp: true },
|
|
160
|
-
}, flags);
|
|
146
|
+
}, profile.flags);
|
|
161
147
|
}
|
|
162
148
|
|
|
163
149
|
export function removeMtpDefaults(profile) {
|
|
164
|
-
const flags = { ...profile.flags, port: LLAMA_CPP_PORT };
|
|
165
150
|
return applyProfileFlags({
|
|
166
151
|
...profile,
|
|
167
|
-
backend: "llama-cpp",
|
|
168
|
-
providerId: "llama-cpp",
|
|
169
152
|
drafterPath: null,
|
|
170
153
|
capabilities: { ...(profile.capabilities ?? {}), mtp: false },
|
|
171
|
-
}, flags);
|
|
154
|
+
}, profile.flags);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── oMLX (managed server) profile configuration ───────────────────────────
|
|
158
|
+
|
|
159
|
+
export async function configureManagedProfile(prompt, profile) {
|
|
160
|
+
let configured = profile;
|
|
161
|
+
const modelId = profile.omlxModel ?? profile.modelAlias ?? profile.id;
|
|
162
|
+
|
|
163
|
+
// Detect MTP capability from the model's config.json
|
|
164
|
+
const modelDir = await findOmlxModelDir(modelId);
|
|
165
|
+
if (modelDir) {
|
|
166
|
+
const mtpResult = await detectOmlxMtpCapability(modelDir);
|
|
167
|
+
if (mtpResult.compatible) {
|
|
168
|
+
console.log("");
|
|
169
|
+
console.log(renderSection("MTP detected", renderRows([
|
|
170
|
+
["Feature", "Multi-Token Prediction (speculative decoding)"],
|
|
171
|
+
["Mechanism", "oMLX native MTP (enabled via admin API at load time)"],
|
|
172
|
+
])));
|
|
173
|
+
const useMtp = await prompt.yesNo("Use MTP speculative decoding?", true);
|
|
174
|
+
configured = { ...configured, capabilities: { ...(configured.capabilities ?? {}), mtp: useMtp } };
|
|
175
|
+
} else if (mtpResult.reason !== "model has no MTP heads in config") {
|
|
176
|
+
// Model declares MTP but can't use it — surface the reason
|
|
177
|
+
console.log("");
|
|
178
|
+
console.log(renderSection("MTP not available", renderRows([
|
|
179
|
+
["Feature", "Multi-Token Prediction (speculative decoding)"],
|
|
180
|
+
["Reason", pc.yellow(mtpResult.reason)],
|
|
181
|
+
])));
|
|
182
|
+
}
|
|
183
|
+
// If reason is "no MTP heads in config", don't surface anything —
|
|
184
|
+
// most models don't have MTP, and showing a card for every non-MTP
|
|
185
|
+
// model would be noise.
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.log("");
|
|
189
|
+
console.log(renderSection("Model setup", renderRows([
|
|
190
|
+
["Model", pc.bold(profile.label)],
|
|
191
|
+
["Backend", "oMLX"],
|
|
192
|
+
...(configured.capabilities?.mtp ? [["MTP", "enabled"]] : []),
|
|
193
|
+
])));
|
|
194
|
+
|
|
195
|
+
if (!(await prompt.yesNo("Save profile with these settings?", true))) return null;
|
|
196
|
+
return configured;
|
|
172
197
|
}
|
|
173
198
|
|
|
174
199
|
function applyVisionDefaults(profile) {
|
|
@@ -248,92 +273,4 @@ function samplingSummary(flags) {
|
|
|
248
273
|
return `temp ${flags.temperature}, top-p ${flags.topP}, top-k ${flags.topK}`;
|
|
249
274
|
}
|
|
250
275
|
|
|
251
|
-
// ── MLX profile configuration ─────────────────────────────────────────────
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Interactive configuration for an mlx-vlm profile.
|
|
255
|
-
*/
|
|
256
|
-
export async function configureMlxProfile(prompt, profile) {
|
|
257
|
-
let configured = profile;
|
|
258
|
-
|
|
259
|
-
console.log("");
|
|
260
|
-
console.log(renderSection("Model setup", renderRows([
|
|
261
|
-
["Model", pc.bold(profile.label)],
|
|
262
|
-
["Detected", mlxDetectionSummary(configured.capabilities)],
|
|
263
|
-
["Context", String(configured.flags.ctxSize) + " tokens"],
|
|
264
|
-
])));
|
|
265
|
-
console.log(pc.dim("Larger context windows use more memory. You can edit the profile later if needed.\n"));
|
|
266
|
-
|
|
267
|
-
if (configured.capabilities.vision) {
|
|
268
|
-
console.log(renderSection("Vision detected", renderRows([
|
|
269
|
-
["Capability", "image / multimodal input"],
|
|
270
|
-
["Note", "mlx-vlm loads vision from the model directory automatically."],
|
|
271
|
-
])));
|
|
272
|
-
}
|
|
273
276
|
|
|
274
|
-
if (configured.capabilities.thinking) {
|
|
275
|
-
console.log("");
|
|
276
|
-
console.log(renderSection("Thinking mode", renderRows([
|
|
277
|
-
["Flag", "--enable-thinking"],
|
|
278
|
-
["Default", "on for Qwen 3 / Gemma 4 / DeepSeek-R class models"],
|
|
279
|
-
])));
|
|
280
|
-
const useThinking = await prompt.yesNo("Enable thinking mode?", true);
|
|
281
|
-
configured = await applyMlxThinkingToggle(configured, useThinking);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const ctxSize = await prompt.number("Context window tokens", configured.flags.ctxSize, 1024, 1048576);
|
|
285
|
-
configured = applyMlxContextSize(configured, ctxSize);
|
|
286
|
-
|
|
287
|
-
console.log("\n" + renderMlxMemoryEstimate(configured));
|
|
288
|
-
|
|
289
|
-
console.log("");
|
|
290
|
-
console.log(renderSection("Defaults", renderRows([
|
|
291
|
-
["Backend", configured.backend],
|
|
292
|
-
["Endpoint", configured.baseUrl],
|
|
293
|
-
["Context", String(configured.flags.ctxSize) + " tokens"],
|
|
294
|
-
["Thinking", configured.capabilities?.thinking ? "on" : "off"],
|
|
295
|
-
["Vision", configured.capabilities.vision ? "yes" : "no"],
|
|
296
|
-
])));
|
|
297
|
-
|
|
298
|
-
if (!(await prompt.yesNo("Save profile with these settings?", true))) return null;
|
|
299
|
-
return configured;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
async function applyMlxThinkingToggle(profile, enabled) {
|
|
303
|
-
if (!profile.capabilities.thinking) return profile;
|
|
304
|
-
return {
|
|
305
|
-
...profile,
|
|
306
|
-
capabilities: { ...profile.capabilities, thinkingEnabled: enabled },
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function applyMlxContextSize(profile, ctxSize) {
|
|
311
|
-
const flags = { ...profile.flags, ctxSize };
|
|
312
|
-
return {
|
|
313
|
-
...profile,
|
|
314
|
-
flags,
|
|
315
|
-
baseUrl: baseUrlForFlags(flags),
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function renderMlxMemoryEstimate(profile) {
|
|
320
|
-
const modelBytes = profile.modelSizeBytes || 0;
|
|
321
|
-
if (!modelBytes) {
|
|
322
|
-
return renderSection("Memory estimate", pc.dim("Model size unknown — save the profile to estimate."));
|
|
323
|
-
}
|
|
324
|
-
const totalMb = estimateMemoryMb(modelBytes);
|
|
325
|
-
const overheadBytes = Math.max(0, totalMb * 1024 * 1024 - modelBytes);
|
|
326
|
-
return renderSection("Memory estimate", renderRows([
|
|
327
|
-
["Estimated total", pc.bold(`~${formatBytes(totalMb * 1024 * 1024)}`)],
|
|
328
|
-
["Model", formatBytes(modelBytes)],
|
|
329
|
-
["Overhead", `~${formatBytes(overheadBytes)} (KV cache, APC, runtime)`],
|
|
330
|
-
]));
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function mlxDetectionSummary(caps) {
|
|
334
|
-
const parts = [];
|
|
335
|
-
if (caps.architecture) parts.push(caps.architecture);
|
|
336
|
-
if (caps.thinking) parts.push("thinking");
|
|
337
|
-
if (caps.vision) parts.push("vision");
|
|
338
|
-
return parts.length > 0 ? parts.join(" · ") : "standard MLX";
|
|
339
|
-
}
|