@khanglvm/llm-router 2.4.1 → 2.5.1
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/CHANGELOG.md +17 -0
- package/README.md +12 -0
- package/package.json +1 -1
- package/src/node/huggingface-gguf.js +273 -0
- package/src/node/llamacpp-runtime.js +309 -0
- package/src/node/local-model-browser.js +132 -0
- package/src/node/local-model-capacity.js +39 -0
- package/src/node/local-models-service.js +238 -0
- package/src/node/start-command.js +12 -0
- package/src/node/web-console-client.js +27 -27
- package/src/node/web-console-server.js +575 -0
- package/src/node/web-console-styles.generated.js +1 -1
- package/src/node/web-console-ui/api-client.js +94 -0
- package/src/node/web-console-ui/local-models-utils.js +138 -0
- package/src/runtime/config.js +22 -7
- package/src/runtime/handler/provider-translation.js +5 -5
- package/src/runtime/local-models.js +168 -0
- package/src/translator/response/openai-to-claude.js +70 -9
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.5.1] - 2026-04-23
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Relaxed the live Claude Code publish smoke check so short affirmative routed replies such as `OK` or `好的` no longer fail `npm publish` when the end-to-end router path is otherwise healthy.
|
|
14
|
+
|
|
15
|
+
## [2.5.0] - 2026-04-23
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Local Models can now use a native macOS file/folder picker to attach GGUF files in place, scan a selected folder recursively for GGUF artifacts, and browse directly to a local `llama-server` runtime binary.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- Hugging Face GGUF search results for Local Models now rank quantizations more intelligently, show tighter Mac memory-fit guidance, and call out better long-context download choices for 64 GB Macs.
|
|
22
|
+
- `llama.cpp` runtime detection now searches common local source-build locations in addition to `PATH` and Homebrew installs, and server validation now recognizes more `llama-server` help output variants including TurboQuant builds.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- OpenAI-to-Claude response translation now preserves Anthropic-compatible usage metadata such as `speed`, `service_tier`, cache counters, and tool-usage fields so Claude Code no longer trips over missing `usage.speed` on routed responses.
|
|
26
|
+
|
|
10
27
|
## [2.4.1] - 2026-04-19
|
|
11
28
|
|
|
12
29
|
### Fixed
|
package/README.md
CHANGED
|
@@ -36,6 +36,18 @@ llr ai-help # agent-oriented setup brief
|
|
|
36
36
|
- **Deployable** — run locally or deploy to Cloudflare Workers
|
|
37
37
|
- **AI-agent friendly** — full CLI parity with `llr config --operation=...` so agents can configure everything programmatically
|
|
38
38
|
|
|
39
|
+
## Local Models
|
|
40
|
+
|
|
41
|
+
Open `llr` and use the **Local Models** tab to manage local inference sources alongside hosted providers.
|
|
42
|
+
|
|
43
|
+
- **`llama.cpp` runtime** — detect or point at a local `llama-server`, attach GGUF files in place, or download public GGUF artifacts into the router-managed library under `~/.llm-router/local-models`
|
|
44
|
+
- **Native macOS browsing** — use the built-in file picker to choose a single GGUF file, scan a folder recursively for GGUF models, or browse directly to a local `llama-server` binary
|
|
45
|
+
- **Managed + attached model library** — stale or moved files stay visible instead of crashing the app, and can be repaired by locating the file again or removed cleanly
|
|
46
|
+
- **Router-visible local variants** — create friendly model variants with bounded presets, context-window metadata, preload toggles, and Mac unified-memory fit guidance with clearer safe/tight recommendations
|
|
47
|
+
- **Alias-ready local routing** — once saved, local variants behave like normal router models and can be used in aliases, capability flags, and fallback chains
|
|
48
|
+
|
|
49
|
+
For v1, the managed download flow only searches public Hugging Face GGUF files and the fit guidance is tuned for Macs with unified memory.
|
|
50
|
+
|
|
39
51
|
## Local Runtime Reliability
|
|
40
52
|
|
|
41
53
|
`llr start` keeps a small supervisor bound to the fixed local router port and runs the real router backend behind it on an internal loopback port.
|
package/package.json
CHANGED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
|
|
4
|
+
const HUGGING_FACE_API_URL = "https://huggingface.co/api/models";
|
|
5
|
+
const HUGGING_FACE_BASE_URL = "https://huggingface.co";
|
|
6
|
+
const POTENTIAL_MODEL_ARTIFACT_PATTERN = /\.(gguf|safetensors|bin|pth|pt)$/i;
|
|
7
|
+
const DEFAULT_EXPECTED_CONTEXT_WINDOW = 200000;
|
|
8
|
+
|
|
9
|
+
function normalizeString(value) {
|
|
10
|
+
return typeof value === "string" ? value.trim() : "";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizePositiveNumber(value) {
|
|
14
|
+
const parsed = Number(value);
|
|
15
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
|
|
16
|
+
return parsed;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseQuantizationFromFileName(fileName) {
|
|
20
|
+
const match = String(fileName || "").match(/(UD-[A-Z0-9_]+|IQ\d+_[A-Z]+|Q\d+_[A-Z0-9]+|Q\d+_0|MXFP4_MOE|BF16|F16|F32)/i);
|
|
21
|
+
return match ? match[1].toUpperCase() : "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function scoreQuantization(fileName) {
|
|
25
|
+
const quantization = parseQuantizationFromFileName(fileName);
|
|
26
|
+
if (!quantization) return 0;
|
|
27
|
+
if (quantization.startsWith("Q5")) return 6;
|
|
28
|
+
if (quantization.startsWith("IQ")) return 5;
|
|
29
|
+
if (quantization === "Q4_K_M" || quantization === "Q4_K_S" || quantization.startsWith("Q4")) return 4;
|
|
30
|
+
if (quantization.startsWith("Q6")) return 3;
|
|
31
|
+
if (quantization.startsWith("Q8")) return 2;
|
|
32
|
+
if (quantization === "BF16" || quantization === "F16" || quantization === "F32") return 1;
|
|
33
|
+
return 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildCompatibilityBadges(fileName, fit, recommendation = "") {
|
|
37
|
+
const badges = [];
|
|
38
|
+
if (/\.gguf$/i.test(fileName)) badges.push("GGUF");
|
|
39
|
+
badges.push("llama.cpp");
|
|
40
|
+
if (fit === "safe") badges.push("Mac OK");
|
|
41
|
+
else if (fit === "tight") badges.push("Mac Tight");
|
|
42
|
+
else badges.push("Mac review");
|
|
43
|
+
if (/best fit/i.test(recommendation)) badges.push("Best fit");
|
|
44
|
+
return badges;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isPotentialModelArtifact(fileName) {
|
|
48
|
+
return POTENTIAL_MODEL_ARTIFACT_PATTERN.test(String(fileName || ""));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function encodePathSegments(rawPath) {
|
|
52
|
+
return String(rawPath || "")
|
|
53
|
+
.split("/")
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.map((segment) => encodeURIComponent(segment))
|
|
56
|
+
.join("/");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function extractHuggingFaceFiles(models = []) {
|
|
60
|
+
const files = [];
|
|
61
|
+
for (const model of Array.isArray(models) ? models : []) {
|
|
62
|
+
const repo = normalizeString(model?.id || model?.modelId);
|
|
63
|
+
if (!repo) continue;
|
|
64
|
+
for (const sibling of Array.isArray(model?.siblings) ? model.siblings : []) {
|
|
65
|
+
const file = normalizeString(sibling?.rfilename);
|
|
66
|
+
if (!file || !isPotentialModelArtifact(file)) continue;
|
|
67
|
+
files.push({
|
|
68
|
+
repo,
|
|
69
|
+
file,
|
|
70
|
+
size: normalizePositiveNumber(sibling?.size) ?? normalizePositiveNumber(sibling?.lfs?.size),
|
|
71
|
+
downloads: normalizePositiveNumber(model?.downloads) || 0,
|
|
72
|
+
likes: normalizePositiveNumber(model?.likes) || 0,
|
|
73
|
+
gguf: model?.gguf || undefined,
|
|
74
|
+
private: model?.private === true,
|
|
75
|
+
gated: model?.gated === true
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return files;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function classifyGgufCandidateForMac(candidate, { totalMemoryBytes } = {}) {
|
|
83
|
+
const fileName = normalizeString(candidate?.file || candidate?.rfilename);
|
|
84
|
+
const sizeBytes = normalizePositiveNumber(candidate?.sizeBytes ?? candidate?.size);
|
|
85
|
+
const expectedContextWindow = normalizePositiveNumber(candidate?.expectedContextWindow) || DEFAULT_EXPECTED_CONTEXT_WINDOW;
|
|
86
|
+
|
|
87
|
+
if (!/\.gguf$/i.test(fileName)) {
|
|
88
|
+
return {
|
|
89
|
+
fit: "unsupported",
|
|
90
|
+
disabled: true,
|
|
91
|
+
reason: "Not a GGUF file",
|
|
92
|
+
recommendation: "Unsupported for llama.cpp in v1."
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (sizeBytes && totalMemoryBytes && sizeBytes > Number(totalMemoryBytes) * 0.85) {
|
|
97
|
+
return {
|
|
98
|
+
fit: "over-budget",
|
|
99
|
+
disabled: true,
|
|
100
|
+
reason: "Too large for this Mac",
|
|
101
|
+
recommendation: "Skip this one on a 64 GB Mac."
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!sizeBytes || !totalMemoryBytes) {
|
|
106
|
+
return {
|
|
107
|
+
fit: "unknown",
|
|
108
|
+
disabled: false,
|
|
109
|
+
reason: "",
|
|
110
|
+
recommendation: "Review memory fit manually before download."
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const memoryRatio = sizeBytes / Number(totalMemoryBytes);
|
|
115
|
+
const quantScore = scoreQuantization(fileName);
|
|
116
|
+
|
|
117
|
+
if (expectedContextWindow >= 200000 && memoryRatio >= 0.5) {
|
|
118
|
+
return {
|
|
119
|
+
fit: "tight",
|
|
120
|
+
disabled: false,
|
|
121
|
+
reason: "200K context will be tight on this Mac",
|
|
122
|
+
recommendation: quantScore >= 2
|
|
123
|
+
? "200K context needs review on a 64 GB Mac."
|
|
124
|
+
: "Large context and heavy quantization choice need review."
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (memoryRatio >= 0.4) {
|
|
129
|
+
return {
|
|
130
|
+
fit: "tight",
|
|
131
|
+
disabled: false,
|
|
132
|
+
reason: "Fits, but leaves limited unified memory headroom",
|
|
133
|
+
recommendation: "Reasonable fit, but memory headroom will be tight."
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
fit: "safe",
|
|
139
|
+
disabled: false,
|
|
140
|
+
reason: "",
|
|
141
|
+
recommendation: quantScore >= 4
|
|
142
|
+
? "Best fit for a 64 GB Mac and long-context testing."
|
|
143
|
+
: "Fits this Mac comfortably."
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function shapeHuggingFaceGgufResults(files, systemInfo = {}) {
|
|
148
|
+
const results = (Array.isArray(files) ? files : []).map((entry) => {
|
|
149
|
+
const file = normalizeString(entry?.file || entry?.rfilename);
|
|
150
|
+
const sizeBytes = normalizePositiveNumber(entry?.sizeBytes ?? entry?.size);
|
|
151
|
+
const status = classifyGgufCandidateForMac({
|
|
152
|
+
file,
|
|
153
|
+
sizeBytes,
|
|
154
|
+
expectedContextWindow: systemInfo?.expectedContextWindow
|
|
155
|
+
}, systemInfo);
|
|
156
|
+
const quantization = parseQuantizationFromFileName(file);
|
|
157
|
+
const fitScore = status.fit === "safe" ? 30 : status.fit === "tight" ? 15 : status.fit === "unknown" ? 8 : -20;
|
|
158
|
+
const rankingScore = fitScore
|
|
159
|
+
+ (status.disabled ? -100 : 0)
|
|
160
|
+
+ (scoreQuantization(file) * 10)
|
|
161
|
+
+ Math.min(15, Math.log10(Number(entry?.downloads || 0) + 1) * 4)
|
|
162
|
+
+ Math.min(8, Math.log10(Number(entry?.likes || 0) + 1) * 3)
|
|
163
|
+
- Math.min(12, (sizeBytes || 0) / (1024 ** 3));
|
|
164
|
+
return {
|
|
165
|
+
repo: normalizeString(entry?.repo || entry?.id || entry?.modelId),
|
|
166
|
+
file,
|
|
167
|
+
quantization,
|
|
168
|
+
sizeBytes,
|
|
169
|
+
disabled: status.disabled,
|
|
170
|
+
disabledReason: status.reason,
|
|
171
|
+
fit: status.fit,
|
|
172
|
+
recommendation: status.recommendation,
|
|
173
|
+
badges: buildCompatibilityBadges(file, status.fit, status.recommendation),
|
|
174
|
+
rankingScore
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return results.sort((left, right) => {
|
|
179
|
+
if (right.rankingScore !== left.rankingScore) return right.rankingScore - left.rankingScore;
|
|
180
|
+
return String(left.file || "").localeCompare(String(right.file || ""));
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function searchHuggingFaceGgufCandidates(query, {
|
|
185
|
+
limit = 20,
|
|
186
|
+
totalMemoryBytes,
|
|
187
|
+
expectedContextWindow = DEFAULT_EXPECTED_CONTEXT_WINDOW,
|
|
188
|
+
fetchImpl = fetch
|
|
189
|
+
} = {}) {
|
|
190
|
+
const search = normalizeString(query);
|
|
191
|
+
const url = new URL(HUGGING_FACE_API_URL);
|
|
192
|
+
if (search) url.searchParams.set("search", search);
|
|
193
|
+
url.searchParams.set("limit", String(Math.max(1, Math.min(50, Number(limit) || 20))));
|
|
194
|
+
for (const field of ["siblings", "gguf", "downloads", "likes", "gated", "private"]) {
|
|
195
|
+
url.searchParams.append("expand[]", field);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const response = await fetchImpl(url, {
|
|
199
|
+
headers: {
|
|
200
|
+
accept: "application/json"
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
if (!response.ok) {
|
|
204
|
+
throw new Error(`Hugging Face search failed (${response.status}).`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const payload = await response.json();
|
|
208
|
+
return shapeHuggingFaceGgufResults(
|
|
209
|
+
extractHuggingFaceFiles(payload),
|
|
210
|
+
{ totalMemoryBytes, expectedContextWindow }
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function buildHuggingFaceFileDownloadUrl(repo, file) {
|
|
215
|
+
const normalizedRepo = encodePathSegments(repo);
|
|
216
|
+
const normalizedFile = encodePathSegments(file);
|
|
217
|
+
return `${HUGGING_FACE_BASE_URL}/${normalizedRepo}/resolve/main/${normalizedFile}?download=true`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function downloadManagedHuggingFaceGguf({
|
|
221
|
+
repo,
|
|
222
|
+
file,
|
|
223
|
+
destinationPath
|
|
224
|
+
} = {}, {
|
|
225
|
+
fetchImpl = fetch,
|
|
226
|
+
onProgress = () => {}
|
|
227
|
+
} = {}) {
|
|
228
|
+
const targetRepo = normalizeString(repo);
|
|
229
|
+
const targetFile = normalizeString(file);
|
|
230
|
+
const outputPath = normalizeString(destinationPath);
|
|
231
|
+
if (!targetRepo || !targetFile || !outputPath) {
|
|
232
|
+
throw new Error("repo, file, and destinationPath are required.");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const url = buildHuggingFaceFileDownloadUrl(targetRepo, targetFile);
|
|
236
|
+
const response = await fetchImpl(url, {
|
|
237
|
+
headers: {
|
|
238
|
+
accept: "application/octet-stream"
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
if (!response.ok || !response.body) {
|
|
242
|
+
throw new Error(`Hugging Face download failed (${response.status}).`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
246
|
+
const tempPath = `${outputPath}.part`;
|
|
247
|
+
const fileHandle = await fs.open(tempPath, "w");
|
|
248
|
+
const totalBytes = normalizePositiveNumber(response.headers.get("content-length"));
|
|
249
|
+
let receivedBytes = 0;
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const reader = response.body.getReader();
|
|
253
|
+
while (true) {
|
|
254
|
+
const { value, done } = await reader.read();
|
|
255
|
+
if (done) break;
|
|
256
|
+
const chunk = value || new Uint8Array();
|
|
257
|
+
if (chunk.byteLength > 0) {
|
|
258
|
+
await fileHandle.write(chunk);
|
|
259
|
+
receivedBytes += chunk.byteLength;
|
|
260
|
+
onProgress({ receivedBytes, totalBytes });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
} finally {
|
|
264
|
+
await fileHandle.close();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
await fs.rename(tempPath, outputPath);
|
|
268
|
+
return {
|
|
269
|
+
filePath: outputPath,
|
|
270
|
+
sizeBytes: receivedBytes || totalBytes || undefined,
|
|
271
|
+
downloadUrl: url
|
|
272
|
+
};
|
|
273
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
export const LLAMACPP_DEFAULT_HOST = "127.0.0.1";
|
|
7
|
+
export const LLAMACPP_DEFAULT_PORT = 39391;
|
|
8
|
+
const LLAMACPP_EXECUTABLE = "llama-server";
|
|
9
|
+
const FALLBACK_LLAMACPP_PATHS = Object.freeze([
|
|
10
|
+
"/opt/homebrew/bin/llama-server",
|
|
11
|
+
"/usr/local/bin/llama-server"
|
|
12
|
+
]);
|
|
13
|
+
const COMMON_SOURCE_BUILD_PATHS = Object.freeze([
|
|
14
|
+
"src/llama-cpp/build/bin/llama-server",
|
|
15
|
+
"src/llama.cpp/build/bin/llama-server",
|
|
16
|
+
"src/llama-cpp-turboquant/build/bin/llama-server",
|
|
17
|
+
"src/llama.cpp-turboquant/build/bin/llama-server"
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
let managedLlamacppRuntime = null;
|
|
21
|
+
|
|
22
|
+
function isPlainObject(value) {
|
|
23
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeString(value) {
|
|
27
|
+
return typeof value === "string" ? value.trim() : "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizePort(value, fallback = LLAMACPP_DEFAULT_PORT) {
|
|
31
|
+
const parsed = Number(value);
|
|
32
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) return fallback;
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizePathEntries(entries) {
|
|
37
|
+
return Array.isArray(entries)
|
|
38
|
+
? entries.map((entry) => normalizeString(entry)).filter(Boolean)
|
|
39
|
+
: [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readConfiguredLlamacppRuntime(config) {
|
|
43
|
+
const runtime = config?.metadata?.localModels?.runtime?.llamacpp;
|
|
44
|
+
if (!isPlainObject(runtime)) {
|
|
45
|
+
return {
|
|
46
|
+
startWithRouter: false,
|
|
47
|
+
command: "",
|
|
48
|
+
host: LLAMACPP_DEFAULT_HOST,
|
|
49
|
+
port: LLAMACPP_DEFAULT_PORT
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
startWithRouter: runtime.startWithRouter === true,
|
|
55
|
+
command: normalizeString(runtime.selectedCommand || runtime.manualCommand || runtime.command || runtime.path),
|
|
56
|
+
host: normalizeString(runtime.host) || LLAMACPP_DEFAULT_HOST,
|
|
57
|
+
port: normalizePort(runtime.port, LLAMACPP_DEFAULT_PORT)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildPreloadModels(config) {
|
|
62
|
+
const library = config?.metadata?.localModels?.library;
|
|
63
|
+
const variants = config?.metadata?.localModels?.variants;
|
|
64
|
+
if (!isPlainObject(library) || !isPlainObject(variants)) return [];
|
|
65
|
+
|
|
66
|
+
const preloadModels = [];
|
|
67
|
+
for (const variant of Object.values(variants)) {
|
|
68
|
+
if (!isPlainObject(variant)) continue;
|
|
69
|
+
if (variant.runtime !== "llamacpp" || variant.preload !== true || variant.enabled !== true) continue;
|
|
70
|
+
const baseModel = library[variant.baseModelId];
|
|
71
|
+
const modelPath = normalizeString(baseModel?.path);
|
|
72
|
+
if (!modelPath) continue;
|
|
73
|
+
preloadModels.push({
|
|
74
|
+
variantId: normalizeString(variant.id),
|
|
75
|
+
modelPath,
|
|
76
|
+
contextWindow: Number.isFinite(Number(variant.contextWindow)) ? Number(variant.contextWindow) : undefined
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return preloadModels;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function detectLlamacppCandidates({
|
|
83
|
+
envPathEntries = process.env.PATH?.split(path.delimiter) || [],
|
|
84
|
+
homeDir = os.homedir(),
|
|
85
|
+
existingPaths = null
|
|
86
|
+
} = {}) {
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
const candidates = [];
|
|
89
|
+
const searchTargets = [
|
|
90
|
+
...normalizePathEntries(envPathEntries).map((entry) => ({
|
|
91
|
+
path: path.join(entry, LLAMACPP_EXECUTABLE),
|
|
92
|
+
source: "path"
|
|
93
|
+
})),
|
|
94
|
+
...FALLBACK_LLAMACPP_PATHS.map((entry) => ({
|
|
95
|
+
path: entry,
|
|
96
|
+
source: "homebrew"
|
|
97
|
+
})),
|
|
98
|
+
...COMMON_SOURCE_BUILD_PATHS.map((entry) => ({
|
|
99
|
+
path: path.join(homeDir, entry),
|
|
100
|
+
source: "source-build"
|
|
101
|
+
}))
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
for (const target of searchTargets) {
|
|
105
|
+
const candidatePath = normalizeString(target.path);
|
|
106
|
+
if (seen.has(candidatePath)) continue;
|
|
107
|
+
seen.add(candidatePath);
|
|
108
|
+
const exists = existingPaths instanceof Set ? existingPaths.has(candidatePath) : existsSync(candidatePath);
|
|
109
|
+
if (!exists) continue;
|
|
110
|
+
candidates.push({
|
|
111
|
+
id: candidatePath,
|
|
112
|
+
label: candidatePath,
|
|
113
|
+
path: candidatePath,
|
|
114
|
+
source: target.source
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return candidates;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function buildLlamacppLaunchArgs({
|
|
122
|
+
command,
|
|
123
|
+
host = LLAMACPP_DEFAULT_HOST,
|
|
124
|
+
port = LLAMACPP_DEFAULT_PORT,
|
|
125
|
+
preloadModels = []
|
|
126
|
+
} = {}) {
|
|
127
|
+
const firstModel = Array.isArray(preloadModels) ? preloadModels[0] : null;
|
|
128
|
+
const args = [
|
|
129
|
+
normalizeString(command),
|
|
130
|
+
"--host", normalizeString(host) || LLAMACPP_DEFAULT_HOST,
|
|
131
|
+
"--port", String(normalizePort(port, LLAMACPP_DEFAULT_PORT))
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
if (firstModel?.modelPath) {
|
|
135
|
+
args.push("-m", firstModel.modelPath);
|
|
136
|
+
if (Number.isFinite(Number(firstModel.contextWindow)) && Number(firstModel.contextWindow) > 0) {
|
|
137
|
+
args.push("-c", String(Math.floor(Number(firstModel.contextWindow))));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return args.filter(Boolean);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function parseLlamacppValidationOutput(output = "") {
|
|
145
|
+
const text = String(output || "").trim();
|
|
146
|
+
const lowered = text.toLowerCase();
|
|
147
|
+
const supportsHost = /(^|\s)--host(\s|$)/m.test(text);
|
|
148
|
+
const supportsPort = /(^|\s)--port(\s|$)/m.test(text);
|
|
149
|
+
const referencesModelFlag = /(^|\s)(-m,\s+)?--model(\s|$)/m.test(text);
|
|
150
|
+
const looksLikeServerHelp = supportsHost && supportsPort && referencesModelFlag;
|
|
151
|
+
const kind = lowered.includes("llama-server") || looksLikeServerHelp ? "server" : "";
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
ok: Boolean(kind) && supportsHost && supportsPort,
|
|
155
|
+
kind,
|
|
156
|
+
supportsHost,
|
|
157
|
+
supportsPort,
|
|
158
|
+
isTurboQuant: lowered.includes("turboquant") || /\bturbo[234]\b/.test(lowered)
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function validateLlamacppCommand(command, { spawnSyncImpl = spawnSync } = {}) {
|
|
163
|
+
const target = normalizeString(command);
|
|
164
|
+
if (!target) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
errorMessage: "No llama.cpp command is configured."
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const result = spawnSyncImpl(target, ["--help"], {
|
|
172
|
+
encoding: "utf8"
|
|
173
|
+
});
|
|
174
|
+
if (result?.error) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
errorMessage: result.error instanceof Error ? result.error.message : String(result.error)
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const parsed = parseLlamacppValidationOutput(`${result?.stdout || ""}\n${result?.stderr || ""}`);
|
|
182
|
+
if (!parsed.ok) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
errorMessage: `Command '${target}' does not appear to be a compatible llama-server binary.`,
|
|
186
|
+
...parsed
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
ok: true,
|
|
192
|
+
...parsed
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function startConfiguredRuntime(config, {
|
|
197
|
+
line = () => {},
|
|
198
|
+
error = () => {},
|
|
199
|
+
requireAutostart = true
|
|
200
|
+
} = {}, {
|
|
201
|
+
spawnSyncImpl = spawnSync,
|
|
202
|
+
spawnImpl = spawn
|
|
203
|
+
} = {}) {
|
|
204
|
+
const runtime = readConfiguredLlamacppRuntime(config);
|
|
205
|
+
if (requireAutostart && !runtime.startWithRouter) {
|
|
206
|
+
return { ok: true, skipped: true, reason: "autostart-disabled" };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!runtime.command) {
|
|
210
|
+
const errorMessage = "llama.cpp autostart is enabled, but no runtime command is configured.";
|
|
211
|
+
error(errorMessage);
|
|
212
|
+
return { ok: false, errorMessage };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (managedLlamacppRuntime
|
|
216
|
+
&& managedLlamacppRuntime.command === runtime.command
|
|
217
|
+
&& managedLlamacppRuntime.host === runtime.host
|
|
218
|
+
&& managedLlamacppRuntime.port === runtime.port
|
|
219
|
+
&& managedLlamacppRuntime.child?.exitCode === null
|
|
220
|
+
&& managedLlamacppRuntime.child?.killed !== true) {
|
|
221
|
+
return { ok: true, alreadyRunning: true, runtime: managedLlamacppRuntime };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const validation = validateLlamacppCommand(runtime.command, { spawnSyncImpl });
|
|
225
|
+
if (!validation.ok) {
|
|
226
|
+
error(validation.errorMessage || `Failed validating llama.cpp runtime '${runtime.command}'.`);
|
|
227
|
+
return validation;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const preloadModels = buildPreloadModels(config);
|
|
231
|
+
const args = buildLlamacppLaunchArgs({
|
|
232
|
+
command: runtime.command,
|
|
233
|
+
host: runtime.host,
|
|
234
|
+
port: runtime.port,
|
|
235
|
+
preloadModels
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
return new Promise((resolve) => {
|
|
239
|
+
let settled = false;
|
|
240
|
+
const child = spawnImpl(args[0], args.slice(1), {
|
|
241
|
+
stdio: "ignore"
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const finish = (result) => {
|
|
245
|
+
if (settled) return;
|
|
246
|
+
settled = true;
|
|
247
|
+
resolve(result);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
child.once("spawn", () => {
|
|
251
|
+
managedLlamacppRuntime = {
|
|
252
|
+
child,
|
|
253
|
+
command: runtime.command,
|
|
254
|
+
host: runtime.host,
|
|
255
|
+
port: runtime.port,
|
|
256
|
+
args
|
|
257
|
+
};
|
|
258
|
+
child.once("exit", () => {
|
|
259
|
+
if (managedLlamacppRuntime?.child === child) {
|
|
260
|
+
managedLlamacppRuntime = null;
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
if (typeof child.unref === "function") child.unref();
|
|
264
|
+
line(`Started llama.cpp runtime on http://${runtime.host}:${runtime.port}${validation.isTurboQuant ? " (TurboQuant detected)" : ""}.`);
|
|
265
|
+
finish({ ok: true, runtime: managedLlamacppRuntime, validation });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
child.once("error", (spawnError) => {
|
|
269
|
+
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
|
270
|
+
error(`Failed starting llama.cpp runtime: ${errorMessage}`);
|
|
271
|
+
finish({ ok: false, errorMessage });
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function ensureConfiguredLlamacppRuntimeStarted(config, callbacks = {}, deps = {}) {
|
|
277
|
+
return startConfiguredRuntime(config, {
|
|
278
|
+
...callbacks,
|
|
279
|
+
requireAutostart: true
|
|
280
|
+
}, deps);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function startConfiguredLlamacppRuntime(config, callbacks = {}, deps = {}) {
|
|
284
|
+
return startConfiguredRuntime(config, {
|
|
285
|
+
...callbacks,
|
|
286
|
+
requireAutostart: false
|
|
287
|
+
}, deps);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function stopManagedLlamacppRuntime({
|
|
291
|
+
line = () => {},
|
|
292
|
+
error = () => {}
|
|
293
|
+
} = {}) {
|
|
294
|
+
const active = managedLlamacppRuntime;
|
|
295
|
+
if (!active?.child) {
|
|
296
|
+
return { ok: true, skipped: true, reason: "not-running" };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
managedLlamacppRuntime = null;
|
|
300
|
+
try {
|
|
301
|
+
active.child.kill("SIGTERM");
|
|
302
|
+
line("Stopped managed llama.cpp runtime.");
|
|
303
|
+
return { ok: true };
|
|
304
|
+
} catch (stopError) {
|
|
305
|
+
const errorMessage = stopError instanceof Error ? stopError.message : String(stopError);
|
|
306
|
+
error(`Failed stopping llama.cpp runtime: ${errorMessage}`);
|
|
307
|
+
return { ok: false, errorMessage };
|
|
308
|
+
}
|
|
309
|
+
}
|