offgrid-ai 0.3.26 → 0.3.28
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 +1 -1
- package/src/autodetect.mjs +9 -2
- package/src/cli.mjs +44 -0
- package/src/config.mjs +7 -2
- package/src/profile-setup.mjs +34 -1
- package/src/profiles.mjs +1 -0
- package/src/runtime.mjs +130 -0
- package/src/scan.mjs +1 -1
package/package.json
CHANGED
package/src/autodetect.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { readGgufMetadata } from "./gguf.mjs";
|
|
|
6
6
|
|
|
7
7
|
export function detectCapabilities(modelPath, mmprojPath) {
|
|
8
8
|
const meta = safeReadGgufMetadata(modelPath);
|
|
9
|
+
const mmprojMeta = mmprojPath ? safeReadGgufMetadata(mmprojPath) : {};
|
|
9
10
|
const name = basename(modelPath).toLowerCase();
|
|
10
11
|
const pathHints = String(modelPath).toLowerCase();
|
|
11
12
|
|
|
@@ -25,13 +26,14 @@ export function detectCapabilities(modelPath, mmprojPath) {
|
|
|
25
26
|
|
|
26
27
|
// Vision — mmproj present
|
|
27
28
|
const vision = Boolean(mmprojPath && existsSync(mmprojPath));
|
|
29
|
+
const mmprojProjectorType = stringMeta(mmprojMeta, "clip.vision.projector_type") ?? stringMeta(mmprojMeta, "clip.audio.projector_type") ?? null;
|
|
28
30
|
|
|
29
31
|
// MTP (multi-token prediction) — detect speculative decoding.
|
|
30
32
|
// Do not treat all Qwen models as MTP; require an explicit filename or metadata hint.
|
|
31
33
|
const mtp = /\bmtp\b|draft-mtp|multi-token/i.test(pathHints) || Object.keys(meta).some((key) => /mtp|draft|speculative/i.test(key));
|
|
32
34
|
|
|
33
35
|
// Quantization
|
|
34
|
-
const quant = name.match(/(Q\d_K_[A-Z]+|UD-[A-Z0-9_]+)/i)?.[1] ?? null;
|
|
36
|
+
const quant = name.match(/(Q\d_K_[A-Z]+|Q\d_[01]|UD-[A-Z0-9_]+)/i)?.[1] ?? null;
|
|
35
37
|
|
|
36
38
|
// Context size from metadata, fallback to name hints
|
|
37
39
|
const metaCtx = architecture
|
|
@@ -39,7 +41,7 @@ export function detectCapabilities(modelPath, mmprojPath) {
|
|
|
39
41
|
: undefined;
|
|
40
42
|
const ctxSize = metaCtx ?? (thinking ? 80000 : 32768);
|
|
41
43
|
|
|
42
|
-
return { architecture, thinking, vision, mtp, qat, imatrix, quant, metaCtx, ctxSize, meta };
|
|
44
|
+
return { architecture, thinking, vision, mtp, qat, imatrix, quant, metaCtx, ctxSize, meta, mmprojProjectorType };
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
// ── Compute llama-server flags from capabilities ───────────────────────────
|
|
@@ -127,4 +129,9 @@ function safeReadGgufMetadata(modelPath) {
|
|
|
127
129
|
function numberMeta(meta, key) {
|
|
128
130
|
const value = key ? meta[key] : undefined;
|
|
129
131
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function stringMeta(meta, key) {
|
|
135
|
+
const value = key ? meta[key] : undefined;
|
|
136
|
+
return typeof value === "string" && value ? value : undefined;
|
|
130
137
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { removeInstallerPathEntries } from "./shell-path.mjs";
|
|
|
15
15
|
import { configureLocalProfile } from "./profile-setup.mjs";
|
|
16
16
|
import { buildPrettyCommand } from "./command.mjs";
|
|
17
17
|
import { detectCapabilities } from "./autodetect.mjs";
|
|
18
|
+
import { offerManagedLlamaRuntimeUpdate } from "./runtime.mjs";
|
|
18
19
|
|
|
19
20
|
// ── Entry point ────────────────────────────────────────────────────────────
|
|
20
21
|
|
|
@@ -66,6 +67,15 @@ export async function run(argv) {
|
|
|
66
67
|
export async function mainFlow() {
|
|
67
68
|
await ensureDirs();
|
|
68
69
|
|
|
70
|
+
if (process.stdin.isTTY) {
|
|
71
|
+
const runtimePrompt = createPrompt();
|
|
72
|
+
try {
|
|
73
|
+
await offerManagedLlamaRuntimeUpdate(runtimePrompt);
|
|
74
|
+
} finally {
|
|
75
|
+
runtimePrompt.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
// 1. Check what backends are available
|
|
70
80
|
const llamaBinary = await findLlamaServer();
|
|
71
81
|
const ggufModels = await scanGgufModels();
|
|
@@ -456,6 +466,33 @@ function capabilitySummary(caps) {
|
|
|
456
466
|
return parts.length > 0 ? parts.join(" · ") : "standard GGUF";
|
|
457
467
|
}
|
|
458
468
|
|
|
469
|
+
function isUnsupportedMmprojError(err, profile) {
|
|
470
|
+
const message = String(err?.message ?? "");
|
|
471
|
+
return Boolean(profile.mmprojPath && /unknown projector type|failed to load multimodal model|failed to load CLIP model/i.test(message));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function textOnlyProfile(profile) {
|
|
475
|
+
return normalizeProfile({
|
|
476
|
+
...profile,
|
|
477
|
+
mmprojPath: null,
|
|
478
|
+
disabledMmprojPath: profile.disabledMmprojPath ?? profile.mmprojPath,
|
|
479
|
+
capabilities: { ...(profile.capabilities ?? {}), vision: false, visionDisabledReason: "unsupported-mmproj" },
|
|
480
|
+
commandArgv: removeCommandOption(profile.commandArgv ?? [], "--mmproj"),
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function removeCommandOption(argv, flag) {
|
|
485
|
+
const next = [];
|
|
486
|
+
for (let i = 0; i < argv.length; i++) {
|
|
487
|
+
if (argv[i] === flag) {
|
|
488
|
+
if (argv[i + 1] && !argv[i + 1].startsWith("--")) i += 1;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
next.push(argv[i]);
|
|
492
|
+
}
|
|
493
|
+
return next;
|
|
494
|
+
}
|
|
495
|
+
|
|
459
496
|
function createManagedProfile(model, backendId) {
|
|
460
497
|
return normalizeProfile({
|
|
461
498
|
id: model.id.replace(/[^a-z0-9._-]+/gi, "-").toLowerCase(),
|
|
@@ -510,6 +547,13 @@ async function runProfile(profile, options = {}) {
|
|
|
510
547
|
if (state?.pid) {
|
|
511
548
|
try { await stopProfile(profile); } catch { /* best effort */ }
|
|
512
549
|
}
|
|
550
|
+
if (!options.textOnlyRetry && isUnsupportedMmprojError(err, profile)) {
|
|
551
|
+
console.log(pc.yellow("Vision projector is not supported by this llama.cpp build. Retrying text-only."));
|
|
552
|
+
console.log(pc.dim("Update llama.cpp later to re-enable vision for this model."));
|
|
553
|
+
const textOnly = textOnlyProfile(profile);
|
|
554
|
+
await saveProfile(textOnly);
|
|
555
|
+
return await runProfile(textOnly, { ...options, textOnlyRetry: true });
|
|
556
|
+
}
|
|
513
557
|
throw err;
|
|
514
558
|
}
|
|
515
559
|
}
|
package/src/config.mjs
CHANGED
|
@@ -10,6 +10,8 @@ export const DATA_DIR = process.env.OFFGRID_DIR || join(homedir(), ".offgrid-ai"
|
|
|
10
10
|
export const PROFILE_DIR = join(DATA_DIR, "profiles");
|
|
11
11
|
export const LOG_DIR = join(DATA_DIR, "logs");
|
|
12
12
|
export const RUN_DIR = join(DATA_DIR, "run");
|
|
13
|
+
export const RUNTIME_DIR = join(DATA_DIR, "runtime");
|
|
14
|
+
export const MANAGED_LLAMA_SERVER = join(RUNTIME_DIR, "bin", "llama-server");
|
|
13
15
|
|
|
14
16
|
// ── Default scan directories ──────────────────────────────────────────────
|
|
15
17
|
|
|
@@ -74,14 +76,17 @@ export async function findLlamaServer() {
|
|
|
74
76
|
return process.env.LLAMA_SERVER_BINARY;
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
// 2.
|
|
79
|
+
// 2. offgrid-ai managed runtime
|
|
80
|
+
if (existsSync(MANAGED_LLAMA_SERVER)) return MANAGED_LLAMA_SERVER;
|
|
81
|
+
|
|
82
|
+
// 3. which/where
|
|
78
83
|
try {
|
|
79
84
|
const { stdout } = await execFileAsync("which", ["llama-server"]);
|
|
80
85
|
const path = stdout.trim();
|
|
81
86
|
if (path && existsSync(path)) return path;
|
|
82
87
|
} catch { /* not on PATH */ }
|
|
83
88
|
|
|
84
|
-
//
|
|
89
|
+
// 4. Homebrew
|
|
85
90
|
try {
|
|
86
91
|
const { stdout } = await execFileAsync("brew", ["--prefix", "llama.cpp"]);
|
|
87
92
|
const prefix = stdout.trim();
|
package/src/profile-setup.mjs
CHANGED
|
@@ -50,10 +50,22 @@ export async function configureLocalProfile(prompt, profile) {
|
|
|
50
50
|
console.log("");
|
|
51
51
|
console.log(renderSection("QAT detected", renderRows([
|
|
52
52
|
["Meaning", "quantization-aware trained"],
|
|
53
|
-
["Runtime flags", "none
|
|
53
|
+
["Runtime flags", "none QAT-specific"],
|
|
54
54
|
])));
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
if (caps.vision && profile.mmprojPath) {
|
|
58
|
+
console.log("");
|
|
59
|
+
const gemma4Unified = isGemma4UnifiedProjector(caps.mmprojProjectorType);
|
|
60
|
+
console.log(renderSection("Vision projector detected", renderRows([
|
|
61
|
+
["Projector", caps.mmprojProjectorType ?? "unknown"],
|
|
62
|
+
["Flag", `--mmproj ${profile.mmprojPath}`],
|
|
63
|
+
...(gemma4Unified ? [["Note", pc.yellow("Gemma 4 unified projectors need newer llama.cpp than current Homebrew stable.")]] : []),
|
|
64
|
+
])));
|
|
65
|
+
const useVision = await prompt.yesNo("Enable vision with --mmproj?", !gemma4Unified);
|
|
66
|
+
configured = useVision ? applyVisionDefaults(configured) : removeVisionDefaults(configured, gemma4Unified ? "gemma4-unified-unsupported" : "user-disabled");
|
|
67
|
+
}
|
|
68
|
+
|
|
57
69
|
if (caps.thinking) {
|
|
58
70
|
console.log("");
|
|
59
71
|
console.log(renderSection("Thinking model detected", renderRows([
|
|
@@ -106,6 +118,23 @@ function removeMtpDefaults(profile) {
|
|
|
106
118
|
});
|
|
107
119
|
}
|
|
108
120
|
|
|
121
|
+
function applyVisionDefaults(profile) {
|
|
122
|
+
if (!profile.mmprojPath) return profile;
|
|
123
|
+
return applyProfileFlags({
|
|
124
|
+
...profile,
|
|
125
|
+
capabilities: { ...(profile.capabilities ?? {}), vision: true, visionDisabledReason: undefined },
|
|
126
|
+
}, profile.flags, { values: { "--mmproj": profile.mmprojPath } });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function removeVisionDefaults(profile, reason) {
|
|
130
|
+
return applyProfileFlags({
|
|
131
|
+
...profile,
|
|
132
|
+
disabledMmprojPath: profile.mmprojPath,
|
|
133
|
+
mmprojPath: null,
|
|
134
|
+
capabilities: { ...(profile.capabilities ?? {}), vision: false, visionDisabledReason: reason },
|
|
135
|
+
}, profile.flags, { remove: ["--mmproj"] });
|
|
136
|
+
}
|
|
137
|
+
|
|
109
138
|
function applyThinkingDefaults(profile) {
|
|
110
139
|
const flags = { ...profile.flags, ...THINKING_DEFAULTS };
|
|
111
140
|
return applyProfileFlags(profile, flags);
|
|
@@ -179,6 +208,10 @@ function renderMemoryEstimate(profile) {
|
|
|
179
208
|
}
|
|
180
209
|
}
|
|
181
210
|
|
|
211
|
+
function isGemma4UnifiedProjector(projectorType) {
|
|
212
|
+
return /gemma4u[va]/i.test(String(projectorType ?? ""));
|
|
213
|
+
}
|
|
214
|
+
|
|
182
215
|
function detectionSummary(caps) {
|
|
183
216
|
const parts = [];
|
|
184
217
|
if (caps.architecture) parts.push(caps.architecture);
|
package/src/profiles.mjs
CHANGED
package/src/runtime.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { MANAGED_LLAMA_SERVER, RUNTIME_DIR } from "./config.mjs";
|
|
9
|
+
import { compareVersions } from "./updates.mjs";
|
|
10
|
+
import { pc, renderCard, renderRows } from "./ui.mjs";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
const RELEASE_API = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest";
|
|
14
|
+
const VERSION_PATH = join(RUNTIME_DIR, "llama.cpp", "VERSION.json");
|
|
15
|
+
|
|
16
|
+
export async function offerManagedLlamaRuntimeUpdate(prompt, { fetchImpl = globalThis.fetch } = {}) {
|
|
17
|
+
const latest = await latestLlamaRelease(fetchImpl);
|
|
18
|
+
if (!latest) return false;
|
|
19
|
+
|
|
20
|
+
const installed = await installedRuntime();
|
|
21
|
+
if (installed?.tag && compareBuildTags(installed.tag, latest.tag) >= 0 && existsSync(MANAGED_LLAMA_SERVER)) return false;
|
|
22
|
+
|
|
23
|
+
console.log("\n" + renderCard("llama.cpp runtime", renderRows([
|
|
24
|
+
["Installed", installed?.tag ?? "none"],
|
|
25
|
+
["Latest", pc.green(latest.tag)],
|
|
26
|
+
["Source", "official GitHub release binary"],
|
|
27
|
+
]), { formatBorder: pc.cyan }));
|
|
28
|
+
|
|
29
|
+
const shouldInstall = await prompt.yesNo(installed ? "Update llama.cpp runtime?" : "Install llama.cpp runtime?", true);
|
|
30
|
+
if (!shouldInstall) return false;
|
|
31
|
+
|
|
32
|
+
await installLlamaRelease(latest);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function latestLlamaRelease(fetchImpl = globalThis.fetch) {
|
|
37
|
+
try {
|
|
38
|
+
const response = await fetchImpl(RELEASE_API, { signal: AbortSignal.timeout(5000) });
|
|
39
|
+
if (!response.ok) return null;
|
|
40
|
+
const body = await response.json();
|
|
41
|
+
const tag = typeof body?.tag_name === "string" ? body.tag_name : null;
|
|
42
|
+
const asset = selectAsset(body?.assets ?? [], process.platform, process.arch);
|
|
43
|
+
if (!tag || !asset) return null;
|
|
44
|
+
return { tag, asset };
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function installedRuntime() {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(await readFile(VERSION_PATH, "utf8"));
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function installLlamaRelease(release) {
|
|
59
|
+
const tmp = await mkdtemp(join(tmpdir(), "offgrid-llama-"));
|
|
60
|
+
const archive = join(tmp, release.asset.name);
|
|
61
|
+
const releaseDir = join(RUNTIME_DIR, "llama.cpp", "releases", release.tag);
|
|
62
|
+
const binDir = join(RUNTIME_DIR, "bin");
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
console.log(pc.dim(`Downloading ${release.asset.name}...`));
|
|
66
|
+
const response = await fetch(release.asset.url);
|
|
67
|
+
if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`);
|
|
68
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
69
|
+
verifyDigest(bytes, release.asset.digest);
|
|
70
|
+
await writeFile(archive, bytes);
|
|
71
|
+
|
|
72
|
+
await rm(releaseDir, { recursive: true, force: true });
|
|
73
|
+
await mkdir(releaseDir, { recursive: true });
|
|
74
|
+
await execFileAsync("tar", ["-xzf", archive, "--strip-components", "1", "-C", releaseDir]);
|
|
75
|
+
|
|
76
|
+
const llamaServer = join(releaseDir, "llama-server");
|
|
77
|
+
if (!existsSync(llamaServer)) throw new Error("Release archive did not contain llama-server");
|
|
78
|
+
await chmod(llamaServer, 0o755);
|
|
79
|
+
|
|
80
|
+
await mkdir(binDir, { recursive: true });
|
|
81
|
+
await unlink(MANAGED_LLAMA_SERVER).catch(() => {});
|
|
82
|
+
await symlink(llamaServer, MANAGED_LLAMA_SERVER);
|
|
83
|
+
|
|
84
|
+
await mkdir(join(RUNTIME_DIR, "llama.cpp"), { recursive: true });
|
|
85
|
+
await writeFile(VERSION_PATH, JSON.stringify({
|
|
86
|
+
runtime: "llama.cpp",
|
|
87
|
+
tag: release.tag,
|
|
88
|
+
asset: release.asset.name,
|
|
89
|
+
url: release.asset.url,
|
|
90
|
+
installedAt: new Date().toISOString(),
|
|
91
|
+
llamaServer,
|
|
92
|
+
}, null, 2) + "\n", "utf8");
|
|
93
|
+
|
|
94
|
+
console.log(pc.green(`✓ Installed llama.cpp ${release.tag}`));
|
|
95
|
+
} finally {
|
|
96
|
+
await rm(tmp, { recursive: true, force: true }).catch(() => {});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function selectAsset(assets, platform, arch) {
|
|
101
|
+
const suffix = assetSuffix(platform, arch);
|
|
102
|
+
if (!suffix) return null;
|
|
103
|
+
const asset = assets.find((item) => item?.name?.endsWith(suffix));
|
|
104
|
+
if (!asset?.browser_download_url) return null;
|
|
105
|
+
return {
|
|
106
|
+
name: asset.name,
|
|
107
|
+
url: asset.browser_download_url,
|
|
108
|
+
digest: asset.digest,
|
|
109
|
+
size: asset.size,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function assetSuffix(platform, arch) {
|
|
114
|
+
const cpu = arch === "arm64" ? "arm64" : arch === "x64" ? "x64" : null;
|
|
115
|
+
if (!cpu) return null;
|
|
116
|
+
if (platform === "darwin") return `bin-macos-${cpu}.tar.gz`;
|
|
117
|
+
if (platform === "linux") return `bin-ubuntu-${cpu}.tar.gz`;
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function compareBuildTags(a, b) {
|
|
122
|
+
return compareVersions(String(a).replace(/^b/u, ""), String(b).replace(/^b/u, ""));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function verifyDigest(bytes, digest) {
|
|
126
|
+
if (!digest?.startsWith("sha256:")) return;
|
|
127
|
+
const expected = digest.slice("sha256:".length);
|
|
128
|
+
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
129
|
+
if (actual !== expected) throw new Error(`${basename("llama.cpp")}: checksum mismatch`);
|
|
130
|
+
}
|
package/src/scan.mjs
CHANGED