Skip to content

Commit a30bc65

Browse files
committed
feat: add local runtime and model manager
1 parent b56e04b commit a30bc65

6 files changed

Lines changed: 271 additions & 106 deletions

File tree

app/src/local-server-manager.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { buildServerCommand, describeSpawnFailure } from "./local-server-manager";
2+
import { buildRuntimeProbe, buildServerCommand, describeSpawnFailure } from "./local-server-manager";
33

44
describe("buildServerCommand", () => {
55
it("builds an mlx_lm.server invocation with the default python", () => {
@@ -73,3 +73,23 @@ describe("describeSpawnFailure", () => {
7373
expect(describeSpawnFailure("vllm")).toMatch(/pip install vllm/);
7474
});
7575
});
76+
77+
describe("buildRuntimeProbe", () => {
78+
it("recognizes MLX only on Apple Silicon", () => {
79+
expect(buildRuntimeProbe("mlx", {}, "darwin", "arm64").compatible).toBe(true);
80+
expect(buildRuntimeProbe("mlx", {}, "linux", "x64").compatible).toBe(false);
81+
});
82+
83+
it("checks vLLM through WSL on Windows", () => {
84+
const probe = buildRuntimeProbe("vllm", {}, "win32", "x64");
85+
expect(probe.compatible).toBe(true);
86+
expect(probe.command).toBe("wsl.exe");
87+
expect(probe.args).toEqual(["--", "vllm", "--version"]);
88+
});
89+
90+
it("uses the configured ROCm runtime when supplied", () => {
91+
const probe = buildRuntimeProbe("rocm", { rocmServerPath: "/opt/rocm/llama-server" }, "win32", "x64");
92+
expect(probe.compatible).toBe(true);
93+
expect(probe.command).toBe("/opt/rocm/llama-server");
94+
});
95+
});

app/src/local-server-manager.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@ export interface LocalBackendConfig {
2424
vllmCommand?: string;
2525
}
2626

27+
export interface LocalRuntimeStatus {
28+
backend: LocalBackendId;
29+
compatible: boolean;
30+
installed: boolean;
31+
running: boolean;
32+
model?: string;
33+
detail: string;
34+
}
35+
36+
export interface RuntimeProbe {
37+
compatible: boolean;
38+
command: string;
39+
args: string[];
40+
detail: string;
41+
}
42+
2743
interface RunningServer {
2844
process: ChildProcess;
2945
model: string;
@@ -47,6 +63,92 @@ const IDLE_TIMEOUT_MS = Number.isFinite(configuredIdleMinutes)
4763
const servers = new Map<LocalBackendId, RunningServer>();
4864
const serverStarts = new Map<LocalBackendId, { model: string; promise: Promise<string> }>();
4965

66+
export function buildRuntimeProbe(
67+
backend: LocalBackendId,
68+
config: LocalBackendConfig,
69+
platform: NodeJS.Platform = process.platform,
70+
arch: string = process.arch
71+
): RuntimeProbe {
72+
if (backend === "mlx") {
73+
const compatible = platform === "darwin" && arch === "arm64";
74+
return {
75+
compatible,
76+
command: config.mlxPythonPath?.trim() || "python3",
77+
args: ["-c", "import mlx_lm"],
78+
detail: compatible ? "Apple Silicon accelerated runtime" : "Requires an Apple Silicon Mac",
79+
};
80+
}
81+
if (backend === "vllm") {
82+
const compatible = platform === "linux" || platform === "win32";
83+
if (!config.vllmCommand?.trim() && platform === "win32") {
84+
return {
85+
compatible,
86+
command: "wsl.exe",
87+
args: ["--", "vllm", "--version"],
88+
detail: "CUDA or ROCm runtime through WSL",
89+
};
90+
}
91+
return {
92+
compatible,
93+
command: config.vllmCommand?.trim() || "vllm",
94+
args: ["--version"],
95+
detail: compatible ? "High-throughput CUDA or ROCm runtime" : "Requires Linux or Windows with WSL",
96+
};
97+
}
98+
const compatible = platform === "linux" || !!config.rocmServerPath?.trim();
99+
return {
100+
compatible,
101+
command: config.rocmServerPath?.trim() || "llama-server",
102+
args: ["--version"],
103+
detail: compatible ? "AMD GPU runtime for local GGUF models" : "Requires Linux and a ROCm-capable AMD GPU",
104+
};
105+
}
106+
107+
async function commandSucceeds(command: string, args: string[]): Promise<boolean> {
108+
return new Promise((resolve) => {
109+
let settled = false;
110+
let timer: NodeJS.Timeout;
111+
const finish = (value: boolean) => {
112+
if (settled) return;
113+
settled = true;
114+
clearTimeout(timer);
115+
resolve(value);
116+
};
117+
let child: ChildProcess;
118+
try {
119+
child = spawn(command, args, { stdio: "ignore" });
120+
} catch {
121+
resolve(false);
122+
return;
123+
}
124+
timer = setTimeout(() => {
125+
child.kill();
126+
finish(false);
127+
}, 5_000);
128+
timer.unref();
129+
child.once("error", () => finish(false));
130+
child.once("exit", (code) => finish(code === 0));
131+
});
132+
}
133+
134+
export async function getRuntimeStatuses(config: LocalBackendConfig): Promise<LocalRuntimeStatus[]> {
135+
return Promise.all(
136+
(["rocm", "mlx", "vllm"] as const).map(async (backend) => {
137+
const probe = buildRuntimeProbe(backend, config);
138+
const running = servers.get(backend);
139+
const installed = probe.compatible && (running ? !running.exited : await commandSucceeds(probe.command, probe.args));
140+
return {
141+
backend,
142+
compatible: probe.compatible,
143+
installed,
144+
running: !!running && !running.exited,
145+
model: running && !running.exited ? running.model : undefined,
146+
detail: probe.detail,
147+
};
148+
})
149+
);
150+
}
151+
50152
function clearIdleTimer(server: RunningServer): void {
51153
if (!server.idleTimer) return;
52154
clearTimeout(server.idleTimer);

app/src/main.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,14 @@ function registerIpcHandlers(): void {
304304
await llamacpp.deleteModel(getLlamaCppModelsDir(), requireString(name, "model name"));
305305
});
306306
ipcMain.handle("llamacpp:getAvailableGpuBackends", () => llamacpp.getAvailableGpuBackends());
307+
ipcMain.handle("localBackends:getStatuses", () => {
308+
const settings = settingsStore.getSettings();
309+
return localServers.getRuntimeStatuses({
310+
mlxPythonPath: settings.mlxPythonPath,
311+
rocmServerPath: settings.rocmServerPath,
312+
vllmCommand: settings.vllmCommand,
313+
});
314+
});
307315
ipcMain.handle("llamacpp:setGpuBackend", async (_event: IpcMainInvokeEvent, backend: llamacpp.GpuBackend) => {
308316
await llamacpp.setGpuBackend(backend);
309317
settingsStore.saveSettings({ llamaCppGpuBackend: backend });

app/src/preload.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { RollbackResult, ProjectScripts } from "./agent-tools";
77
import type { PromptPreset } from "./settings-store";
88
import type { LocalGgufModel, GpuBackend } from "./llamacpp-manager";
99
import type { ScheduledTask } from "./scheduled-tasks-store";
10+
import type { LocalRuntimeStatus } from "./local-server-manager";
1011

1112
interface ToolExecuteResult {
1213
result?: unknown;
@@ -93,6 +94,10 @@ contextBridge.exposeInMainWorld("api", {
9394
pickModelsDir: (): Promise<string | null> => ipcRenderer.invoke("llamacpp:pickModelsDir"),
9495
},
9596

97+
localBackends: {
98+
getStatuses: (): Promise<LocalRuntimeStatus[]> => ipcRenderer.invoke("localBackends:getStatuses"),
99+
},
100+
96101
chat: {
97102
send: (
98103
provider: ProviderId,

0 commit comments

Comments
 (0)