sublera-cli 0.0.0-stage → 1.3.0
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 +172 -0
- package/LICENSE +21 -0
- package/README.md +585 -2
- package/SECURITY.md +78 -0
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/dist/agent.d.ts +270 -0
- package/dist/agent.js +1867 -0
- package/dist/commands.d.ts +76 -0
- package/dist/commands.js +841 -0
- package/dist/config.d.ts +27 -0
- package/dist/config.js +95 -0
- package/dist/engine/binaryManager.d.ts +67 -0
- package/dist/engine/binaryManager.js +590 -0
- package/dist/engine/gguf.d.ts +88 -0
- package/dist/engine/gguf.js +458 -0
- package/dist/engine/llamaRunner.d.ts +78 -0
- package/dist/engine/llamaRunner.js +282 -0
- package/dist/engine/modelManager.d.ts +63 -0
- package/dist/engine/modelManager.js +510 -0
- package/dist/engine/selfCheck.d.ts +34 -0
- package/dist/engine/selfCheck.js +108 -0
- package/dist/exitCodes.d.ts +20 -0
- package/dist/exitCodes.js +20 -0
- package/dist/hardware/gpuMonitor.d.ts +32 -0
- package/dist/hardware/gpuMonitor.js +378 -0
- package/dist/hardware/sleepGuard.d.ts +42 -0
- package/dist/hardware/sleepGuard.js +149 -0
- package/dist/hardware/types.d.ts +40 -0
- package/dist/hardware/types.js +2 -0
- package/dist/health.d.ts +17 -0
- package/dist/health.js +38 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +268 -0
- package/dist/logger.d.ts +53 -0
- package/dist/logger.js +201 -0
- package/dist/network/proxy.d.ts +58 -0
- package/dist/network/proxy.js +328 -0
- package/dist/network/streamProxy.d.ts +51 -0
- package/dist/network/streamProxy.js +286 -0
- package/dist/network/tunnelClient.d.ts +54 -0
- package/dist/network/tunnelClient.js +257 -0
- package/dist/settings.d.ts +53 -0
- package/dist/settings.js +171 -0
- package/dist/state.d.ts +52 -0
- package/dist/state.js +71 -0
- package/dist/storageCommand.d.ts +22 -0
- package/dist/storageCommand.js +81 -0
- package/dist/types.d.ts +222 -0
- package/dist/types.js +43 -0
- package/dist/ui/progress.d.ts +29 -0
- package/dist/ui/progress.js +82 -0
- package/dist/ui/terminalDashboard.d.ts +88 -0
- package/dist/ui/terminalDashboard.js +295 -0
- package/dist/util/env.d.ts +3 -0
- package/dist/util/env.js +6 -0
- package/dist/util/exec.d.ts +23 -0
- package/dist/util/exec.js +116 -0
- package/dist/util/format.d.ts +47 -0
- package/dist/util/format.js +125 -0
- package/dist/util/fs.d.ts +18 -0
- package/dist/util/fs.js +140 -0
- package/dist/util/http.d.ts +28 -0
- package/dist/util/http.js +128 -0
- package/dist/util/instanceLock.d.ts +3 -0
- package/dist/util/instanceLock.js +108 -0
- package/dist/util/open.d.ts +16 -0
- package/dist/util/open.js +65 -0
- package/dist/util/storage.d.ts +10 -0
- package/dist/util/storage.js +69 -0
- package/dist/version.d.ts +15 -0
- package/dist/version.js +29 -0
- package/package.json +68 -4
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
import { createWriteStream } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { brandEnv } from '../util/env.js';
|
|
4
|
+
import { chmod, copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { basename, dirname, join, normalize, relative, sep } from 'node:path';
|
|
6
|
+
import AdmZip from 'adm-zip';
|
|
7
|
+
import * as tar from 'tar';
|
|
8
|
+
import { binDir, cacheDir } from '../config.js';
|
|
9
|
+
import { USER_AGENT } from '../version.js';
|
|
10
|
+
import { atomicWrite, pathExists, safeJoin, sha256File } from '../util/fs.js';
|
|
11
|
+
import { fetchJson } from '../util/http.js';
|
|
12
|
+
import { which } from '../util/exec.js';
|
|
13
|
+
import { ProgressBar } from '../ui/progress.js';
|
|
14
|
+
import { directoryBytes, diskWriteAllowance, requireDiskSpace, storagePolicy } from '../util/storage.js';
|
|
15
|
+
const MAX_ARCHIVE_BYTES = 2 * 1024 ** 3;
|
|
16
|
+
const MAX_ENGINE_CACHE_BYTES = 10 * 1024 ** 3;
|
|
17
|
+
const RELEASE_CACHE_MS = 6 * 60 * 60 * 1000;
|
|
18
|
+
const RELEASE_URL = 'https://api.github.com/repos/ggml-org/llama.cpp/releases/latest';
|
|
19
|
+
const RELEASE_LIST_URL = 'https://api.github.com/repos/ggml-org/llama.cpp/releases?per_page=20';
|
|
20
|
+
const RELEASE_URL_FALLBACK = 'https://api.github.com/repos/ggerganov/llama.cpp/releases/latest';
|
|
21
|
+
export function variantFor(platform, arch, vendor) {
|
|
22
|
+
if (platform === 'darwin')
|
|
23
|
+
return arch === 'arm64' ? 'metal' : 'cpu';
|
|
24
|
+
if (vendor === 'nvidia')
|
|
25
|
+
return 'cuda';
|
|
26
|
+
if (vendor === 'amd' || vendor === 'intel')
|
|
27
|
+
return 'vulkan';
|
|
28
|
+
return 'cpu';
|
|
29
|
+
}
|
|
30
|
+
function patternsFor(platform, arch, variant) {
|
|
31
|
+
if (platform === 'win32') {
|
|
32
|
+
if (variant === 'cuda')
|
|
33
|
+
return [/bin-win-cuda.*x64\.zip$/i, /win-cuda.*x64\.zip$/i, /win-cuda.*\.zip$/i];
|
|
34
|
+
if (variant === 'vulkan')
|
|
35
|
+
return [/bin-win-vulkan.*x64\.zip$/i, /win-vulkan.*x64\.zip$/i];
|
|
36
|
+
return [/bin-win-cpu.*x64\.zip$/i, /win-cpu.*x64\.zip$/i];
|
|
37
|
+
}
|
|
38
|
+
if (platform === 'darwin') {
|
|
39
|
+
return arch === 'arm64'
|
|
40
|
+
? [/macos-arm64\.(zip|tar\.gz)$/i, /bin-macos-arm64\.(zip|tar\.gz)$/i, /macos.*arm64.*\.(zip|tar\.gz)$/i]
|
|
41
|
+
: [/macos-x64\.(zip|tar\.gz)$/i, /bin-macos-x64\.(zip|tar\.gz)$/i];
|
|
42
|
+
}
|
|
43
|
+
if (variant === 'vulkan')
|
|
44
|
+
return [/ubuntu-vulkan.*x64\.(zip|tar\.gz)$/i, /linux-vulkan.*x64\.(zip|tar\.gz)$/i];
|
|
45
|
+
if (variant === 'cuda')
|
|
46
|
+
return [/ubuntu-cuda.*x64\.(zip|tar\.gz)$/i, /linux-cuda.*x64\.(zip|tar\.gz)$/i, /ubuntu.*x64\.(zip|tar\.gz)$/i];
|
|
47
|
+
return [/ubuntu-x64\.(zip|tar\.gz)$/i, /ubuntu.*x64\.(zip|tar\.gz)$/i, /linux.*x64\.(zip|tar\.gz)$/i];
|
|
48
|
+
}
|
|
49
|
+
export function pickReleaseAsset(release, platform, arch, variant) {
|
|
50
|
+
const assets = release.assets.filter((a) => !/\.sha256(\.txt)?$/i.test(a.name) && !/^cudart/i.test(a.name));
|
|
51
|
+
let main = null;
|
|
52
|
+
for (const pattern of patternsFor(platform, arch, variant)) {
|
|
53
|
+
const found = assets.find((a) => pattern.test(a.name));
|
|
54
|
+
if (found) {
|
|
55
|
+
main = found;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (!main && variant !== 'cpu') {
|
|
60
|
+
// A CPU build is a safe fallback on a machine without the expected runtime.
|
|
61
|
+
const patterns = patternsFor(platform, arch, 'cpu');
|
|
62
|
+
for (const pattern of patterns) {
|
|
63
|
+
const found = assets.find((a) => pattern.test(a.name));
|
|
64
|
+
if (found) {
|
|
65
|
+
main = found;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const cudaVersion = main?.name.match(/cuda-([\d.]+)-x64/i)?.[1] || null;
|
|
71
|
+
const companions = variant === 'cuda'
|
|
72
|
+
? release.assets.filter((a) => {
|
|
73
|
+
if (!/^cudart/i.test(a.name) || /\.sha256/i.test(a.name))
|
|
74
|
+
return false;
|
|
75
|
+
if (!/(x64|x86_64)/i.test(a.name))
|
|
76
|
+
return false;
|
|
77
|
+
if (platform === 'win32' && !/win/i.test(a.name))
|
|
78
|
+
return false;
|
|
79
|
+
if (platform === 'linux' && !/(ubuntu|linux)/i.test(a.name))
|
|
80
|
+
return false;
|
|
81
|
+
if (platform === 'darwin')
|
|
82
|
+
return false;
|
|
83
|
+
if (cudaVersion && !a.name.includes(cudaVersion))
|
|
84
|
+
return false;
|
|
85
|
+
return true;
|
|
86
|
+
})
|
|
87
|
+
: [];
|
|
88
|
+
return { main, companions };
|
|
89
|
+
}
|
|
90
|
+
function releaseCachePath(env, key = 'default') {
|
|
91
|
+
return join(cacheDir(env), `llama-release-${key}.json`);
|
|
92
|
+
}
|
|
93
|
+
async function readReleaseCache(env, key) {
|
|
94
|
+
try {
|
|
95
|
+
const raw = await readFile(releaseCachePath(env, key), 'utf8');
|
|
96
|
+
const data = JSON.parse(raw);
|
|
97
|
+
if (!data || Date.now() - data.fetchedAt > RELEASE_CACHE_MS || !data.release?.assets?.length)
|
|
98
|
+
return null;
|
|
99
|
+
return data.release;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function fetchRelease(options, platform, arch, variant) {
|
|
106
|
+
if (options.release)
|
|
107
|
+
return options.release;
|
|
108
|
+
const env = options.env ?? process.env;
|
|
109
|
+
const cacheKey = `${platform}-${arch}-${variant}`;
|
|
110
|
+
const cached = await readReleaseCache(env, cacheKey);
|
|
111
|
+
if (cached && pickReleaseAsset(cached, platform, arch, variant).main)
|
|
112
|
+
return cached;
|
|
113
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
114
|
+
const headers = { 'user-agent': USER_AGENT, accept: 'application/vnd.github+json' };
|
|
115
|
+
const parse = async (response) => {
|
|
116
|
+
if (!response.ok)
|
|
117
|
+
return null;
|
|
118
|
+
const data = await response.json();
|
|
119
|
+
const list = Array.isArray(data) ? data : [data];
|
|
120
|
+
return list.find((release) => release?.tag_name && Array.isArray(release.assets) && release.assets.length > 0 && pickReleaseAsset(release, platform, arch, variant).main) || null;
|
|
121
|
+
};
|
|
122
|
+
let release = null;
|
|
123
|
+
for (const url of [RELEASE_URL, RELEASE_LIST_URL, RELEASE_URL_FALLBACK]) {
|
|
124
|
+
try {
|
|
125
|
+
release = await parse(await fetchImpl(url, { headers, signal: AbortSignal.timeout(20_000) }));
|
|
126
|
+
if (release)
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* try the next endpoint */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!release)
|
|
134
|
+
throw new Error(`No llama.cpp release with a ${variant} build was found for ${platform}/${arch}`);
|
|
135
|
+
await mkdir(cacheDir(env), { recursive: true });
|
|
136
|
+
await writeFile(releaseCachePath(env, cacheKey), JSON.stringify({ fetchedAt: Date.now(), release }), 'utf8');
|
|
137
|
+
return release;
|
|
138
|
+
}
|
|
139
|
+
async function checksumFor(asset, release, options, isMain) {
|
|
140
|
+
const env = options.env ?? process.env;
|
|
141
|
+
const override = brandEnv(env, 'SUBLET_LLAMA_SHA256');
|
|
142
|
+
if (isMain && override && /^[a-f0-9]{64}$/i.test(override.trim()))
|
|
143
|
+
return { expected: override.trim().toLowerCase(), published: true };
|
|
144
|
+
if (asset.digest && /^sha256:[a-f0-9]{64}$/i.test(asset.digest))
|
|
145
|
+
return { expected: asset.digest.slice(7).toLowerCase(), published: true };
|
|
146
|
+
const sidecar = release.assets.find((a) => new RegExp(`^${escapeRegExp(asset.name)}\\.sha256(?:\\.txt)?$`, 'i').test(a.name));
|
|
147
|
+
if (sidecar) {
|
|
148
|
+
try {
|
|
149
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
150
|
+
const response = await fetchImpl(sidecar.browser_download_url, { headers: { 'user-agent': USER_AGENT }, signal: AbortSignal.timeout(20_000) });
|
|
151
|
+
const text = await response.text();
|
|
152
|
+
const match = text.match(/\b([a-f0-9]{64})\b/i);
|
|
153
|
+
if (match?.[1])
|
|
154
|
+
return { expected: match[1].toLowerCase(), published: true };
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
/* no published digest */
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { expected: null, published: false };
|
|
161
|
+
}
|
|
162
|
+
function escapeRegExp(value) {
|
|
163
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
164
|
+
}
|
|
165
|
+
async function downloadFile(url, dest, options) {
|
|
166
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
167
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
168
|
+
const bar = options.showProgress === false ? null : new ProgressBar({
|
|
169
|
+
label: options.label,
|
|
170
|
+
total: 0,
|
|
171
|
+
enabled: options.showProgress ?? Boolean((options.stream ?? process.stderr).isTTY),
|
|
172
|
+
stream: options.stream ?? process.stderr,
|
|
173
|
+
});
|
|
174
|
+
let lastError;
|
|
175
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
176
|
+
try {
|
|
177
|
+
const response = await fetchImpl(url, { redirect: 'follow', headers: { 'user-agent': USER_AGENT }, signal: AbortSignal.timeout(30 * 60_000) });
|
|
178
|
+
if (!response.ok || !response.body)
|
|
179
|
+
throw new Error(`HTTP ${response.status}`);
|
|
180
|
+
const total = Number(response.headers.get('content-length') || 0) || null;
|
|
181
|
+
const reserve = storagePolicy(options.env).reserveBytes;
|
|
182
|
+
const budget = Math.min(MAX_ARCHIVE_BYTES, await diskWriteAllowance(dirname(dest), reserve));
|
|
183
|
+
if (budget <= 0 || (total != null && (!Number.isSafeInteger(total) || total < 0 || total > budget))) {
|
|
184
|
+
await response.body.cancel().catch(() => undefined);
|
|
185
|
+
throw new Error('Engine archive exceeds storage budget (maximum 2 GiB), or insufficient free disk space');
|
|
186
|
+
}
|
|
187
|
+
if (total)
|
|
188
|
+
bar?.setTotal(total);
|
|
189
|
+
const writer = createWriteStream(dest, { flags: 'w' });
|
|
190
|
+
let received = 0;
|
|
191
|
+
const reader = response.body.getReader();
|
|
192
|
+
try {
|
|
193
|
+
while (true) {
|
|
194
|
+
const { done, value } = await reader.read();
|
|
195
|
+
if (done)
|
|
196
|
+
break;
|
|
197
|
+
if (value?.length) {
|
|
198
|
+
// Free space can shrink while streaming (other downloads/processes).
|
|
199
|
+
// Compare remaining allowance with this chunk plus writes still buffered,
|
|
200
|
+
// not all bytes received: flushed bytes already reduced free disk space.
|
|
201
|
+
const allowance = await diskWriteAllowance(dirname(dest), reserve);
|
|
202
|
+
if (received + value.length > budget || value.length + writer.writableLength > allowance) {
|
|
203
|
+
throw new Error('Engine archive exceeds storage budget');
|
|
204
|
+
}
|
|
205
|
+
if (!writer.write(Buffer.from(value))) {
|
|
206
|
+
await new Promise((resolve, reject) => {
|
|
207
|
+
const onDrain = () => { cleanup(); resolve(); };
|
|
208
|
+
const onError = (error) => { cleanup(); reject(error); };
|
|
209
|
+
const cleanup = () => {
|
|
210
|
+
writer.off('drain', onDrain);
|
|
211
|
+
writer.off('error', onError);
|
|
212
|
+
};
|
|
213
|
+
writer.once('drain', onDrain);
|
|
214
|
+
writer.once('error', onError);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
received += value.length;
|
|
218
|
+
bar?.set(received, total ?? undefined);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
await new Promise((resolve, reject) => writer.end((error) => error ? reject(error) : resolve()));
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
writer.destroy();
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
await reader.cancel().catch(() => undefined);
|
|
229
|
+
reader.releaseLock();
|
|
230
|
+
}
|
|
231
|
+
bar?.done();
|
|
232
|
+
return dest;
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
lastError = error;
|
|
236
|
+
bar?.fail();
|
|
237
|
+
await rm(dest, { force: true });
|
|
238
|
+
if (attempt < 3)
|
|
239
|
+
await new Promise((r) => setTimeout(r, 750 * attempt));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
throw new Error(`Could not download ${basename(url)}: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
|
243
|
+
}
|
|
244
|
+
export async function extractArchive(archive, destination) {
|
|
245
|
+
await mkdir(destination, { recursive: true });
|
|
246
|
+
const lower = archive.toLowerCase();
|
|
247
|
+
if (lower.endsWith('.zip')) {
|
|
248
|
+
const zip = new AdmZip(archive);
|
|
249
|
+
const entries = zip.getEntries();
|
|
250
|
+
if (entries.length > 10_000)
|
|
251
|
+
throw new Error('Archive has too many entries');
|
|
252
|
+
let uncompressed = 0;
|
|
253
|
+
for (const entry of entries) {
|
|
254
|
+
uncompressed += Number(entry.header.size || 0);
|
|
255
|
+
if (uncompressed > 4 * 1024 * 1024 * 1024)
|
|
256
|
+
throw new Error('Archive expands beyond the 4GB safety limit');
|
|
257
|
+
if (entry.isDirectory) {
|
|
258
|
+
await mkdir(safeJoin(destination, entry.entryName), { recursive: true });
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const target = safeJoin(destination, entry.entryName);
|
|
262
|
+
await mkdir(join(target, '..'), { recursive: true });
|
|
263
|
+
const data = entry.getData();
|
|
264
|
+
await writeFile(target, data);
|
|
265
|
+
if (/[\\/](llama-server|server)(\.exe)?$/i.test(target)) {
|
|
266
|
+
await chmod(target, 0o755).catch(() => undefined);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) {
|
|
272
|
+
// Linux and macOS builds ship library link chains (libllama.so -> libllama.so.0 -> libllama.so.0.9.0).
|
|
273
|
+
// tar refuses to create a link through another link, so links are skipped here and copied as files below.
|
|
274
|
+
const links = new Map();
|
|
275
|
+
let entries = 0;
|
|
276
|
+
let expanded = 0;
|
|
277
|
+
await tar.x({
|
|
278
|
+
file: archive,
|
|
279
|
+
cwd: destination,
|
|
280
|
+
strict: true,
|
|
281
|
+
filter: (path, entry) => {
|
|
282
|
+
safeJoin(destination, path);
|
|
283
|
+
entries++;
|
|
284
|
+
expanded += Number(entry.size || 0);
|
|
285
|
+
if (entries > 10_000 || !Number.isSafeInteger(expanded) || expanded > 4 * 1024 ** 3)
|
|
286
|
+
throw new Error('Archive exceeds entry or 4 GiB expansion limit');
|
|
287
|
+
const type = 'type' in entry ? entry.type : undefined;
|
|
288
|
+
if (type === 'SymbolicLink' || type === 'Link') {
|
|
289
|
+
links.set(path.replace(/\/+$/, ''), { linkpath: String(entry.linkpath ?? ''), hard: type === 'Link' });
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
return true;
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
await materializeLinks(destination, links);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
throw new Error(`Unsupported archive type: ${basename(archive)}`);
|
|
299
|
+
}
|
|
300
|
+
/** Copies the file each archive link points to, following link chains, and never outside the destination. */
|
|
301
|
+
async function materializeLinks(destination, links) {
|
|
302
|
+
let expanded = await directoryBytes(destination);
|
|
303
|
+
const resolveLink = (path, depth = 0) => {
|
|
304
|
+
const link = links.get(path);
|
|
305
|
+
if (!link)
|
|
306
|
+
return path;
|
|
307
|
+
if (depth > 16)
|
|
308
|
+
throw new Error(`Archive link loop at ${path}`);
|
|
309
|
+
const target = link.hard
|
|
310
|
+
? normalize(link.linkpath)
|
|
311
|
+
: normalize(join(dirname(path), link.linkpath));
|
|
312
|
+
safeJoin(destination, target);
|
|
313
|
+
return resolveLink(target.split(sep).join('/'), depth + 1);
|
|
314
|
+
};
|
|
315
|
+
for (const path of links.keys()) {
|
|
316
|
+
const source = safeJoin(destination, resolveLink(path));
|
|
317
|
+
const info = await stat(source).catch(() => null);
|
|
318
|
+
if (!info?.isFile())
|
|
319
|
+
continue;
|
|
320
|
+
expanded += info.size;
|
|
321
|
+
if (expanded > 4 * 1024 ** 3)
|
|
322
|
+
throw new Error('Archive links exceed the 4 GiB expansion limit');
|
|
323
|
+
const target = safeJoin(destination, path);
|
|
324
|
+
await mkdir(dirname(target), { recursive: true });
|
|
325
|
+
await copyFile(source, target);
|
|
326
|
+
await chmod(target, info.mode & 0o777).catch(() => undefined);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async function locateServerBinary(dir) {
|
|
330
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
331
|
+
for (const entry of entries) {
|
|
332
|
+
const full = join(dir, entry.name);
|
|
333
|
+
if (entry.isFile() && /^(llama-server|server)(\.exe)?$/i.test(entry.name))
|
|
334
|
+
return full;
|
|
335
|
+
}
|
|
336
|
+
for (const entry of entries) {
|
|
337
|
+
if (!entry.isDirectory())
|
|
338
|
+
continue;
|
|
339
|
+
const found = await locateServerBinary(join(dir, entry.name));
|
|
340
|
+
if (found)
|
|
341
|
+
return found;
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
function manifestPath(env) {
|
|
346
|
+
return join(binDir(env), 'manifest.json');
|
|
347
|
+
}
|
|
348
|
+
async function readManifest(env) {
|
|
349
|
+
try {
|
|
350
|
+
const data = JSON.parse(await readFile(manifestPath(env), 'utf8'));
|
|
351
|
+
return { installs: Array.isArray(data.installs) ? data.installs : [], latest: data.latest };
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return { installs: [] };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async function writeManifest(env, manifest) {
|
|
358
|
+
await mkdir(binDir(env), { recursive: true });
|
|
359
|
+
await atomicWrite(manifestPath(env), JSON.stringify(manifest, null, 2));
|
|
360
|
+
}
|
|
361
|
+
export async function findExistingLlama(options = {}) {
|
|
362
|
+
const env = options.env ?? process.env;
|
|
363
|
+
const explicit = env.LLAMA_SERVER;
|
|
364
|
+
if (explicit && await pathExists(explicit)) {
|
|
365
|
+
return { path: explicit, version: 'env', variant: 'cpu', sha256: null, verified: false, source: 'env' };
|
|
366
|
+
}
|
|
367
|
+
const manifest = await readManifest(env);
|
|
368
|
+
if (manifest.latest) {
|
|
369
|
+
const install = manifest.installs.find((i) => i.path === manifest.latest);
|
|
370
|
+
if (install && await pathExists(install.path)) {
|
|
371
|
+
if (!install.binarySha256 || await sha256File(install.path) !== install.binarySha256)
|
|
372
|
+
return null;
|
|
373
|
+
return { ...install, source: 'manifest' };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const fromPath = await which('llama-server');
|
|
377
|
+
if (fromPath)
|
|
378
|
+
return { path: fromPath, version: 'path', variant: 'cpu', sha256: null, verified: false, source: 'path' };
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
export async function ensureLlamaServer(options = {}) {
|
|
382
|
+
if (options.simulation)
|
|
383
|
+
return { found: false, reason: 'Simulation mode: llama-server is not needed.' };
|
|
384
|
+
const existing = await findExistingLlama(options);
|
|
385
|
+
if (existing) {
|
|
386
|
+
if (!options.requireChecksum || existing.verified)
|
|
387
|
+
return { found: true, install: existing };
|
|
388
|
+
if (!options.fix || existing.source === 'env' || existing.source === 'path') {
|
|
389
|
+
return { found: false, reason: 'The existing llama-server has no verified release checksum; --require-checksum refuses it. Remove custom LLAMA_SERVER/PATH overrides and run sublera doctor --fix --require-checksum.' };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (!options.fix)
|
|
393
|
+
return { found: false, reason: 'llama-server is not installed. Run: sublera doctor --fix' };
|
|
394
|
+
try {
|
|
395
|
+
const install = await installLlamaServer(options);
|
|
396
|
+
return { found: true, install };
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
return { found: false, reason: error instanceof Error ? error.message : String(error) };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const pendingInstalls = new Map();
|
|
403
|
+
// The caller owns the cross-process instance lock (the running agent already holds it).
|
|
404
|
+
// Queue the entire read/modify/write transaction, not just the manifest write.
|
|
405
|
+
export async function installLlamaServer(options = {}) {
|
|
406
|
+
const env = { ...(options.env ?? process.env) };
|
|
407
|
+
const home = binDir(env);
|
|
408
|
+
const key = process.platform === 'win32' ? home.toLowerCase() : home;
|
|
409
|
+
const previous = pendingInstalls.get(key);
|
|
410
|
+
const pending = (previous ?? Promise.resolve()).catch(() => undefined)
|
|
411
|
+
.then(() => installLlamaServerUnlocked({ ...options, env }));
|
|
412
|
+
pendingInstalls.set(key, pending);
|
|
413
|
+
try {
|
|
414
|
+
return await pending;
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
if (pendingInstalls.get(key) === pending)
|
|
418
|
+
pendingInstalls.delete(key);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async function installLlamaServerUnlocked(options) {
|
|
422
|
+
const env = options.env ?? process.env;
|
|
423
|
+
const platform = options.platform ?? process.platform;
|
|
424
|
+
const arch = options.arch ?? process.arch;
|
|
425
|
+
const vendor = options.gpu?.primary?.vendor;
|
|
426
|
+
const variant = variantFor(platform, arch, vendor);
|
|
427
|
+
const policy = storagePolicy(env);
|
|
428
|
+
const release = await fetchRelease(options, platform, arch, variant);
|
|
429
|
+
const picked = pickReleaseAsset(release, platform, arch, variant);
|
|
430
|
+
if (!picked.main) {
|
|
431
|
+
throw new Error(`No llama.cpp release asset found for ${platform}/${arch} (${variant}) in ${release.tag_name}`);
|
|
432
|
+
}
|
|
433
|
+
const version = (options.version || release.tag_name || 'latest').replace(/[^\w.-]+/g, '-');
|
|
434
|
+
const installDir = join(binDir(env), `llama-${version}-${variant}`);
|
|
435
|
+
const existingBinary = await locateServerBinary(installDir).catch(() => null);
|
|
436
|
+
if (existingBinary) {
|
|
437
|
+
const manifest = await readManifest(env);
|
|
438
|
+
const known = manifest.installs.find((i) => i.path === existingBinary);
|
|
439
|
+
if (known?.binarySha256 && (!options.requireChecksum || known.verified) && await sha256File(existingBinary) === known.binarySha256) {
|
|
440
|
+
manifest.latest = existingBinary;
|
|
441
|
+
await writeManifest(env, manifest);
|
|
442
|
+
return { ...known, source: 'manifest' };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const id = randomUUID();
|
|
446
|
+
const work = join(cacheDir(env), `install-${id}`);
|
|
447
|
+
const staging = `${installDir}.${id}.tmp`;
|
|
448
|
+
const backup = `${installDir}.${id}.bak`;
|
|
449
|
+
let backedUp = false;
|
|
450
|
+
let promoted = false;
|
|
451
|
+
const archiveDir = join(work, 'archives');
|
|
452
|
+
const extractDir = join(work, 'extract');
|
|
453
|
+
await mkdir(binDir(env), { recursive: true });
|
|
454
|
+
if (await directoryBytes(binDir(env)) >= MAX_ENGINE_CACHE_BYTES)
|
|
455
|
+
throw new Error('Engine cache reached 10 GiB; remove unused engine versions while the agent is stopped');
|
|
456
|
+
// Archives, extracted libraries and staging coexist during installation.
|
|
457
|
+
await requireDiskSpace(binDir(env), 8 * 1024 ** 3, policy.reserveBytes);
|
|
458
|
+
await mkdir(archiveDir, { recursive: true });
|
|
459
|
+
await mkdir(extractDir, { recursive: true });
|
|
460
|
+
try {
|
|
461
|
+
const assets = [picked.main, ...picked.companions];
|
|
462
|
+
if (assets.length > 4)
|
|
463
|
+
throw new Error('Too many companion engine archives');
|
|
464
|
+
let mainSha = null;
|
|
465
|
+
let anyVerified = true;
|
|
466
|
+
for (const [index, asset] of assets.entries()) {
|
|
467
|
+
const archive = join(archiveDir, asset.name);
|
|
468
|
+
await downloadFile(asset.browser_download_url, archive, {
|
|
469
|
+
...options,
|
|
470
|
+
label: `Downloading ${asset.name}`,
|
|
471
|
+
});
|
|
472
|
+
const actual = await sha256File(archive);
|
|
473
|
+
const check = await checksumFor(asset, release, options, index === 0);
|
|
474
|
+
if (check.expected && check.expected !== actual) {
|
|
475
|
+
throw new Error(`Checksum mismatch for ${asset.name}. Expected ${check.expected}, got ${actual}.`);
|
|
476
|
+
}
|
|
477
|
+
if (!check.published) {
|
|
478
|
+
if (options.requireChecksum) {
|
|
479
|
+
throw new Error(`No published SHA-256 for ${asset.name}; refusing because --require-checksum is set.`);
|
|
480
|
+
}
|
|
481
|
+
anyVerified = false;
|
|
482
|
+
options.logger?.warn(`No published SHA-256 for ${asset.name}; recorded the local hash.`);
|
|
483
|
+
}
|
|
484
|
+
if (index === 0)
|
|
485
|
+
mainSha = actual;
|
|
486
|
+
await requireDiskSpace(extractDir, 4 * 1024 ** 3, policy.reserveBytes);
|
|
487
|
+
await extractArchive(archive, extractDir);
|
|
488
|
+
if (await directoryBytes(extractDir) > 4 * 1024 ** 3)
|
|
489
|
+
throw new Error('Combined engine extraction exceeds 4 GiB');
|
|
490
|
+
}
|
|
491
|
+
const binary = await locateServerBinary(extractDir);
|
|
492
|
+
if (!binary)
|
|
493
|
+
throw new Error('No llama-server binary found inside the release archive');
|
|
494
|
+
const extractedBytes = await directoryBytes(extractDir);
|
|
495
|
+
if (await directoryBytes(binDir(env)) + extractedBytes > MAX_ENGINE_CACHE_BYTES)
|
|
496
|
+
throw new Error('Engine cache would exceed 10 GiB; remove unused versions while the agent is stopped');
|
|
497
|
+
await requireDiskSpace(binDir(env), extractedBytes, policy.reserveBytes);
|
|
498
|
+
await rm(staging, { recursive: true, force: true });
|
|
499
|
+
await mkdir(staging, { recursive: true });
|
|
500
|
+
const entries = await readdir(extractDir, { withFileTypes: true });
|
|
501
|
+
for (const entry of entries) {
|
|
502
|
+
const source = join(extractDir, entry.name);
|
|
503
|
+
const target = join(staging, entry.name);
|
|
504
|
+
if (entry.isDirectory()) {
|
|
505
|
+
await copyDir(source, target);
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
await copyFile(source, target);
|
|
509
|
+
if (/^(llama-server|server)(\.exe)?$/i.test(entry.name))
|
|
510
|
+
await chmod(target, 0o755).catch(() => undefined);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// Validate and hash the copied candidate before disturbing the previous install.
|
|
514
|
+
const stagedBinary = await locateServerBinary(staging);
|
|
515
|
+
if (!stagedBinary)
|
|
516
|
+
throw new Error('llama-server was extracted but could not be found in staging');
|
|
517
|
+
await chmod(stagedBinary, 0o755).catch(() => undefined);
|
|
518
|
+
const binarySha256 = await sha256File(stagedBinary);
|
|
519
|
+
const installedBinary = join(installDir, relative(staging, stagedBinary));
|
|
520
|
+
if (await pathExists(installDir)) {
|
|
521
|
+
await rename(installDir, backup);
|
|
522
|
+
backedUp = true;
|
|
523
|
+
}
|
|
524
|
+
await rename(staging, installDir);
|
|
525
|
+
promoted = true;
|
|
526
|
+
const install = {
|
|
527
|
+
path: installedBinary,
|
|
528
|
+
version,
|
|
529
|
+
variant,
|
|
530
|
+
sha256: mainSha,
|
|
531
|
+
binarySha256,
|
|
532
|
+
verified: anyVerified,
|
|
533
|
+
source: 'download',
|
|
534
|
+
dir: installDir,
|
|
535
|
+
};
|
|
536
|
+
const manifest = await readManifest(env);
|
|
537
|
+
manifest.installs = manifest.installs.filter((i) => i.path !== installedBinary);
|
|
538
|
+
manifest.installs.push(install);
|
|
539
|
+
manifest.latest = installedBinary;
|
|
540
|
+
await writeManifest(env, manifest);
|
|
541
|
+
// A failed cleanup must not roll back an already committed manifest.
|
|
542
|
+
if (backedUp)
|
|
543
|
+
await rm(backup, { recursive: true, force: true }).catch(() => undefined);
|
|
544
|
+
return install;
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
547
|
+
// Keep the backup if rollback itself fails, so it remains recoverable.
|
|
548
|
+
if (promoted)
|
|
549
|
+
await rm(installDir, { recursive: true, force: true });
|
|
550
|
+
if (backedUp)
|
|
551
|
+
await rename(backup, installDir);
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
finally {
|
|
555
|
+
await rm(work, { recursive: true, force: true }).catch(() => undefined);
|
|
556
|
+
await rm(staging, { recursive: true, force: true }).catch(() => undefined);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
async function copyDir(source, target) {
|
|
560
|
+
await mkdir(target, { recursive: true });
|
|
561
|
+
for (const entry of await readdir(source, { withFileTypes: true })) {
|
|
562
|
+
const from = join(source, entry.name);
|
|
563
|
+
const to = join(target, entry.name);
|
|
564
|
+
if (entry.isDirectory())
|
|
565
|
+
await copyDir(from, to);
|
|
566
|
+
else
|
|
567
|
+
await copyFile(from, to);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
export async function binaryStatus(options = {}) {
|
|
571
|
+
return { install: await findExistingLlama(options), manifestPath: manifestPath(options.env ?? process.env) };
|
|
572
|
+
}
|
|
573
|
+
export async function verifyInstalledBinary(install) {
|
|
574
|
+
if (!install.binarySha256)
|
|
575
|
+
return false;
|
|
576
|
+
const actual = await sha256File(install.path);
|
|
577
|
+
return actual === install.binarySha256;
|
|
578
|
+
}
|
|
579
|
+
export function isHttpError(error) {
|
|
580
|
+
return Boolean(error && typeof error === 'object' && 'status' in error);
|
|
581
|
+
}
|
|
582
|
+
export async function readReleaseForTest() {
|
|
583
|
+
const response = await fetchJson(RELEASE_URL, { headers: { accept: 'application/vnd.github+json' } });
|
|
584
|
+
return response;
|
|
585
|
+
}
|
|
586
|
+
void rename;
|
|
587
|
+
void stat;
|
|
588
|
+
void ensureDirUnused;
|
|
589
|
+
function ensureDirUnused() { }
|
|
590
|
+
//# sourceMappingURL=binaryManager.js.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export type GgufInfo = {
|
|
2
|
+
version: number;
|
|
3
|
+
architecture: string | null;
|
|
4
|
+
name: string | null;
|
|
5
|
+
layers: number | null;
|
|
6
|
+
embeddingLength: number | null;
|
|
7
|
+
fileType: number | null;
|
|
8
|
+
quantLabel: string | null;
|
|
9
|
+
headCount: number | null;
|
|
10
|
+
headCountKv: number | null;
|
|
11
|
+
feedForward: number | null;
|
|
12
|
+
contextTrain: number | null;
|
|
13
|
+
vocabSize: number | null;
|
|
14
|
+
paramB: number | null;
|
|
15
|
+
keysRead: number;
|
|
16
|
+
complete: boolean;
|
|
17
|
+
};
|
|
18
|
+
export declare function quantBytesPerParam(quant: string): number;
|
|
19
|
+
export declare function guessLayers(paramB: number): number;
|
|
20
|
+
export declare function estimateVramMb(opts: {
|
|
21
|
+
paramB: number;
|
|
22
|
+
quant: string;
|
|
23
|
+
context: number;
|
|
24
|
+
layers?: number;
|
|
25
|
+
slots?: number;
|
|
26
|
+
}): number;
|
|
27
|
+
export declare function paramBFromName(name: string): number | null;
|
|
28
|
+
export declare function quantFromName(name: string): string | null;
|
|
29
|
+
export declare function layerMismatch(claimedParamB: number | null, layers: number | null): string | null;
|
|
30
|
+
export declare function rejectModelFile(file: string): string | null;
|
|
31
|
+
export declare function parseGgufHeader(buf: Buffer): GgufInfo;
|
|
32
|
+
export declare function readGgufHeader(path: string, maxBytes?: number): Promise<GgufInfo>;
|
|
33
|
+
export type HfParsedUrl = {
|
|
34
|
+
repo: string;
|
|
35
|
+
file: string;
|
|
36
|
+
rev: string;
|
|
37
|
+
downloadUrl: string;
|
|
38
|
+
};
|
|
39
|
+
export declare function parseHfUrl(raw: string): HfParsedUrl | null;
|
|
40
|
+
export declare const MAX_MODEL_BYTES: number;
|
|
41
|
+
export type ModelUrlOptions = {
|
|
42
|
+
/** Unit tests only: allow http(s) downloads from a loopback address. */
|
|
43
|
+
allowLocalhost?: boolean;
|
|
44
|
+
};
|
|
45
|
+
/** Validates a model link and returns the canonical HuggingFace resolve URL to download. */
|
|
46
|
+
export declare function checkModelUrl(raw: string, options?: ModelUrlOptions): string;
|
|
47
|
+
/** fetch() that follows redirects itself, so every hop must also be https on HuggingFace. */
|
|
48
|
+
export declare function fetchModelUrl(url: string, init: Omit<RequestInit, 'redirect'>, options?: ModelUrlOptions & {
|
|
49
|
+
fetchImpl?: typeof fetch;
|
|
50
|
+
}): Promise<Response>;
|
|
51
|
+
export type HfFileInfo = {
|
|
52
|
+
sha256: string | null;
|
|
53
|
+
size: number | null;
|
|
54
|
+
siblings: string[];
|
|
55
|
+
};
|
|
56
|
+
export declare function fetchHfFileInfo(repo: string, file: string, timeoutMs?: number, rev?: string, fetchImpl?: typeof fetch): Promise<HfFileInfo>;
|
|
57
|
+
export type InspectedModel = {
|
|
58
|
+
repo: string;
|
|
59
|
+
file: string;
|
|
60
|
+
url: string;
|
|
61
|
+
sha256: string | null;
|
|
62
|
+
size: number | null;
|
|
63
|
+
info: GgufInfo | null;
|
|
64
|
+
paramB: number | null;
|
|
65
|
+
quant: string | null;
|
|
66
|
+
layers: number | null;
|
|
67
|
+
contextTrain: number | null;
|
|
68
|
+
needsMb: number | null;
|
|
69
|
+
headerChecked: boolean;
|
|
70
|
+
note: string;
|
|
71
|
+
};
|
|
72
|
+
export declare function inspectHuggingFace(rawUrl: string, timeoutMs?: number): Promise<InspectedModel>;
|
|
73
|
+
export declare class BufferWriter {
|
|
74
|
+
private parts;
|
|
75
|
+
u8(n: number): void;
|
|
76
|
+
u16(n: number): void;
|
|
77
|
+
u32(n: number): void;
|
|
78
|
+
i32(n: number): void;
|
|
79
|
+
u64(n: number): void;
|
|
80
|
+
str(s: string): void;
|
|
81
|
+
raw(b: Buffer): void;
|
|
82
|
+
buf(): Buffer<ArrayBuffer>;
|
|
83
|
+
}
|
|
84
|
+
export declare function buildGgufSample(pairs: {
|
|
85
|
+
key: string;
|
|
86
|
+
type: number;
|
|
87
|
+
write: (b: BufferWriter) => void;
|
|
88
|
+
}[]): Buffer;
|