offgrid-ai 0.8.15 → 0.9.2
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 +26 -25
- package/package.json +3 -2
- package/src/autodetect.mjs +6 -3
- package/src/backends.mjs +36 -45
- package/src/benchmark/finalize.mjs +198 -0
- package/src/benchmark/flow.mjs +237 -0
- package/src/benchmark/metrics.mjs +152 -0
- package/src/benchmark/pi-runner.mjs +252 -0
- package/src/benchmark/prepare.mjs +121 -0
- package/src/benchmark/repo.mjs +77 -0
- package/src/benchmark/shared.mjs +54 -0
- package/src/benchmark/stream-renderer.mjs +274 -0
- package/src/benchmark.mjs +10 -1330
- package/src/cli.mjs +2 -2
- package/src/commands/main.mjs +2 -2
- package/src/commands/onboard.mjs +6 -2
- package/src/config.mjs +8 -2
- package/src/harness-pi.mjs +1 -1
- package/src/managed.mjs +3 -3
- package/src/model-catalog.mjs +2 -1
- package/src/model-name.mjs +220 -0
- package/src/process.mjs +29 -21
- package/src/runtime.mjs +11 -0
- package/src/scan.mjs +9 -20
package/src/cli.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { uninstallCommand } from "./commands/uninstall.mjs";
|
|
|
10
10
|
|
|
11
11
|
async function offerUpdate(argv) {
|
|
12
12
|
const invocation = detectInvocation();
|
|
13
|
-
const update = await checkForUpdate(
|
|
13
|
+
const update = await checkForUpdate();
|
|
14
14
|
if (!update) return false;
|
|
15
15
|
|
|
16
16
|
const plan = updateCommand(invocation, argv);
|
|
@@ -56,7 +56,7 @@ async function printVersion() {
|
|
|
56
56
|
const version = currentPackageVersion();
|
|
57
57
|
console.log(`offgrid-ai v${version}`);
|
|
58
58
|
const invocation = detectInvocation();
|
|
59
|
-
const update = await checkForUpdate(
|
|
59
|
+
const update = await checkForUpdate();
|
|
60
60
|
if (update) {
|
|
61
61
|
const plan = updateCommand(invocation, ["version"]);
|
|
62
62
|
console.log(pc.yellow(`Update available: v${update.latest}. Run: ${plan.display}`));
|
package/src/commands/main.mjs
CHANGED
|
@@ -27,8 +27,8 @@ export async function mainFlow() {
|
|
|
27
27
|
const { models: ggufModels, drafters } = await scanGgufModels();
|
|
28
28
|
const managedModels = await scanManagedModels();
|
|
29
29
|
const profiles = await loadProfiles();
|
|
30
|
-
const hasAnyBackend = llamaBinary || managedModels.some((item) => item.models.length > 0);
|
|
31
|
-
const hasAnyModels = ggufModels.length > 0 || managedModels.some((item) => item.models.length > 0);
|
|
30
|
+
const hasAnyBackend = llamaBinary || managedModels.some((item) => item.status === "ok" && item.models.length > 0);
|
|
31
|
+
const hasAnyModels = ggufModels.length > 0 || managedModels.some((item) => item.status === "ok" && item.models.length > 0);
|
|
32
32
|
|
|
33
33
|
const piInstalled = await hasPi();
|
|
34
34
|
const needsLlama = ggufModels.length > 0 || profiles.some((profile) => backendFor(profile.backend).type === "local-server");
|
package/src/commands/onboard.mjs
CHANGED
|
@@ -90,8 +90,12 @@ function printFoundModels(ggufModels, managedModels, llamaBinary) {
|
|
|
90
90
|
console.log(pc.green(`✓ Found ${ggufModels.length} GGUF model${ggufModels.length === 1 ? "" : "s"}`));
|
|
91
91
|
if (!llamaBinary) console.log(pc.yellow("Install the managed llama.cpp runtime to run these GGUF models."));
|
|
92
92
|
}
|
|
93
|
-
for (const { backendId, models } of managedModels) {
|
|
94
|
-
if (
|
|
93
|
+
for (const { backendId, models, status, reason } of managedModels) {
|
|
94
|
+
if (status === "unavailable") {
|
|
95
|
+
console.log(pc.yellow(`${BACKENDS[backendId].label}: unavailable${reason ? ` — ${reason}` : ""}`));
|
|
96
|
+
} else if (models.length > 0) {
|
|
97
|
+
console.log(pc.green(`✓ ${BACKENDS[backendId].label}: ${models.length} model${models.length === 1 ? "" : "s"}`));
|
|
98
|
+
}
|
|
95
99
|
}
|
|
96
100
|
}
|
|
97
101
|
|
package/src/config.mjs
CHANGED
|
@@ -46,8 +46,13 @@ export async function loadConfig() {
|
|
|
46
46
|
try {
|
|
47
47
|
const raw = await readFile(CONFIG_PATH, "utf8");
|
|
48
48
|
return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
|
|
49
|
-
} catch {
|
|
50
|
-
return { ...DEFAULT_CONFIG };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error?.code === "ENOENT") return { ...DEFAULT_CONFIG };
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Failed to read config at ${CONFIG_PATH}: ${error.message}. ` +
|
|
53
|
+
`Fix or remove the file, then try again.`,
|
|
54
|
+
{ cause: error }
|
|
55
|
+
);
|
|
51
56
|
}
|
|
52
57
|
}
|
|
53
58
|
|
|
@@ -99,6 +104,7 @@ export async function findLlamaServer() {
|
|
|
99
104
|
if (existsSync(candidate)) return candidate;
|
|
100
105
|
} catch { /* Homebrew not installed or llama.cpp not brewed */ }
|
|
101
106
|
|
|
107
|
+
// No llama-server found — caller must present actionable error or onboarding.
|
|
102
108
|
return null;
|
|
103
109
|
}
|
|
104
110
|
|
package/src/harness-pi.mjs
CHANGED
|
@@ -70,7 +70,7 @@ export async function hasPi() {
|
|
|
70
70
|
// ── Internals ──────────────────────────────────────────────────────────────
|
|
71
71
|
|
|
72
72
|
async function activeProviderProfiles(currentProfile) {
|
|
73
|
-
const allProfiles = await loadProfiles()
|
|
73
|
+
const allProfiles = await loadProfiles();
|
|
74
74
|
const byAlias = new Map();
|
|
75
75
|
for (const item of [...allProfiles, currentProfile]) {
|
|
76
76
|
if (item.providerId !== currentProfile.providerId) continue;
|
package/src/managed.mjs
CHANGED
|
@@ -10,9 +10,9 @@ export async function scanManagedModels() {
|
|
|
10
10
|
const backend = BACKENDS[backendId];
|
|
11
11
|
try {
|
|
12
12
|
const models = await backend.scanModels();
|
|
13
|
-
results.push({ backendId, models });
|
|
14
|
-
} catch {
|
|
15
|
-
|
|
13
|
+
results.push({ backendId, models, status: "ok" });
|
|
14
|
+
} catch (error) {
|
|
15
|
+
results.push({ backendId, models: [], status: "unavailable", reason: error?.message ?? String(error) });
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
return results;
|
package/src/model-catalog.mjs
CHANGED
|
@@ -18,7 +18,8 @@ export function normalizeCatalog(catalog) {
|
|
|
18
18
|
const profiledPaths = new Set(profiles.map((profile) => profile.modelPath).filter(Boolean));
|
|
19
19
|
const newModels = ggufModels.filter((model) => !profiledPaths.has(model.path));
|
|
20
20
|
const managedItems = [];
|
|
21
|
-
for (const { backendId, models } of managedModels) {
|
|
21
|
+
for (const { backendId, models, status } of managedModels) {
|
|
22
|
+
if (status === "unavailable") continue;
|
|
22
23
|
const profiledAliases = new Set(
|
|
23
24
|
profiles
|
|
24
25
|
.filter((profile) => profile.backend === backendId)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// ── Single path for parsing and formatting model names ─────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Every model display name in offgrid-ai goes through parseModelName().
|
|
4
|
+
// No other function should format, title-case, or dissect a model name.
|
|
5
|
+
//
|
|
6
|
+
// The returned `id` is always the raw identifier (untouched) and is used for
|
|
7
|
+
// API calls, profile IDs, Pi config matching, and benchmark directory slugs.
|
|
8
|
+
// The returned `display` is the human-readable string shown in pickers, details,
|
|
9
|
+
// and benchmark metadata.
|
|
10
|
+
|
|
11
|
+
// ── Known model families ────────────────────────────────────────────────
|
|
12
|
+
//
|
|
13
|
+
// Mapped to their title-case form. Matched as prefix tokens so "qwen"
|
|
14
|
+
// matches "qwen3", "qwen2.5", etc.
|
|
15
|
+
|
|
16
|
+
const FAMILY_TITLE_CASE = {
|
|
17
|
+
"deepseek-r": "DeepSeek-R",
|
|
18
|
+
"deepseek": "DeepSeek",
|
|
19
|
+
"starcoder2": "StarCoder2",
|
|
20
|
+
"starcoder": "StarCoder",
|
|
21
|
+
"command-r": "Command-R",
|
|
22
|
+
"command": "Command",
|
|
23
|
+
"codestral": "Codestral",
|
|
24
|
+
"mistral": "Mistral",
|
|
25
|
+
"mixtral": "Mixtral",
|
|
26
|
+
"mathstral": "Mathstral",
|
|
27
|
+
"pixtral": "Pixtral",
|
|
28
|
+
"gemma": "Gemma",
|
|
29
|
+
"qwen": "Qwen",
|
|
30
|
+
"llama": "Llama",
|
|
31
|
+
"phi": "Phi",
|
|
32
|
+
"yi": "Yi",
|
|
33
|
+
"zephyr": "Zephyr",
|
|
34
|
+
"internlm": "InternLM",
|
|
35
|
+
"cohere": "Cohere",
|
|
36
|
+
"falcon": "Falcon",
|
|
37
|
+
"baichuan": "Baichuan",
|
|
38
|
+
"mamba": "Mamba",
|
|
39
|
+
"solar": "Solar",
|
|
40
|
+
"granite": "Granite",
|
|
41
|
+
"dbrx": "DBRX",
|
|
42
|
+
"stablelm": "StableLM",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Sort families by length descending so longer families match first
|
|
46
|
+
const SORTED_FAMILIES = Object.keys(FAMILY_TITLE_CASE).sort((a, b) => b.length - a.length);
|
|
47
|
+
|
|
48
|
+
// ── Quant patterns (order matters — longer/more-specific first) ────────
|
|
49
|
+
|
|
50
|
+
const QUANT_PATTERNS = [
|
|
51
|
+
/[-_]UD-[A-Z0-9_]+/i,
|
|
52
|
+
/[-_]IQ[0-9_]+(?:_[A-Z]+)?/i,
|
|
53
|
+
/[-_]Q\d_K_[A-Z]+/i,
|
|
54
|
+
/[-_]Q\d_[01]/i,
|
|
55
|
+
/[-_]F(?:16|32)/i,
|
|
56
|
+
/[-_]BF16/i,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// ── Tag tokens extracted from the name ──────────────────────────────────
|
|
60
|
+
|
|
61
|
+
const TAG_TOKENS = [
|
|
62
|
+
"it", "instruct", "chat", "code", "base", "vision", "mtp",
|
|
63
|
+
"mmproj", "draft", "assistant",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
// ── Main entry point ───────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse a raw model identifier into a structured display name.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} rawId The raw identifier: GGUF filename (no .gguf),
|
|
72
|
+
* Ollama model name, or oMLX model id.
|
|
73
|
+
* @param {"local-gguf"|"ollama"|"omlx"} source Where this name came from.
|
|
74
|
+
* @returns {{ publisher: string|null, model: string, params: string|null,
|
|
75
|
+
* quant: string|null, tags: string[], display: string,
|
|
76
|
+
* sort: string, id: string }}
|
|
77
|
+
*/
|
|
78
|
+
export function parseModelName(rawId, source) {
|
|
79
|
+
const id = rawId; // never modify the raw id
|
|
80
|
+
|
|
81
|
+
// 1. Extract publisher (anything before the first /)
|
|
82
|
+
let publisher = null;
|
|
83
|
+
let name = rawId;
|
|
84
|
+
const slashIdx = rawId.indexOf("/");
|
|
85
|
+
if (slashIdx !== -1) {
|
|
86
|
+
publisher = rawId.slice(0, slashIdx);
|
|
87
|
+
name = rawId.slice(slashIdx + 1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 2. For Ollama, split on : to separate model from tag (e.g. "gemma3:4b")
|
|
91
|
+
// The tag after : is a model size/variant identifier — not a GGUF quant.
|
|
92
|
+
let ollamaTag = null;
|
|
93
|
+
if (source === "ollama") {
|
|
94
|
+
const colonIdx = name.lastIndexOf(":");
|
|
95
|
+
if (colonIdx !== -1) {
|
|
96
|
+
ollamaTag = name.slice(colonIdx + 1);
|
|
97
|
+
name = name.slice(0, colonIdx);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 3. Extract quant (GGUF quantization suffix)
|
|
102
|
+
let quant = null;
|
|
103
|
+
for (const pattern of QUANT_PATTERNS) {
|
|
104
|
+
const match = name.match(pattern);
|
|
105
|
+
if (match) {
|
|
106
|
+
quant = match[0].replace(/^[-_]/, "");
|
|
107
|
+
name = name.slice(0, match.index) + name.slice(match.index + match[0].length);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 4. Extract known tags as hyphen/underscore-delimited tokens
|
|
113
|
+
const tags = [];
|
|
114
|
+
for (const tag of TAG_TOKENS) {
|
|
115
|
+
const tagRegex = new RegExp(`(?:^|[-_])${tag}(?:$|[-_])`, "i");
|
|
116
|
+
if (tagRegex.test(name)) {
|
|
117
|
+
tags.push(tag);
|
|
118
|
+
// Remove the tag token from the name
|
|
119
|
+
name = name.replace(new RegExp(`(?:^|[-_])${tag}(?=[-_]|$)`, "i"), (m) => {
|
|
120
|
+
// Preserve the leading hyphen/underscore boundary
|
|
121
|
+
return m.startsWith("-") || m.startsWith("_") ? "" : "";
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Clean up leftover separators
|
|
126
|
+
name = name.replace(/[-_]{2,}/g, "-").replace(/^[-_]+|[-_]+$/g, "");
|
|
127
|
+
|
|
128
|
+
// 5. For Ollama, re-attach the tag as part of the model name
|
|
129
|
+
// (Ollama tags like "4b" or "30b-a3b" are size variants, not quants)
|
|
130
|
+
if (ollamaTag) {
|
|
131
|
+
name = name + "-" + ollamaTag;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 6. Title-case the remaining model name
|
|
135
|
+
let model = titleCaseModel(name);
|
|
136
|
+
|
|
137
|
+
// If nothing is left after parsing, fall back to the raw name
|
|
138
|
+
if (!model || model.trim() === "") {
|
|
139
|
+
model = rawId.includes("/") ? rawId : rawId.replace(/[-_]/g, " ");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 7. Extract params (size like 30B, 12B) for sort/filter convenience
|
|
143
|
+
const params = extractParams(model);
|
|
144
|
+
|
|
145
|
+
// 8. Build display string
|
|
146
|
+
const display = buildDisplay(publisher, model, tags, quant);
|
|
147
|
+
|
|
148
|
+
// 9. Build sort key (lowercase, no publisher, for alphabetical ordering)
|
|
149
|
+
const sort = model.toLowerCase().replace(/[-_]/g, " ");
|
|
150
|
+
|
|
151
|
+
return { publisher, model, params, quant, tags, display, sort, id };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Display builder ────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
function buildDisplay(publisher, model, tags, quant) {
|
|
157
|
+
const parts = [];
|
|
158
|
+
if (publisher) {
|
|
159
|
+
parts.push(publisher);
|
|
160
|
+
}
|
|
161
|
+
let modelPart = model;
|
|
162
|
+
if (tags.length > 0) {
|
|
163
|
+
modelPart += ` (${tags.join(", ")})`;
|
|
164
|
+
}
|
|
165
|
+
parts.push(modelPart);
|
|
166
|
+
if (quant) {
|
|
167
|
+
parts.push(quant);
|
|
168
|
+
}
|
|
169
|
+
return parts.join(" › ");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Params extraction ──────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
function extractParams(model) {
|
|
175
|
+
const match = model.match(/\b(\d+(?:\.\d+)?)\s*B\b/);
|
|
176
|
+
return match ? match[1] + "B" : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Title-case model names ────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
function titleCaseModel(name) {
|
|
182
|
+
// Replace hyphens and underscores with spaces
|
|
183
|
+
let result = name.replace(/[-_]/g, " ");
|
|
184
|
+
|
|
185
|
+
// Title-case known families (prefix match so "qwen" matches "qwen3", etc.)
|
|
186
|
+
// Insert a space between the family name and a following digit/version.
|
|
187
|
+
for (const family of SORTED_FAMILIES) {
|
|
188
|
+
const pattern = new RegExp(`\\b${family}(?=[0-9])`, "gi");
|
|
189
|
+
result = result.replace(pattern, FAMILY_TITLE_CASE[family] + " ");
|
|
190
|
+
// Also match family at end of word (no digit following)
|
|
191
|
+
const patternEnd = new RegExp(`\\b${family}(?![a-z0-9])`, "gi");
|
|
192
|
+
result = result.replace(patternEnd, FAMILY_TITLE_CASE[family]);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Title-case param sizes (30b → 30B, 12b → 12B, 0.5b → 0.5B)
|
|
196
|
+
result = result.replace(/\b(\d+(?:\.\d+)?)\s*[bB]\b/g, (_, num) => {
|
|
197
|
+
return num + "B";
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Title-case version numbers that follow a family name (Gemma 3, Qwen 2.5)
|
|
201
|
+
// Pattern: family name followed by a space then a bare digit sequence
|
|
202
|
+
// that's not a param size (not followed by B/b).
|
|
203
|
+
// We already have "Gemma 4", "Qwen 3" etc. from family + spacing.
|
|
204
|
+
// Just ensure the numbers look clean.
|
|
205
|
+
|
|
206
|
+
// Title-case "it" and "instruct" if they survived tag extraction
|
|
207
|
+
result = result.replace(/\bit\b/g, "IT");
|
|
208
|
+
result = result.replace(/\binstruct\b/gi, "Instruct");
|
|
209
|
+
|
|
210
|
+
// Title-case "r1", "r2" etc. (DeepSeek-R1, etc.)
|
|
211
|
+
result = result.replace(/\br(\d+)\b/gi, (_, num) => `R${num}`);
|
|
212
|
+
|
|
213
|
+
// Title-case standalone aXb patterns (A3B, A12B — active parameters)
|
|
214
|
+
result = result.replace(/\ba(\d+)\s*b\b/gi, (_, num) => `A${num}B`);
|
|
215
|
+
|
|
216
|
+
// Clean up extra spaces
|
|
217
|
+
result = result.replace(/\s{2,}/g, " ").trim();
|
|
218
|
+
|
|
219
|
+
return result;
|
|
220
|
+
}
|
package/src/process.mjs
CHANGED
|
@@ -195,47 +195,55 @@ export async function waitForReady(profile, pid, rawLogPath) {
|
|
|
195
195
|
// ── Internals ──────────────────────────────────────────────────────────────
|
|
196
196
|
|
|
197
197
|
async function serverModelIds(baseUrl) {
|
|
198
|
-
const
|
|
199
|
-
|
|
198
|
+
const result = await fetchJson(`${baseUrl.replace(/\/+$/u, "")}/models`);
|
|
199
|
+
if (!result.ok) return [];
|
|
200
|
+
return (Array.isArray(result.data?.data) ? result.data.data : [])
|
|
200
201
|
.map((model) => String(model?.id ?? "").trim())
|
|
201
202
|
.filter(Boolean);
|
|
202
203
|
}
|
|
203
204
|
|
|
204
205
|
async function ollamaLoadedModelIds(profile) {
|
|
205
|
-
const
|
|
206
|
-
|
|
206
|
+
const result = await fetchJson(`${apiRootUrl(profile.baseUrl)}/api/ps`);
|
|
207
|
+
if (!result.ok) return [];
|
|
208
|
+
return (Array.isArray(result.data?.models) ? result.data.models : [])
|
|
207
209
|
.flatMap((model) => [model?.name, model?.model])
|
|
208
210
|
.map((id) => String(id ?? "").trim())
|
|
209
211
|
.filter(Boolean);
|
|
210
212
|
}
|
|
211
213
|
|
|
212
214
|
async function omlxLoadedModelIds(profile) {
|
|
213
|
-
const
|
|
214
|
-
const fromStatus =
|
|
215
|
-
.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
215
|
+
const statusResult = await fetchJson(`${profile.baseUrl.replace(/\/+$/u, "")}/models/status`);
|
|
216
|
+
const fromStatus = statusResult.ok
|
|
217
|
+
? (Array.isArray(statusResult.data?.models) ? statusResult.data.models : [])
|
|
218
|
+
.filter((model) => model?.loaded === true)
|
|
219
|
+
.flatMap((model) => [model?.id, model?.name, model?.model, model?.alias])
|
|
220
|
+
.map((id) => String(id ?? "").trim())
|
|
221
|
+
.filter(Boolean)
|
|
222
|
+
: [];
|
|
223
|
+
if (!statusResult.ok || Number(statusResult.data?.loaded_count) === 0) return fromStatus;
|
|
224
|
+
|
|
225
|
+
const summaryResult = await fetchJson(`${apiRootUrl(profile.baseUrl)}/api/status`);
|
|
226
|
+
const fromSummary = summaryResult.ok
|
|
227
|
+
? (Array.isArray(summaryResult.data?.loaded_models) ? summaryResult.data.loaded_models : [])
|
|
228
|
+
.map((id) => String(id ?? "").trim())
|
|
229
|
+
.filter(Boolean)
|
|
230
|
+
: [];
|
|
225
231
|
return [...fromStatus, ...fromSummary];
|
|
226
232
|
}
|
|
227
233
|
|
|
228
234
|
async function fetchJson(url) {
|
|
229
235
|
try {
|
|
230
236
|
const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
|
|
231
|
-
if (!response.ok) return null;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
237
|
+
if (!response.ok) return { ok: false, reason: "http", status: response.status, data: null };
|
|
238
|
+
const data = await response.json();
|
|
239
|
+
return { ok: true, data };
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (error?.name === "AbortError" || error?.name === "TimeoutError") return { ok: false, reason: "timeout", data: null };
|
|
242
|
+
return { ok: false, reason: "network", data: null };
|
|
235
243
|
}
|
|
236
244
|
}
|
|
237
245
|
|
|
238
|
-
function apiRootUrl(baseUrl) {
|
|
246
|
+
export function apiRootUrl(baseUrl) {
|
|
239
247
|
try {
|
|
240
248
|
const url = new URL(baseUrl);
|
|
241
249
|
url.pathname = url.pathname.replace(/\/v1\/?$/u, "") || "/";
|
package/src/runtime.mjs
CHANGED
|
@@ -33,6 +33,12 @@ export async function offerManagedLlamaRuntimeUpdate(prompt, { fetchImpl = globa
|
|
|
33
33
|
return true;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Check for the latest llama.cpp release on GitHub.
|
|
38
|
+
* Returns null if the check fails (network error, timeout, etc.).
|
|
39
|
+
* Callers must treat null as "check failed, skip prompt" — do not
|
|
40
|
+
* treat null as "no update available."
|
|
41
|
+
*/
|
|
36
42
|
export async function latestLlamaRelease(fetchImpl = globalThis.fetch) {
|
|
37
43
|
try {
|
|
38
44
|
const response = await fetchImpl(RELEASE_API, { signal: AbortSignal.timeout(5000) });
|
|
@@ -47,6 +53,11 @@ export async function latestLlamaRelease(fetchImpl = globalThis.fetch) {
|
|
|
47
53
|
}
|
|
48
54
|
}
|
|
49
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Read the installed runtime version from disk.
|
|
58
|
+
* Returns null if not installed or if the version file is missing/corrupt.
|
|
59
|
+
* Callers must treat null as "runtime not installed" — not as a hidden default.
|
|
60
|
+
*/
|
|
50
61
|
export async function installedRuntime() {
|
|
51
62
|
try {
|
|
52
63
|
return JSON.parse(await readFile(VERSION_PATH, "utf8"));
|
package/src/scan.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { readdir } 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
|
+
import { parseModelName } from "./model-name.mjs";
|
|
6
7
|
|
|
7
8
|
// ── Scan for GGUF models and MTP drafters ────────────────────────────────
|
|
8
9
|
|
|
@@ -48,6 +49,7 @@ async function scanOneDir(root) {
|
|
|
48
49
|
const mmprojPath = mmprojs.find((candidate) => dirname(candidate) === dir) ?? null;
|
|
49
50
|
const name = basename(path).replace(/\.gguf$/i, "");
|
|
50
51
|
const sizeBytes = statSync(path).size;
|
|
52
|
+
const parsed = parseModelName(name, "local-gguf");
|
|
51
53
|
|
|
52
54
|
// Read GGUF metadata to detect drafter architecture
|
|
53
55
|
const meta = safeReadGgufMetadata(path);
|
|
@@ -57,9 +59,9 @@ async function scanOneDir(root) {
|
|
|
57
59
|
// This is an MTP drafter model, not a main model
|
|
58
60
|
drafters.push({
|
|
59
61
|
path,
|
|
60
|
-
label:
|
|
61
|
-
aliasSuggestion:
|
|
62
|
-
quant:
|
|
62
|
+
label: parsed.display,
|
|
63
|
+
aliasSuggestion: parsed.id,
|
|
64
|
+
quant: parsed.quant,
|
|
63
65
|
sizeBytes,
|
|
64
66
|
architecture,
|
|
65
67
|
targetHint: drafterTargetHint(name),
|
|
@@ -70,9 +72,9 @@ async function scanOneDir(root) {
|
|
|
70
72
|
models.push({
|
|
71
73
|
path,
|
|
72
74
|
mmprojPath,
|
|
73
|
-
label:
|
|
74
|
-
aliasSuggestion:
|
|
75
|
-
quant:
|
|
75
|
+
label: parsed.display,
|
|
76
|
+
aliasSuggestion: parsed.id,
|
|
77
|
+
quant: parsed.quant,
|
|
76
78
|
sizeBytes,
|
|
77
79
|
backend: "llama-cpp",
|
|
78
80
|
source: "local-gguf",
|
|
@@ -143,20 +145,7 @@ async function findFiles(root, predicate) {
|
|
|
143
145
|
return result;
|
|
144
146
|
}
|
|
145
147
|
|
|
146
|
-
|
|
147
|
-
return name
|
|
148
|
-
.replace(/-/g, " ")
|
|
149
|
-
.replace(/\bqwen/i, "Qwen")
|
|
150
|
-
.replace(/q4_k_m/i, "Q4_K_M");
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function aliasFromName(name) {
|
|
154
|
-
return name.replace(/-Q4_K_M$/i, "-GGUF");
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function quantFromName(name) {
|
|
158
|
-
return name.match(/(Q\d_K_[A-Z]+|Q\d_[01]|UD-[A-Z0-9_]+)/)?.[1];
|
|
159
|
-
}
|
|
148
|
+
// (labelFromName, aliasFromName, quantFromName removed — parseModelName in model-name.mjs is the single path)
|
|
160
149
|
|
|
161
150
|
|
|
162
151
|
function safeReadGgufMetadata(path) {
|