@otto-code/brain 0.8.0 → 0.8.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/dist/commands/runtime.d.ts +1 -0
- package/dist/commands/runtime.js +32 -3
- package/dist/runtime/args.d.ts +16 -3
- package/dist/runtime/args.js +19 -13
- package/dist/runtime/index.d.ts +9 -3
- package/dist/runtime/index.js +21 -3
- package/dist/runtime/lmstudio.d.ts +2 -2
- package/dist/runtime/lmstudio.js +115 -28
- package/dist/runtime/managed.d.ts +72 -8
- package/dist/runtime/managed.js +404 -38
- package/package.json +1 -1
|
@@ -17,5 +17,6 @@ export declare function runRuntimeListCommand(_options: unknown, _command: Comma
|
|
|
17
17
|
export declare function addRuntimeInstallOptions(cmd: Command): Command;
|
|
18
18
|
export declare function runRuntimeInstallCommand(options: {
|
|
19
19
|
build?: string;
|
|
20
|
+
variant?: string;
|
|
20
21
|
}, _command: Command): Promise<AnyCommandResult<RuntimeRow>>;
|
|
21
22
|
//# sourceMappingURL=runtime.d.ts.map
|
package/dist/commands/runtime.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { loadBrainConfig } from "../config/index.js";
|
|
2
2
|
import { resolveBrainPaths } from "../config/paths.js";
|
|
3
|
-
import {
|
|
3
|
+
import { CommandError } from "../output/types.js";
|
|
4
|
+
import { defaultRuntimeSpec, installManagedRuntime, listAllRuntimes, listRuntimeDevices, probeNvidiaGpu, supportedVariants, } from "../runtime/index.js";
|
|
4
5
|
const runtimeSchema = {
|
|
5
6
|
idField: "dir",
|
|
6
7
|
columns: [
|
|
@@ -32,12 +33,26 @@ export async function runRuntimeListCommand(_options, _command) {
|
|
|
32
33
|
export function addRuntimeInstallOptions(cmd) {
|
|
33
34
|
return cmd
|
|
34
35
|
.description("Download a self-contained llama.cpp runtime")
|
|
35
|
-
.option("--build <tag>", "llama.cpp release build tag")
|
|
36
|
+
.option("--build <tag>", "llama.cpp release build tag")
|
|
37
|
+
.option("--variant <name>", `accelerator to install (${supportedVariants().join("|")}); defaults to the best one this machine can use`);
|
|
36
38
|
}
|
|
37
39
|
export async function runRuntimeInstallCommand(options, _command) {
|
|
38
40
|
loadBrainConfig();
|
|
39
41
|
const { runtimesDir } = resolveBrainPaths();
|
|
40
|
-
const
|
|
42
|
+
const available = supportedVariants();
|
|
43
|
+
if (options.variant && !available.includes(options.variant)) {
|
|
44
|
+
throw new CommandError({
|
|
45
|
+
code: "UNSUPPORTED_VARIANT",
|
|
46
|
+
message: `llama.cpp publishes no "${options.variant}" build for ${process.platform}/${process.arch}` +
|
|
47
|
+
` - available: ${available.join(", ") || "(none)"}`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// Only "auto" needs the GPU probe, and only to choose between CUDA and Vulkan
|
|
51
|
+
// on the platforms that have both.
|
|
52
|
+
const spec = defaultRuntimeSpec(options.build, {
|
|
53
|
+
variant: options.variant ?? "auto",
|
|
54
|
+
hasNvidiaGpu: options.variant ? undefined : await probeNvidiaGpu(),
|
|
55
|
+
});
|
|
41
56
|
process.stderr.write(` installing ${spec.label} (${spec.version})…\n`);
|
|
42
57
|
const runtime = await installManagedRuntime(spec, runtimesDir, (p) => {
|
|
43
58
|
if (p.phase === "downloading" && p.totalBytes) {
|
|
@@ -48,6 +63,20 @@ export async function runRuntimeInstallCommand(options, _command) {
|
|
|
48
63
|
process.stderr.write("\n extracting…\n");
|
|
49
64
|
});
|
|
50
65
|
process.stderr.write("\n");
|
|
66
|
+
// A GPU variant that finds no device still runs - on the CPU, at roughly a
|
|
67
|
+
// fortieth of the prefill throughput, and says nothing about it. Measured on
|
|
68
|
+
// WSL2, which carries no NVIDIA Vulkan ICD. Warn rather than fail: the runtime
|
|
69
|
+
// is genuinely installed and usable, just not accelerated.
|
|
70
|
+
// One line on purpose. The daemon's BrainOpsManager keeps the *last* stderr line
|
|
71
|
+
// as the job's message, so a multi-line warning would surface in the UI as a
|
|
72
|
+
// dangling fragment. As one line it survives intact on both the CLI and the GUI.
|
|
73
|
+
if (spec.variant !== "cpu") {
|
|
74
|
+
const devices = await listRuntimeDevices(runtime);
|
|
75
|
+
if (devices.length === 0) {
|
|
76
|
+
process.stderr.write(` warning: this ${spec.variant} runtime reports no GPU device, so inference will run` +
|
|
77
|
+
` on the CPU until a working ${spec.variant} driver for this GPU is installed.\n`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
51
80
|
return {
|
|
52
81
|
type: "single",
|
|
53
82
|
data: {
|
package/dist/runtime/args.d.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translates a profile into llama-server arguments and builds the PATH the child
|
|
3
|
+
* needs. Runtime-source agnostic: works the same for an LM Studio runtime or a
|
|
4
|
+
* managed one, since both resolve to a `Runtime` (exe + optional vendorDir).
|
|
5
|
+
*/
|
|
1
6
|
import type { Profile } from "../config/schema.js";
|
|
2
7
|
import type { Runtime } from "../types.js";
|
|
3
8
|
export interface ServeTarget {
|
|
@@ -5,10 +10,18 @@ export interface ServeTarget {
|
|
|
5
10
|
host?: string;
|
|
6
11
|
}
|
|
7
12
|
/**
|
|
8
|
-
*
|
|
9
|
-
* runtime dir and its vendor dir go first, ahead of the
|
|
13
|
+
* Loader environment the child process needs so it can resolve its shared
|
|
14
|
+
* libraries. Both the runtime dir and its vendor dir go first, ahead of the
|
|
15
|
+
* inherited values.
|
|
16
|
+
*
|
|
17
|
+
* PATH is the Windows half of this (the DLL-stub trap). The other two platforms
|
|
18
|
+
* do not read PATH for libraries at all: a llama.cpp tarball puts
|
|
19
|
+
* `libggml*.so`/`libllama.so` (or the `.dylib` equivalents) next to the binary,
|
|
20
|
+
* so Linux needs LD_LIBRARY_PATH and macOS needs DYLD_LIBRARY_PATH or the
|
|
21
|
+
* binary dies at load with an unresolved-library error before it prints a line.
|
|
22
|
+
* PATH is still set everywhere - harmless, and it keeps the shape uniform.
|
|
10
23
|
*/
|
|
11
|
-
export declare function buildEnv(runtime: Runtime, baseEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
24
|
+
export declare function buildEnv(runtime: Runtime, baseEnv?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): NodeJS.ProcessEnv;
|
|
12
25
|
/**
|
|
13
26
|
* Translate a profile into llama-server arguments.
|
|
14
27
|
*
|
package/dist/runtime/args.js
CHANGED
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
*
|
|
9
|
-
*
|
|
2
|
+
* Loader environment the child process needs so it can resolve its shared
|
|
3
|
+
* libraries. Both the runtime dir and its vendor dir go first, ahead of the
|
|
4
|
+
* inherited values.
|
|
5
|
+
*
|
|
6
|
+
* PATH is the Windows half of this (the DLL-stub trap). The other two platforms
|
|
7
|
+
* do not read PATH for libraries at all: a llama.cpp tarball puts
|
|
8
|
+
* `libggml*.so`/`libllama.so` (or the `.dylib` equivalents) next to the binary,
|
|
9
|
+
* so Linux needs LD_LIBRARY_PATH and macOS needs DYLD_LIBRARY_PATH or the
|
|
10
|
+
* binary dies at load with an unresolved-library error before it prints a line.
|
|
11
|
+
* PATH is still set everywhere - harmless, and it keeps the shape uniform.
|
|
10
12
|
*/
|
|
11
|
-
export function buildEnv(runtime, baseEnv = process.env) {
|
|
13
|
+
export function buildEnv(runtime, baseEnv = process.env, platform = process.platform) {
|
|
12
14
|
const parts = [runtime.dir];
|
|
13
15
|
if (runtime.vendorDir)
|
|
14
16
|
parts.push(runtime.vendorDir);
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const delimiter = platform === "win32" ? ";" : ":";
|
|
18
|
+
const prepend = (existing) => `${parts.join(delimiter)}${delimiter}${existing || ""}`;
|
|
19
|
+
const env = { ...baseEnv, PATH: prepend(baseEnv.PATH) };
|
|
20
|
+
if (platform === "darwin")
|
|
21
|
+
env.DYLD_LIBRARY_PATH = prepend(baseEnv.DYLD_LIBRARY_PATH);
|
|
22
|
+
else if (platform !== "win32")
|
|
23
|
+
env.LD_LIBRARY_PATH = prepend(baseEnv.LD_LIBRARY_PATH);
|
|
24
|
+
return env;
|
|
19
25
|
}
|
|
20
26
|
/**
|
|
21
27
|
* Translate a profile into llama-server arguments.
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import type { BrainConfig } from "../config/schema.js";
|
|
2
2
|
import type { Runtime } from "../types.js";
|
|
3
|
-
import { type InstallProgress } from "./managed.js";
|
|
3
|
+
import { type InstallProgress, type RuntimeTarget } from "./managed.js";
|
|
4
4
|
export { BACKENDS_DIR, LMSTUDIO_ROOT, listRuntimes as listLmStudioRuntimes } from "./lmstudio.js";
|
|
5
5
|
export { buildArgs, buildEnv, formatCommand, type ServeTarget } from "./args.js";
|
|
6
|
-
export { installManagedRuntime, listManagedRuntimes, defaultRuntimeSpec, DEFAULT_LLAMA_BUILD, type RuntimeSpec, type InstallProgress, } from "./managed.js";
|
|
6
|
+
export { installManagedRuntime, listManagedRuntimes, listRuntimeDevices, verifyRuntimeExecutable, defaultRuntimeSpec, extractArchive, resolveRuntimeVariant, serverExeName, supportedVariants, DEFAULT_LLAMA_BUILD, type RuntimeSpec, type RuntimeTarget, type RuntimeVariant, type InstallProgress, } from "./managed.js";
|
|
7
7
|
/** Every runtime available on this machine, managed first then LM Studio. */
|
|
8
8
|
export declare function listAllRuntimes(env?: NodeJS.ProcessEnv): Runtime[];
|
|
9
|
+
/**
|
|
10
|
+
* Whether this machine has an NVIDIA GPU, for picking a managed build. Returns
|
|
11
|
+
* false rather than throwing when nvidia-smi is absent, which is the normal
|
|
12
|
+
* case on macOS and on AMD/Intel machines.
|
|
13
|
+
*/
|
|
14
|
+
export declare function probeNvidiaGpu(): Promise<boolean>;
|
|
9
15
|
/** The runtime to use given config, or null when none is available. */
|
|
10
16
|
export declare function resolveRuntime(config: BrainConfig, env?: NodeJS.ProcessEnv): Runtime | null;
|
|
11
17
|
/** Ensure a runtime exists, downloading the default managed build if none does. */
|
|
12
|
-
export declare function ensureRuntime(config: BrainConfig, env?: NodeJS.ProcessEnv, onProgress?: (progress: InstallProgress) => void): Promise<Runtime>;
|
|
18
|
+
export declare function ensureRuntime(config: BrainConfig, env?: NodeJS.ProcessEnv, onProgress?: (progress: InstallProgress) => void, target?: RuntimeTarget): Promise<Runtime>;
|
|
13
19
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/runtime/index.js
CHANGED
|
@@ -4,18 +4,32 @@
|
|
|
4
4
|
* path) and `lmstudio` (discovered from an existing LM Studio install, a
|
|
5
5
|
* zero-download fast path). Selection follows config: an explicit path override
|
|
6
6
|
* wins; otherwise `auto` prefers a managed runtime and falls back to LM Studio.
|
|
7
|
+
*
|
|
8
|
+
* Both providers are cross-platform. Which accelerator a managed install picks
|
|
9
|
+
* is decided in `managed.resolveRuntimeVariant` from the platform, the arch and
|
|
10
|
+
* whether an NVIDIA GPU answered - `probeNvidiaGpu` below is the one place that
|
|
11
|
+
* asks, so the download layer stays free of process spawning.
|
|
7
12
|
*/
|
|
13
|
+
import { query as queryGpu } from "../gpu.js";
|
|
8
14
|
import { resolveBrainPaths } from "../config/paths.js";
|
|
9
15
|
import { listRuntimes as listLmStudioRuntimes, resolveOverride } from "./lmstudio.js";
|
|
10
16
|
import { defaultRuntimeSpec, installManagedRuntime, listManagedRuntimes, } from "./managed.js";
|
|
11
17
|
export { BACKENDS_DIR, LMSTUDIO_ROOT, listRuntimes as listLmStudioRuntimes } from "./lmstudio.js";
|
|
12
18
|
export { buildArgs, buildEnv, formatCommand } from "./args.js";
|
|
13
|
-
export { installManagedRuntime, listManagedRuntimes, defaultRuntimeSpec, DEFAULT_LLAMA_BUILD, } from "./managed.js";
|
|
19
|
+
export { installManagedRuntime, listManagedRuntimes, listRuntimeDevices, verifyRuntimeExecutable, defaultRuntimeSpec, extractArchive, resolveRuntimeVariant, serverExeName, supportedVariants, DEFAULT_LLAMA_BUILD, } from "./managed.js";
|
|
14
20
|
/** Every runtime available on this machine, managed first then LM Studio. */
|
|
15
21
|
export function listAllRuntimes(env = process.env) {
|
|
16
22
|
const paths = resolveBrainPaths(env);
|
|
17
23
|
return [...listManagedRuntimes(paths.runtimesDir), ...listLmStudioRuntimes()];
|
|
18
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Whether this machine has an NVIDIA GPU, for picking a managed build. Returns
|
|
27
|
+
* false rather than throwing when nvidia-smi is absent, which is the normal
|
|
28
|
+
* case on macOS and on AMD/Intel machines.
|
|
29
|
+
*/
|
|
30
|
+
export async function probeNvidiaGpu() {
|
|
31
|
+
return (await queryGpu()) !== null;
|
|
32
|
+
}
|
|
19
33
|
/** The runtime to use given config, or null when none is available. */
|
|
20
34
|
export function resolveRuntime(config, env = process.env) {
|
|
21
35
|
const rc = config.runtime;
|
|
@@ -31,11 +45,15 @@ export function resolveRuntime(config, env = process.env) {
|
|
|
31
45
|
return managed[0] ?? lmstudio[0] ?? null; // auto
|
|
32
46
|
}
|
|
33
47
|
/** Ensure a runtime exists, downloading the default managed build if none does. */
|
|
34
|
-
export async function ensureRuntime(config, env = process.env, onProgress) {
|
|
48
|
+
export async function ensureRuntime(config, env = process.env, onProgress, target = {}) {
|
|
35
49
|
const existing = resolveRuntime(config, env);
|
|
36
50
|
if (existing)
|
|
37
51
|
return existing;
|
|
38
52
|
const paths = resolveBrainPaths(env);
|
|
39
|
-
|
|
53
|
+
const resolved = {
|
|
54
|
+
...target,
|
|
55
|
+
hasNvidiaGpu: target.hasNvidiaGpu ?? (await probeNvidiaGpu()),
|
|
56
|
+
};
|
|
57
|
+
return installManagedRuntime(defaultRuntimeSpec(null, resolved), paths.runtimesDir, onProgress);
|
|
40
58
|
}
|
|
41
59
|
//# sourceMappingURL=index.js.map
|
|
@@ -4,7 +4,7 @@ export declare const BACKENDS_DIR: string;
|
|
|
4
4
|
export declare function extractVersion(dirName: string): string;
|
|
5
5
|
/** Compare dotted version strings numerically, descending. */
|
|
6
6
|
export declare function compareVersionsDesc(a: string, b: string): number;
|
|
7
|
-
export declare function listRuntimes(backendsDir?: string): Runtime[];
|
|
7
|
+
export declare function listRuntimes(backendsDir?: string, platform?: NodeJS.Platform): Runtime[];
|
|
8
8
|
/** Resolve an explicit runtime directory or exe override into a Runtime. */
|
|
9
|
-
export declare function resolveOverride(override: string): Runtime;
|
|
9
|
+
export declare function resolveOverride(override: string, platform?: NodeJS.Platform): Runtime;
|
|
10
10
|
//# sourceMappingURL=lmstudio.d.ts.map
|
package/dist/runtime/lmstudio.js
CHANGED
|
@@ -1,33 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Locates the llama-server binary shipped inside LM Studio
|
|
2
|
+
* Locates the llama-server binary shipped inside LM Studio, on all three
|
|
3
|
+
* desktop platforms. LM Studio keeps its backends under `~/.lmstudio` on
|
|
4
|
+
* Windows, macOS and Linux alike, so only the directory naming and the binary
|
|
5
|
+
* name differ per platform.
|
|
3
6
|
*
|
|
4
|
-
* Important gotcha: that executable is a ~20KB stub. Launching it
|
|
5
|
-
* matching `backends/vendor/...` directory on PATH fails with
|
|
7
|
+
* Important gotcha (Windows): that executable is a ~20KB stub. Launching it
|
|
8
|
+
* without the matching `backends/vendor/...` directory on PATH fails with
|
|
6
9
|
* STATUS_DLL_NOT_FOUND (0xC0000135) and prints absolutely nothing, so we always
|
|
7
|
-
* pair a runtime with its vendor directory
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
+
* pair a runtime with its vendor directory, and a Windows runtime whose vendor
|
|
11
|
+
* dir is missing is skipped rather than offered. macOS and Linux builds are
|
|
12
|
+
* self-contained and fail loudly through the dynamic loader when they are not,
|
|
13
|
+
* which is why the name-shape fallback below is enabled only there.
|
|
14
|
+
*
|
|
15
|
+
* This is one of two runtime sources (the other being the self-contained
|
|
16
|
+
* `managed` runtime); it stays as a zero-download fast path when LM Studio is
|
|
17
|
+
* already installed.
|
|
10
18
|
*/
|
|
11
19
|
import fs from "node:fs";
|
|
12
20
|
import os from "node:os";
|
|
13
21
|
import path from "node:path";
|
|
22
|
+
import { serverExeName } from "./managed.js";
|
|
14
23
|
export const LMSTUDIO_ROOT = path.join(os.homedir(), ".lmstudio");
|
|
15
24
|
export const BACKENDS_DIR = path.join(LMSTUDIO_ROOT, "extensions", "backends");
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Most preferred first, per platform. Each entry maps a runtime directory
|
|
27
|
+
* prefix to its vendor dir.
|
|
28
|
+
*
|
|
29
|
+
* Prefix (not substring) matching is deliberate: a CUDA directory is named
|
|
30
|
+
* `llama.cpp-win-x86_64-nvidia-cuda12-avx2-<version>`, so a substring rule for
|
|
31
|
+
* the AVX2 entry would match it a second time and offer the same runtime twice
|
|
32
|
+
* under the wrong label.
|
|
33
|
+
*
|
|
34
|
+
* The Windows rows are verified against a real install. The macOS and Linux
|
|
35
|
+
* rows follow LM Studio's documented naming but have not been read off a live
|
|
36
|
+
* install here - the shape fallback in `listRuntimes` is what makes discovery
|
|
37
|
+
* correct on those platforms regardless.
|
|
38
|
+
*/
|
|
39
|
+
const PREFERENCES = {
|
|
40
|
+
win32: [
|
|
41
|
+
{
|
|
42
|
+
prefix: "llama.cpp-win-x86_64-nvidia-cuda12-",
|
|
43
|
+
vendor: "win-llama-cuda12-vendor-v2",
|
|
44
|
+
label: "CUDA 12",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
prefix: "llama.cpp-win-x86_64-nvidia-cuda-",
|
|
48
|
+
vendor: "win-llama-cuda-vendor-v2",
|
|
49
|
+
label: "CUDA 11",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
prefix: "llama.cpp-win-x86_64-vulkan-",
|
|
53
|
+
vendor: "win-llama-vulkan-vendor-v2",
|
|
54
|
+
label: "Vulkan",
|
|
55
|
+
},
|
|
56
|
+
{ prefix: "llama.cpp-win-x86_64-avx2-", vendor: null, label: "CPU (AVX2)" },
|
|
57
|
+
],
|
|
58
|
+
darwin: [
|
|
59
|
+
{ prefix: "llama.cpp-mac-arm64-apple-metal-", vendor: null, label: "Metal" },
|
|
60
|
+
{ prefix: "llama.cpp-mac-arm64-", vendor: null, label: "CPU (arm64)" },
|
|
61
|
+
{ prefix: "llama.cpp-mac-x86_64-", vendor: null, label: "CPU (x86_64)" },
|
|
62
|
+
],
|
|
63
|
+
linux: [
|
|
64
|
+
{ prefix: "llama.cpp-linux-x86_64-nvidia-cuda12-", vendor: null, label: "CUDA 12" },
|
|
65
|
+
{ prefix: "llama.cpp-linux-x86_64-nvidia-cuda-", vendor: null, label: "CUDA 11" },
|
|
66
|
+
{ prefix: "llama.cpp-linux-x86_64-vulkan-", vendor: null, label: "Vulkan" },
|
|
67
|
+
{ prefix: "llama.cpp-linux-x86_64-avx2-", vendor: null, label: "CPU (AVX2)" },
|
|
68
|
+
{ prefix: "llama.cpp-linux-aarch64-", vendor: null, label: "CPU (aarch64)" },
|
|
69
|
+
],
|
|
70
|
+
};
|
|
31
71
|
// Directory names carry an instruction-set segment before the version
|
|
32
72
|
// (…-cuda12-avx2-2.24.0), so take the trailing dotted number, not the remainder.
|
|
33
73
|
const VERSION_SUFFIX = /(\d+(?:\.\d+)+)$/;
|
|
@@ -46,40 +86,87 @@ export function compareVersionsDesc(a, b) {
|
|
|
46
86
|
}
|
|
47
87
|
return 0;
|
|
48
88
|
}
|
|
49
|
-
|
|
89
|
+
/** A readable label for a directory discovered outside the preference table. */
|
|
90
|
+
function labelFromDirName(dirName) {
|
|
91
|
+
const lower = dirName.toLowerCase();
|
|
92
|
+
if (lower.includes("cuda12"))
|
|
93
|
+
return "CUDA 12";
|
|
94
|
+
if (lower.includes("cuda"))
|
|
95
|
+
return "CUDA";
|
|
96
|
+
if (lower.includes("metal"))
|
|
97
|
+
return "Metal";
|
|
98
|
+
if (lower.includes("vulkan"))
|
|
99
|
+
return "Vulkan";
|
|
100
|
+
if (lower.includes("rocm") || lower.includes("hip"))
|
|
101
|
+
return "ROCm";
|
|
102
|
+
if (lower.includes("avx2"))
|
|
103
|
+
return "CPU (AVX2)";
|
|
104
|
+
return "LM Studio runtime";
|
|
105
|
+
}
|
|
106
|
+
export function listRuntimes(backendsDir = BACKENDS_DIR, platform = process.platform) {
|
|
50
107
|
if (!fs.existsSync(backendsDir))
|
|
51
108
|
return [];
|
|
109
|
+
const exeName = serverExeName(platform);
|
|
52
110
|
const entries = fs
|
|
53
111
|
.readdirSync(backendsDir, { withFileTypes: true })
|
|
54
112
|
.filter((e) => e.isDirectory())
|
|
55
113
|
.map((e) => e.name);
|
|
56
114
|
const found = [];
|
|
57
|
-
|
|
115
|
+
const claimed = new Set();
|
|
116
|
+
for (const pref of PREFERENCES[platform] ?? []) {
|
|
58
117
|
const matches = entries
|
|
59
|
-
.filter((name) => name.startsWith(pref.prefix))
|
|
118
|
+
.filter((name) => name.startsWith(pref.prefix) && !claimed.has(name))
|
|
60
119
|
.map((name) => ({ name, version: extractVersion(name) }))
|
|
61
|
-
.filter(({ name }) => fs.existsSync(path.join(backendsDir, name,
|
|
120
|
+
.filter(({ name }) => fs.existsSync(path.join(backendsDir, name, exeName)))
|
|
62
121
|
.sort((a, b) => compareVersionsDesc(a.version, b.version));
|
|
63
122
|
for (const match of matches) {
|
|
64
123
|
const vendorDir = pref.vendor ? path.join(backendsDir, "vendor", pref.vendor) : null;
|
|
65
124
|
// A runtime whose vendor DLLs are missing cannot launch; skip it.
|
|
66
125
|
if (vendorDir && !fs.existsSync(vendorDir))
|
|
67
126
|
continue;
|
|
127
|
+
claimed.add(match.name);
|
|
68
128
|
found.push({
|
|
69
129
|
label: pref.label,
|
|
70
130
|
version: match.version,
|
|
71
131
|
dir: path.join(backendsDir, match.name),
|
|
72
|
-
exe: path.join(backendsDir, match.name,
|
|
132
|
+
exe: path.join(backendsDir, match.name, exeName),
|
|
73
133
|
vendorDir,
|
|
74
134
|
source: "lmstudio",
|
|
75
135
|
});
|
|
76
136
|
}
|
|
77
137
|
}
|
|
138
|
+
// Shape fallback: anything that holds a llama-server binary is usable on the
|
|
139
|
+
// self-contained platforms, whatever LM Studio decided to call the directory.
|
|
140
|
+
// Not applied on Windows, where an unpaired stub launches and dies silently.
|
|
141
|
+
if (platform !== "win32") {
|
|
142
|
+
const extra = entries
|
|
143
|
+
.filter((name) => !claimed.has(name) && name !== "vendor")
|
|
144
|
+
.filter((name) => fs.existsSync(path.join(backendsDir, name, exeName)))
|
|
145
|
+
.map((name) => ({ name, version: extractVersion(name) }))
|
|
146
|
+
.sort((a, b) => compareVersionsDesc(a.version, b.version));
|
|
147
|
+
for (const match of extra) {
|
|
148
|
+
found.push({
|
|
149
|
+
label: labelFromDirName(match.name),
|
|
150
|
+
version: match.version,
|
|
151
|
+
dir: path.join(backendsDir, match.name),
|
|
152
|
+
exe: path.join(backendsDir, match.name, exeName),
|
|
153
|
+
vendorDir: null,
|
|
154
|
+
source: "lmstudio",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
78
158
|
return found;
|
|
79
159
|
}
|
|
80
160
|
/** Resolve an explicit runtime directory or exe override into a Runtime. */
|
|
81
|
-
export function resolveOverride(override) {
|
|
82
|
-
const
|
|
161
|
+
export function resolveOverride(override, platform = process.platform) {
|
|
162
|
+
const exeName = serverExeName(platform);
|
|
163
|
+
// An override may name the binary directly or the directory holding it. On
|
|
164
|
+
// non-Windows the binary has no extension, so "is it a file?" is the only
|
|
165
|
+
// reliable test - a bare `.../llama-server` path looks exactly like a dir.
|
|
166
|
+
const looksLikeExe = path.basename(override).toLowerCase() === exeName.toLowerCase() ||
|
|
167
|
+
(platform === "win32" && override.toLowerCase().endsWith(".exe")) ||
|
|
168
|
+
(fs.existsSync(override) && fs.statSync(override).isFile());
|
|
169
|
+
const exe = looksLikeExe ? override : path.join(override, exeName);
|
|
83
170
|
if (!fs.existsSync(exe))
|
|
84
171
|
throw new Error(`llama-server not found at ${exe}`);
|
|
85
172
|
const dir = path.dirname(exe);
|
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import type { Runtime } from "../types.js";
|
|
2
|
+
/** The accelerator a managed runtime is built against. */
|
|
3
|
+
export type RuntimeVariant = "cuda" | "metal" | "vulkan" | "cpu";
|
|
2
4
|
export interface RuntimeSpec {
|
|
3
5
|
label: string;
|
|
4
6
|
version: string;
|
|
5
7
|
/** One or more archive URLs, extracted in order into the same target dir. */
|
|
6
8
|
assets: string[];
|
|
9
|
+
/** Platform the assets are built for; drives exe name and extraction. */
|
|
10
|
+
platform: NodeJS.Platform;
|
|
11
|
+
variant: RuntimeVariant;
|
|
12
|
+
}
|
|
13
|
+
/** What to install for. Any field left out is read from the current process. */
|
|
14
|
+
export interface RuntimeTarget {
|
|
15
|
+
platform?: NodeJS.Platform;
|
|
16
|
+
arch?: string;
|
|
17
|
+
/** Explicit accelerator, or "auto" to pick from platform + GPU presence. */
|
|
18
|
+
variant?: RuntimeVariant | "auto";
|
|
19
|
+
/** Whether an NVIDIA GPU was detected; only consulted by "auto". */
|
|
20
|
+
hasNvidiaGpu?: boolean;
|
|
7
21
|
}
|
|
8
22
|
export interface InstallProgress {
|
|
9
23
|
phase: "downloading" | "extracting" | "done";
|
|
@@ -12,15 +26,65 @@ export interface InstallProgress {
|
|
|
12
26
|
totalBytes?: number;
|
|
13
27
|
}
|
|
14
28
|
/**
|
|
15
|
-
* The default runtime build.
|
|
16
|
-
*
|
|
17
|
-
|
|
29
|
+
* The default runtime build. Bumping this requires re-reading the tag's asset
|
|
30
|
+
* names (see the module header) - the naming scheme is not stable across tags.
|
|
31
|
+
*/
|
|
32
|
+
export declare const DEFAULT_LLAMA_BUILD = "b10265";
|
|
33
|
+
/** The binary name llama.cpp ships for a platform. */
|
|
34
|
+
export declare function serverExeName(platform?: NodeJS.Platform): string;
|
|
35
|
+
/**
|
|
36
|
+
* Pick the accelerator for a target. Deliberately conservative: it never
|
|
37
|
+
* chooses an accelerator whose asset does not exist for that platform/arch.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveRuntimeVariant(target?: RuntimeTarget): RuntimeVariant;
|
|
40
|
+
/**
|
|
41
|
+
* Build the runtime spec for a target. Throws when upstream publishes nothing
|
|
42
|
+
* for the combination, naming what it tried so the message is actionable.
|
|
43
|
+
*/
|
|
44
|
+
export declare function defaultRuntimeSpec(build?: string | null | undefined, target?: RuntimeTarget): RuntimeSpec;
|
|
45
|
+
/** Variants with a published asset for a platform/arch, best first. */
|
|
46
|
+
export declare function supportedVariants(platform?: NodeJS.Platform, arch?: string): RuntimeVariant[];
|
|
47
|
+
/**
|
|
48
|
+
* A managed runtime is any dir under runtimesDir that contains llama-server,
|
|
49
|
+
* best accelerator first and newest build first within an accelerator.
|
|
50
|
+
*/
|
|
51
|
+
export declare function listManagedRuntimes(runtimesDir: string, platform?: NodeJS.Platform): Runtime[];
|
|
52
|
+
/**
|
|
53
|
+
* The actionable message for a dynamic-loader failure, or null when the output
|
|
54
|
+
* is not one. Pure, so the classification is testable without spawning.
|
|
55
|
+
*
|
|
56
|
+
* Returning null for an unrecognised failure is deliberate: a future llama.cpp
|
|
57
|
+
* that exits non-zero from `--version` must not turn a perfectly usable install
|
|
58
|
+
* into a hard failure. Only a positively identified missing library throws.
|
|
59
|
+
*/
|
|
60
|
+
export declare function missingLibraryFrom(stderr: string): string | null;
|
|
61
|
+
export declare function describeLoaderFailure(stderr: string, runtimeDir: string, platform?: NodeJS.Platform): string | null;
|
|
62
|
+
/**
|
|
63
|
+
* Run the freshly installed binary once, so a missing *system* library surfaces
|
|
64
|
+
* at install time naming the library, instead of hours later as an opaque
|
|
65
|
+
* supervisor crash.
|
|
66
|
+
*
|
|
67
|
+
* This is not hypothetical: no upstream Linux asset ships `libgomp.so.1`, which
|
|
68
|
+
* `llama-server` hard-links, so on a host without `libgomp1` the download and
|
|
69
|
+
* extract both succeed and the binary then dies with exit 127. `buildEnv` cannot
|
|
70
|
+
* fix that - the library is not in the runtime dir LD_LIBRARY_PATH points at.
|
|
71
|
+
*/
|
|
72
|
+
export declare function verifyRuntimeExecutable(runtime: Runtime, platform?: NodeJS.Platform, arch?: string): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Device lines out of `--list-devices` stdout, which is a header followed by one
|
|
75
|
+
* indented line per device, or the literal `(none)`. Pure half of
|
|
76
|
+
* `listRuntimeDevices`.
|
|
77
|
+
*/
|
|
78
|
+
export declare function parseDeviceList(stdout: string): string[];
|
|
79
|
+
/**
|
|
80
|
+
* Devices the runtime's backends actually found, one line each. Empty means the
|
|
81
|
+
* accelerator resolved to nothing and inference would silently fall back to CPU,
|
|
82
|
+
* which is a real configuration: on WSL2 there is no NVIDIA Vulkan ICD, so a
|
|
83
|
+
* Vulkan runtime on an NVIDIA machine reports no device and runs ~41x slower at
|
|
84
|
+
* prefill without saying so. Never throws; a probe failure reads as "unknown".
|
|
18
85
|
*/
|
|
19
|
-
export declare
|
|
20
|
-
|
|
21
|
-
export declare function defaultRuntimeSpec(build?: string): RuntimeSpec;
|
|
22
|
-
/** A managed runtime is any dir under runtimesDir that contains llama-server.exe. */
|
|
23
|
-
export declare function listManagedRuntimes(runtimesDir: string): Runtime[];
|
|
86
|
+
export declare function listRuntimeDevices(runtime: Runtime, platform?: NodeJS.Platform): Promise<string[]>;
|
|
87
|
+
export declare function extractArchive(archivePath: string, destDir: string, platform?: NodeJS.Platform): Promise<void>;
|
|
24
88
|
/** Download + extract a runtime spec into runtimesDir and return the Runtime. */
|
|
25
89
|
export declare function installManagedRuntime(spec: RuntimeSpec, runtimesDir: string, onProgress?: (progress: InstallProgress) => void): Promise<Runtime>;
|
|
26
90
|
//# sourceMappingURL=managed.d.ts.map
|
package/dist/runtime/managed.js
CHANGED
|
@@ -1,12 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The self-contained runtime source: otto-brain downloads a pinned llama.cpp
|
|
3
3
|
* build into `$OTTO_HOME/otto-brain/runtimes/` and runs it directly, so the tool
|
|
4
|
-
* needs no other software installed. Downloaded runtimes keep their
|
|
5
|
-
* same directory as the
|
|
6
|
-
*
|
|
4
|
+
* needs no other software installed. Downloaded runtimes keep their shared
|
|
5
|
+
* libraries in the same directory as the binary (so `vendorDir` is null - the
|
|
6
|
+
* loader path built by `args.buildEnv` already covers that directory, which is
|
|
7
|
+
* what the DLL-stub trap requires).
|
|
7
8
|
*
|
|
8
|
-
* Extraction uses only OS built-ins - PowerShell's Expand-Archive for .zip
|
|
9
|
-
* the bundled `tar` for tarballs - to keep the
|
|
9
|
+
* Extraction uses only OS built-ins - PowerShell's Expand-Archive for .zip on
|
|
10
|
+
* Windows, the bundled `tar` for tarballs everywhere else - to keep the
|
|
11
|
+
* "nothing else to install" promise.
|
|
12
|
+
*
|
|
13
|
+
* ## The upstream asset matrix (verified against release b10265)
|
|
14
|
+
*
|
|
15
|
+
* Every fact below was read off the release, not inferred from the naming
|
|
16
|
+
* scheme, because the scheme has changed at least once: tag b4600 shipped
|
|
17
|
+
* `llama-b4600-bin-win-cuda-cu12.4-x64.zip` while b10265 ships
|
|
18
|
+
* `llama-b10265-bin-win-cuda-12.4-x64.zip` (no `cu`). A pin bump must re-verify
|
|
19
|
+
* the names against that tag's asset list; they are not stable across tags.
|
|
20
|
+
*
|
|
21
|
+
* - Windows assets are `.zip`; macOS and Linux assets are `.tar.gz`.
|
|
22
|
+
* - **Linux has no CUDA *release asset*, and we deliberately do not source one
|
|
23
|
+
* elsewhere.** The Linux GPU assets are Vulkan, ROCm and SYCL only, so the
|
|
24
|
+
* Linux GPU default is Vulkan - the one accelerator covering NVIDIA, AMD and
|
|
25
|
+
* Intel from a single asset. There is a real Linux CUDA build upstream, in
|
|
26
|
+
* the `ghcr.io/ggml-org/llama.cpp:server-cuda-b<n>` container images (484 of
|
|
27
|
+
* them, and 376 build numbers carry both an image and a release, so a pinned
|
|
28
|
+
* version-aligned import is genuinely possible). It was extracted, run and
|
|
29
|
+
* benchmarked on 2026-08-04 and then rejected on the numbers:
|
|
30
|
+
*
|
|
31
|
+
* CUDA beats Vulkan by 1.00x-1.04x on an RTX 5090 at every prefill depth
|
|
32
|
+
* from 512 to 8192 and at token generation, inside the run-to-run error at
|
|
33
|
+
* three of five points, because NVIDIA's Vulkan driver exposes
|
|
34
|
+
* NV_coopmat2 and llama.cpp's Vulkan backend uses those tensor cores.
|
|
35
|
+
*
|
|
36
|
+
* Paying for that would mean a 40x download (1.3 GB against 32 MB) and a 14x
|
|
37
|
+
* on-disk footprint, assembled from three origins, because `libggml-cuda.so`
|
|
38
|
+
* also needs `libcublas`, `libcudart` and `libnccl.so.2` - and NCCL ships in
|
|
39
|
+
* neither NVIDIA redistributable. Do not reopen this without a measurement on
|
|
40
|
+
* hardware that does *not* report NV_coopmat2, which is the one case where
|
|
41
|
+
* the gap could still be real. Full evidence:
|
|
42
|
+
* findings/linux-gpu-acceleration/2026-08-04-cuda-vs-vulkan-and-cuda-asset-origins.md
|
|
43
|
+
* - **No Linux asset ships `libgomp.so.1`**, which `llama-server` hard-links,
|
|
44
|
+
* so a host without `libgomp1` installs a runtime that then exits 127 on
|
|
45
|
+
* spawn. Windows bundles its OpenMP runtime (`libomp140.x86_64.dll`); Linux
|
|
46
|
+
* bundles nothing. `buildEnv` cannot paper over it - the library is not in
|
|
47
|
+
* the runtime dir that `LD_LIBRARY_PATH` already points at.
|
|
48
|
+
* - Windows CUDA needs a *second* archive (`cudart-llama-bin-win-cuda-*.zip`)
|
|
49
|
+
* extracted over the first, and that asset's name carries no build tag.
|
|
50
|
+
* - macOS arm64 is Metal-accelerated in the stock `macos-arm64` asset; there is
|
|
51
|
+
* no separate Metal asset to select. macOS x64 is CPU-only in practice.
|
|
10
52
|
*/
|
|
11
53
|
import { spawn } from "node:child_process";
|
|
12
54
|
import fs from "node:fs";
|
|
@@ -14,24 +56,125 @@ import { createWriteStream } from "node:fs";
|
|
|
14
56
|
import path from "node:path";
|
|
15
57
|
import { Readable } from "node:stream";
|
|
16
58
|
import { pipeline } from "node:stream/promises";
|
|
59
|
+
import { buildEnv } from "./args.js";
|
|
17
60
|
/**
|
|
18
|
-
* The default runtime build.
|
|
19
|
-
*
|
|
20
|
-
* shipping. Overridable via config (`runtime.path`) or an explicit spec.
|
|
61
|
+
* The default runtime build. Bumping this requires re-reading the tag's asset
|
|
62
|
+
* names (see the module header) - the naming scheme is not stable across tags.
|
|
21
63
|
*/
|
|
22
|
-
export const DEFAULT_LLAMA_BUILD = "
|
|
64
|
+
export const DEFAULT_LLAMA_BUILD = "b10265";
|
|
23
65
|
const LLAMA_RELEASE_BASE = "https://github.com/ggml-org/llama.cpp/releases/download";
|
|
24
|
-
/**
|
|
25
|
-
|
|
66
|
+
/** The CUDA toolkit version whose Windows assets we pin. */
|
|
67
|
+
const WINDOWS_CUDA = "12.4";
|
|
68
|
+
/** The binary name llama.cpp ships for a platform. */
|
|
69
|
+
export function serverExeName(platform = process.platform) {
|
|
70
|
+
return platform === "win32" ? "llama-server.exe" : "llama-server";
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Pick the accelerator for a target. Deliberately conservative: it never
|
|
74
|
+
* chooses an accelerator whose asset does not exist for that platform/arch.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveRuntimeVariant(target = {}) {
|
|
77
|
+
const platform = target.platform ?? process.platform;
|
|
78
|
+
const arch = target.arch ?? process.arch;
|
|
79
|
+
const requested = target.variant ?? "auto";
|
|
80
|
+
if (requested !== "auto")
|
|
81
|
+
return requested;
|
|
82
|
+
if (platform === "darwin")
|
|
83
|
+
return arch === "arm64" ? "metal" : "cpu";
|
|
84
|
+
// Windows is the only platform with an upstream CUDA build. Everywhere else a
|
|
85
|
+
// GPU means Vulkan.
|
|
86
|
+
if (platform === "win32") {
|
|
87
|
+
if (arch === "arm64")
|
|
88
|
+
return "cpu";
|
|
89
|
+
return target.hasNvidiaGpu ? "cuda" : "vulkan";
|
|
90
|
+
}
|
|
91
|
+
if (platform === "linux")
|
|
92
|
+
return target.hasNvidiaGpu === false ? "cpu" : "vulkan";
|
|
93
|
+
return "cpu";
|
|
94
|
+
}
|
|
95
|
+
const VARIANT_LABELS = {
|
|
96
|
+
cuda: `CUDA ${WINDOWS_CUDA}`,
|
|
97
|
+
metal: "Metal",
|
|
98
|
+
vulkan: "Vulkan",
|
|
99
|
+
cpu: "CPU",
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* The archive names for a platform/arch/variant, or null when upstream ships no
|
|
103
|
+
* such asset. Returning null (rather than guessing a URL) is what keeps a bad
|
|
104
|
+
* combination from failing later as an opaque 404 mid-download.
|
|
105
|
+
*/
|
|
106
|
+
function assetNames(build, platform, arch, variant) {
|
|
107
|
+
const bin = (suffix, ext) => `llama-${build}-bin-${suffix}.${ext}`;
|
|
108
|
+
if (platform === "win32") {
|
|
109
|
+
if (arch === "arm64")
|
|
110
|
+
return variant === "cpu" ? [bin("win-cpu-arm64", "zip")] : null;
|
|
111
|
+
if (arch !== "x64")
|
|
112
|
+
return null;
|
|
113
|
+
if (variant === "cuda") {
|
|
114
|
+
return [
|
|
115
|
+
bin(`win-cuda-${WINDOWS_CUDA}-x64`, "zip"),
|
|
116
|
+
// The CUDA runtime DLLs ride in a companion archive whose name carries
|
|
117
|
+
// no build tag; it is published under each release tag all the same.
|
|
118
|
+
`cudart-llama-bin-win-cuda-${WINDOWS_CUDA}-x64.zip`,
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
if (variant === "vulkan")
|
|
122
|
+
return [bin("win-vulkan-x64", "zip")];
|
|
123
|
+
if (variant === "cpu")
|
|
124
|
+
return [bin("win-cpu-x64", "zip")];
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
if (platform === "darwin") {
|
|
128
|
+
if (arch === "arm64") {
|
|
129
|
+
// One asset serves both: the stock macos-arm64 build is Metal-enabled.
|
|
130
|
+
return variant === "metal" || variant === "cpu" ? [bin("macos-arm64", "tar.gz")] : null;
|
|
131
|
+
}
|
|
132
|
+
if (arch === "x64")
|
|
133
|
+
return variant === "cpu" ? [bin("macos-x64", "tar.gz")] : null;
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
if (platform === "linux") {
|
|
137
|
+
const slice = arch === "arm64" ? "arm64" : arch === "x64" ? "x64" : null;
|
|
138
|
+
if (!slice)
|
|
139
|
+
return null;
|
|
140
|
+
if (variant === "vulkan")
|
|
141
|
+
return [bin(`ubuntu-vulkan-${slice}`, "tar.gz")];
|
|
142
|
+
if (variant === "cpu")
|
|
143
|
+
return [bin(`ubuntu-${slice}`, "tar.gz")];
|
|
144
|
+
// No Linux CUDA/Metal *release* asset. A CUDA container image exists and was
|
|
145
|
+
// measured at parity with Vulkan; see the header before reopening this.
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Build the runtime spec for a target. Throws when upstream publishes nothing
|
|
152
|
+
* for the combination, naming what it tried so the message is actionable.
|
|
153
|
+
*/
|
|
154
|
+
export function defaultRuntimeSpec(build = DEFAULT_LLAMA_BUILD, target = {}) {
|
|
155
|
+
const tag = build || DEFAULT_LLAMA_BUILD;
|
|
156
|
+
const platform = target.platform ?? process.platform;
|
|
157
|
+
const arch = target.arch ?? process.arch;
|
|
158
|
+
const variant = resolveRuntimeVariant({ ...target, platform, arch });
|
|
159
|
+
const names = assetNames(tag, platform, arch, variant);
|
|
160
|
+
if (!names) {
|
|
161
|
+
throw new Error(`llama.cpp publishes no ${variant} build for ${platform}/${arch} - ` +
|
|
162
|
+
`pass --variant with one of ${supportedVariants(platform, arch).join(", ") || "(none)"}, ` +
|
|
163
|
+
`or point runtime.path at a runtime you built yourself`);
|
|
164
|
+
}
|
|
26
165
|
return {
|
|
27
|
-
label:
|
|
28
|
-
version:
|
|
29
|
-
assets:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
],
|
|
166
|
+
label: `${VARIANT_LABELS[variant]} (managed)`,
|
|
167
|
+
version: tag,
|
|
168
|
+
assets: names.map((name) => `${LLAMA_RELEASE_BASE}/${tag}/${name}`),
|
|
169
|
+
platform,
|
|
170
|
+
variant,
|
|
33
171
|
};
|
|
34
172
|
}
|
|
173
|
+
/** Variants with a published asset for a platform/arch, best first. */
|
|
174
|
+
export function supportedVariants(platform = process.platform, arch = process.arch) {
|
|
175
|
+
const order = ["cuda", "metal", "vulkan", "cpu"];
|
|
176
|
+
return order.filter((v) => assetNames(DEFAULT_LLAMA_BUILD, platform, arch, v) !== null);
|
|
177
|
+
}
|
|
35
178
|
function slug(spec) {
|
|
36
179
|
return `${spec.label}-${spec.version}`
|
|
37
180
|
.toLowerCase()
|
|
@@ -54,16 +197,37 @@ function findFile(dir, name) {
|
|
|
54
197
|
}
|
|
55
198
|
return null;
|
|
56
199
|
}
|
|
57
|
-
/**
|
|
58
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Rank a managed runtime directory by the accelerator its slug encodes, best
|
|
202
|
+
* first. Once more than one variant can be installed side by side, readdir
|
|
203
|
+
* order is not a defensible preference: a machine that installed CPU for a
|
|
204
|
+
* one-off test would otherwise have it silently outrank its CUDA runtime.
|
|
205
|
+
*/
|
|
206
|
+
function managedVariantRank(dirName) {
|
|
207
|
+
const order = ["cuda", "metal", "vulkan", "cpu"];
|
|
208
|
+
const lower = dirName.toLowerCase();
|
|
209
|
+
const index = order.findIndex((variant) => lower.startsWith(`${variant}-`));
|
|
210
|
+
return index === -1 ? order.length : index;
|
|
211
|
+
}
|
|
212
|
+
/** Numeric part of a `bNNNNN` llama.cpp build tag, for ordering. */
|
|
213
|
+
function buildNumber(version) {
|
|
214
|
+
const match = /(\d+)/.exec(version);
|
|
215
|
+
return match ? Number(match[1]) : 0;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* A managed runtime is any dir under runtimesDir that contains llama-server,
|
|
219
|
+
* best accelerator first and newest build first within an accelerator.
|
|
220
|
+
*/
|
|
221
|
+
export function listManagedRuntimes(runtimesDir, platform = process.platform) {
|
|
59
222
|
if (!fs.existsSync(runtimesDir))
|
|
60
223
|
return [];
|
|
224
|
+
const exeName = serverExeName(platform);
|
|
61
225
|
const found = [];
|
|
62
226
|
for (const entry of fs.readdirSync(runtimesDir, { withFileTypes: true })) {
|
|
63
227
|
if (!entry.isDirectory())
|
|
64
228
|
continue;
|
|
65
229
|
const root = path.join(runtimesDir, entry.name);
|
|
66
|
-
const exe = findFile(root,
|
|
230
|
+
const exe = findFile(root, exeName);
|
|
67
231
|
if (!exe)
|
|
68
232
|
continue;
|
|
69
233
|
found.push({
|
|
@@ -75,7 +239,8 @@ export function listManagedRuntimes(runtimesDir) {
|
|
|
75
239
|
source: "managed",
|
|
76
240
|
});
|
|
77
241
|
}
|
|
78
|
-
return found
|
|
242
|
+
return found.sort((a, b) => managedVariantRank(a.label) - managedVariantRank(b.label) ||
|
|
243
|
+
buildNumber(b.version) - buildNumber(a.version));
|
|
79
244
|
}
|
|
80
245
|
async function downloadFile(url, dest, onProgress) {
|
|
81
246
|
const response = await fetch(url);
|
|
@@ -100,22 +265,212 @@ function run(command, args) {
|
|
|
100
265
|
child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`${command} exited ${code}: ${stderr.trim()}`)));
|
|
101
266
|
});
|
|
102
267
|
}
|
|
103
|
-
|
|
268
|
+
/** Spawn and collect the outcome instead of throwing, so callers can classify it. */
|
|
269
|
+
function runCapture(command, args, env) {
|
|
270
|
+
return new Promise((resolve) => {
|
|
271
|
+
const child = spawn(command, args, { windowsHide: true, env });
|
|
272
|
+
let stdout = "";
|
|
273
|
+
let stderr = "";
|
|
274
|
+
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
275
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
276
|
+
child.on("error", (err) => resolve({ code: null, stdout, stderr: `${stderr}${err}` }));
|
|
277
|
+
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Distro packages providing a library llama.cpp links but ships on no asset.
|
|
282
|
+
* Keyed by soname because that is the string the dynamic loader prints.
|
|
283
|
+
*/
|
|
284
|
+
const SYSTEM_LIBRARY_PACKAGES = {
|
|
285
|
+
"libgomp.so.1": "libgomp1 (Debian/Ubuntu), libgomp (Fedora/RHEL) or gcc-libs (Arch)",
|
|
286
|
+
};
|
|
287
|
+
/**
|
|
288
|
+
* Where to fetch the one library upstream leaves unmet on Linux.
|
|
289
|
+
*
|
|
290
|
+
* Debian's pool rather than Ubuntu's for two measured reasons: bookworm's
|
|
291
|
+
* `libgomp1` carries a `GLIBC_2.34` floor, which is *exactly* llama-server's own
|
|
292
|
+
* floor, so bundling it cannot narrow the set of systems the runtime already ran
|
|
293
|
+
* on; and its payload is `data.tar.xz`, which `tar` reads with the near-universal
|
|
294
|
+
* `xz`, where Ubuntu 24.04+ moved to zstd, which a minimal image does not have.
|
|
295
|
+
*/
|
|
296
|
+
const LIBGOMP_PACKAGE = {
|
|
297
|
+
x64: "https://deb.debian.org/debian/pool/main/g/gcc-12/libgomp1_12.2.0-14+deb12u1_amd64.deb",
|
|
298
|
+
arm64: "https://deb.debian.org/debian/pool/main/g/gcc-12/libgomp1_12.2.0-14+deb12u1_arm64.deb",
|
|
299
|
+
};
|
|
300
|
+
/**
|
|
301
|
+
* Read one member out of a Unix `ar` archive, which is the container format of a
|
|
302
|
+
* `.deb`. Parsed here rather than shelled out to `ar`, which is binutils and not
|
|
303
|
+
* present on a minimal image - the exact kind of host that needs this repair.
|
|
304
|
+
*
|
|
305
|
+
* Layout: an 8-byte magic, then per member a fixed 60-byte ASCII header whose
|
|
306
|
+
* name is bytes 0-15 and size bytes 48-57, followed by the payload padded to an
|
|
307
|
+
* even offset.
|
|
308
|
+
*/
|
|
309
|
+
function readArMember(archive, member) {
|
|
310
|
+
if (archive.subarray(0, 8).toString("ascii") !== "!<arch>\n")
|
|
311
|
+
return null;
|
|
312
|
+
let offset = 8;
|
|
313
|
+
while (offset + 60 <= archive.length) {
|
|
314
|
+
const header = archive.subarray(offset, offset + 60);
|
|
315
|
+
const name = header.subarray(0, 16).toString("ascii").trim().replace(/\/$/, "");
|
|
316
|
+
const size = Number.parseInt(header.subarray(48, 58).toString("ascii").trim(), 10);
|
|
317
|
+
if (!Number.isInteger(size) || size < 0)
|
|
318
|
+
return null;
|
|
319
|
+
const start = offset + 60;
|
|
320
|
+
if (name === member)
|
|
321
|
+
return archive.subarray(start, start + size);
|
|
322
|
+
offset = start + size + (size % 2);
|
|
323
|
+
}
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Try to satisfy a missing system library by placing it beside the binary, where
|
|
328
|
+
* `buildEnv`'s loader path already looks.
|
|
329
|
+
*
|
|
330
|
+
* Only ever runs *after* the runtime has already failed to start, so it cannot
|
|
331
|
+
* regress a host that works: a system with its own `libgomp` never reaches here.
|
|
332
|
+
* Returns false on any failure, which leaves the caller reporting the actionable
|
|
333
|
+
* error it would have reported anyway. Best effort, never fatal in itself.
|
|
334
|
+
*/
|
|
335
|
+
async function repairMissingLibrary(soname, destDir, arch) {
|
|
336
|
+
if (soname !== "libgomp.so.1")
|
|
337
|
+
return false;
|
|
338
|
+
const url = LIBGOMP_PACKAGE[arch === "x64" ? "x64" : arch === "arm64" ? "arm64" : ""];
|
|
339
|
+
if (!url)
|
|
340
|
+
return false;
|
|
341
|
+
const scratch = path.join(destDir, ".libgomp-repair");
|
|
342
|
+
try {
|
|
343
|
+
fs.mkdirSync(scratch, { recursive: true });
|
|
344
|
+
const debPath = path.join(scratch, "lib.deb");
|
|
345
|
+
await downloadFile(url, debPath);
|
|
346
|
+
const payload = readArMember(fs.readFileSync(debPath), "data.tar.xz");
|
|
347
|
+
if (!payload)
|
|
348
|
+
return false;
|
|
349
|
+
const tarPath = path.join(scratch, "data.tar.xz");
|
|
350
|
+
fs.writeFileSync(tarPath, payload);
|
|
351
|
+
await extractArchive(tarPath, scratch, "linux");
|
|
352
|
+
// The package ships `libgomp.so.1` as a symlink to the real `libgomp.so.1.0.0`;
|
|
353
|
+
// copy the target under the soname so no symlink support is needed.
|
|
354
|
+
const real = findFile(scratch, "libgomp.so.1.0.0");
|
|
355
|
+
if (!real)
|
|
356
|
+
return false;
|
|
357
|
+
fs.copyFileSync(real, path.join(destDir, soname));
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* The actionable message for a dynamic-loader failure, or null when the output
|
|
369
|
+
* is not one. Pure, so the classification is testable without spawning.
|
|
370
|
+
*
|
|
371
|
+
* Returning null for an unrecognised failure is deliberate: a future llama.cpp
|
|
372
|
+
* that exits non-zero from `--version` must not turn a perfectly usable install
|
|
373
|
+
* into a hard failure. Only a positively identified missing library throws.
|
|
374
|
+
*/
|
|
375
|
+
export function missingLibraryFrom(stderr) {
|
|
376
|
+
const missing =
|
|
377
|
+
// glibc's loader, and macOS dyld.
|
|
378
|
+
/error while loading shared libraries:\s*([^\s:]+)/.exec(stderr) ??
|
|
379
|
+
/Library not loaded:\s*(\S+)/.exec(stderr);
|
|
380
|
+
return missing ? missing[1] : null;
|
|
381
|
+
}
|
|
382
|
+
export function describeLoaderFailure(stderr, runtimeDir, platform = process.platform) {
|
|
383
|
+
const lib = missingLibraryFrom(stderr);
|
|
384
|
+
if (!lib)
|
|
385
|
+
return null;
|
|
386
|
+
const hint = SYSTEM_LIBRARY_PACKAGES[lib.replace(/^.*[/\\]/, "")];
|
|
387
|
+
return (`the runtime installed but cannot start: ${lib} is missing from this system. ` +
|
|
388
|
+
`llama.cpp links it and ships it on no ${platform} asset, so it has to come from the OS` +
|
|
389
|
+
(hint ? ` - install ${hint}` : "") +
|
|
390
|
+
`. The runtime is at ${runtimeDir}; re-run once the library is present.`);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Run the freshly installed binary once, so a missing *system* library surfaces
|
|
394
|
+
* at install time naming the library, instead of hours later as an opaque
|
|
395
|
+
* supervisor crash.
|
|
396
|
+
*
|
|
397
|
+
* This is not hypothetical: no upstream Linux asset ships `libgomp.so.1`, which
|
|
398
|
+
* `llama-server` hard-links, so on a host without `libgomp1` the download and
|
|
399
|
+
* extract both succeed and the binary then dies with exit 127. `buildEnv` cannot
|
|
400
|
+
* fix that - the library is not in the runtime dir LD_LIBRARY_PATH points at.
|
|
401
|
+
*/
|
|
402
|
+
export async function verifyRuntimeExecutable(runtime, platform = process.platform, arch = process.arch) {
|
|
403
|
+
const env = buildEnv(runtime, process.env, platform);
|
|
404
|
+
const first = await runCapture(runtime.exe, ["--version"], env);
|
|
405
|
+
if (first.code === 0)
|
|
406
|
+
return;
|
|
407
|
+
const problem = describeLoaderFailure(first.stderr, runtime.dir, platform);
|
|
408
|
+
if (!problem)
|
|
409
|
+
return;
|
|
410
|
+
// Repair before giving up. `libgomp.so.1` is missing from every upstream Linux
|
|
411
|
+
// asset, so on a minimal image this is the *expected* first outcome, not an
|
|
412
|
+
// exceptional one, and telling the user to go install a package is a worse
|
|
413
|
+
// answer than placing the library where the loader already looks.
|
|
414
|
+
const soname = missingLibraryFrom(first.stderr);
|
|
415
|
+
if (platform === "linux" && soname && (await repairMissingLibrary(soname, runtime.dir, arch))) {
|
|
416
|
+
const retry = await runCapture(runtime.exe, ["--version"], env);
|
|
417
|
+
if (retry.code === 0)
|
|
418
|
+
return;
|
|
419
|
+
throw new Error(describeLoaderFailure(retry.stderr, runtime.dir, platform) ?? problem);
|
|
420
|
+
}
|
|
421
|
+
throw new Error(problem);
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Device lines out of `--list-devices` stdout, which is a header followed by one
|
|
425
|
+
* indented line per device, or the literal `(none)`. Pure half of
|
|
426
|
+
* `listRuntimeDevices`.
|
|
427
|
+
*/
|
|
428
|
+
export function parseDeviceList(stdout) {
|
|
429
|
+
return stdout
|
|
430
|
+
.split(/\r?\n/)
|
|
431
|
+
.map((line) => line.trim())
|
|
432
|
+
.filter((line) => line && !/^available devices:/i.test(line) && line !== "(none)");
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Devices the runtime's backends actually found, one line each. Empty means the
|
|
436
|
+
* accelerator resolved to nothing and inference would silently fall back to CPU,
|
|
437
|
+
* which is a real configuration: on WSL2 there is no NVIDIA Vulkan ICD, so a
|
|
438
|
+
* Vulkan runtime on an NVIDIA machine reports no device and runs ~41x slower at
|
|
439
|
+
* prefill without saying so. Never throws; a probe failure reads as "unknown".
|
|
440
|
+
*/
|
|
441
|
+
export async function listRuntimeDevices(runtime, platform = process.platform) {
|
|
442
|
+
const env = buildEnv(runtime, process.env, platform);
|
|
443
|
+
const { code, stdout } = await runCapture(runtime.exe, ["--list-devices"], env);
|
|
444
|
+
if (code !== 0)
|
|
445
|
+
return [];
|
|
446
|
+
return parseDeviceList(stdout);
|
|
447
|
+
}
|
|
448
|
+
export async function extractArchive(archivePath, destDir, platform = process.platform) {
|
|
104
449
|
fs.mkdirSync(destDir, { recursive: true });
|
|
105
450
|
if (/\.zip$/i.test(archivePath)) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
"
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
451
|
+
if (platform === "win32") {
|
|
452
|
+
// PowerShell ships with Windows; -Force overwrites an interrupted extract.
|
|
453
|
+
// Paths are rooted at $OTTO_HOME (under the user profile), so a username with
|
|
454
|
+
// an apostrophe would break - or inject into - a raw single-quoted string.
|
|
455
|
+
// Escape single quotes for PowerShell (a literal ' is written as '').
|
|
456
|
+
const psQuote = (value) => `'${value.replace(/'/g, "''")}'`;
|
|
457
|
+
await run("powershell", [
|
|
458
|
+
"-NoProfile",
|
|
459
|
+
"-Command",
|
|
460
|
+
`Expand-Archive -LiteralPath ${psQuote(archivePath)} -DestinationPath ${psQuote(destDir)} -Force`,
|
|
461
|
+
]);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
// macOS and Linux both ship a tar that reads zip (bsdtar on macOS, and on
|
|
465
|
+
// Linux the current assets are tarballs anyway - this branch only matters
|
|
466
|
+
// for a hand-passed zip). `unzip` is not assumed: it is not installed by
|
|
467
|
+
// default on every distro.
|
|
468
|
+
await run("tar", ["-xf", archivePath, "-C", destDir]);
|
|
116
469
|
return;
|
|
117
470
|
}
|
|
118
|
-
|
|
471
|
+
// `.tar.xz` is here for the .deb payload the Linux libgomp repair unpacks; tar
|
|
472
|
+
// picks the decompressor itself, so this stays within the OS built-ins rule.
|
|
473
|
+
if (/\.(tar\.gz|tgz|tar\.xz|tar)$/i.test(archivePath)) {
|
|
119
474
|
await run("tar", ["-xf", archivePath, "-C", destDir]);
|
|
120
475
|
return;
|
|
121
476
|
}
|
|
@@ -123,20 +478,26 @@ async function extractArchive(archivePath, destDir) {
|
|
|
123
478
|
}
|
|
124
479
|
/** Download + extract a runtime spec into runtimesDir and return the Runtime. */
|
|
125
480
|
export async function installManagedRuntime(spec, runtimesDir, onProgress) {
|
|
481
|
+
const platform = spec.platform ?? process.platform;
|
|
126
482
|
const targetDir = path.join(runtimesDir, slug(spec));
|
|
127
483
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
128
484
|
for (const url of spec.assets) {
|
|
129
485
|
const archivePath = path.join(targetDir, path.basename(new URL(url).pathname));
|
|
130
486
|
await downloadFile(url, archivePath, onProgress);
|
|
131
487
|
onProgress?.({ phase: "extracting", asset: url });
|
|
132
|
-
await extractArchive(archivePath, targetDir);
|
|
488
|
+
await extractArchive(archivePath, targetDir, platform);
|
|
133
489
|
fs.rmSync(archivePath, { force: true });
|
|
134
490
|
}
|
|
135
|
-
const
|
|
491
|
+
const exeName = serverExeName(platform);
|
|
492
|
+
const exe = findFile(targetDir, exeName);
|
|
136
493
|
if (!exe)
|
|
137
|
-
throw new Error(`installed runtime has no
|
|
138
|
-
|
|
139
|
-
|
|
494
|
+
throw new Error(`installed runtime has no ${exeName} under ${targetDir}`);
|
|
495
|
+
if (platform !== "win32") {
|
|
496
|
+
// Archive mode bits survive `tar -x`, but not every extractor preserves
|
|
497
|
+
// them; without +x the supervisor's spawn fails with a bare EACCES.
|
|
498
|
+
fs.chmodSync(exe, 0o755);
|
|
499
|
+
}
|
|
500
|
+
const runtime = {
|
|
140
501
|
label: spec.label,
|
|
141
502
|
version: spec.version,
|
|
142
503
|
dir: path.dirname(exe),
|
|
@@ -144,5 +505,10 @@ export async function installManagedRuntime(spec, runtimesDir, onProgress) {
|
|
|
144
505
|
vendorDir: null,
|
|
145
506
|
source: "managed",
|
|
146
507
|
};
|
|
508
|
+
// Before reporting success. An install that cannot exec is not an install, and
|
|
509
|
+
// the loader error names the cause far better than the later spawn failure does.
|
|
510
|
+
await verifyRuntimeExecutable(runtime, platform);
|
|
511
|
+
onProgress?.({ phase: "done" });
|
|
512
|
+
return runtime;
|
|
147
513
|
}
|
|
148
514
|
//# sourceMappingURL=managed.js.map
|
package/package.json
CHANGED