Skip to content

Commit 21ae461

Browse files
committed
feat: add managed vLLM and GPU runtimes
1 parent ac2b49e commit 21ae461

9 files changed

Lines changed: 238 additions & 30 deletions

File tree

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,29 @@ describe("buildServerCommand", () => {
3434
it("uses distinct fixed ports per backend so both can run at once", () => {
3535
const mlx = buildServerCommand("mlx", "m", {});
3636
const rocm = buildServerCommand("rocm", "m", {});
37+
const vllm = buildServerCommand("vllm", "m", {});
3738
const portOf = (args: string[]) => args[args.indexOf("--port") + 1];
3839
expect(portOf(mlx.args)).not.toBe(portOf(rocm.args));
40+
expect(new Set([portOf(mlx.args), portOf(rocm.args), portOf(vllm.args)]).size).toBe(3);
41+
});
42+
43+
it("builds a managed vLLM OpenAI server command", () => {
44+
const { command, args } = buildServerCommand("vllm", "meta-llama/Llama-3.1-8B-Instruct", {}, "linux");
45+
expect(command).toBe("vllm");
46+
expect(args).toEqual(
47+
expect.arrayContaining(["serve", "meta-llama/Llama-3.1-8B-Instruct", "--host", "127.0.0.1"])
48+
);
49+
});
50+
51+
it("allows a vLLM command override without requiring one", () => {
52+
const { command } = buildServerCommand("vllm", "some/model", { vllmCommand: "/opt/vllm/bin/vllm" });
53+
expect(command).toBe("/opt/vllm/bin/vllm");
54+
});
55+
56+
it("launches vLLM through WSL automatically on Windows", () => {
57+
const { command, args } = buildServerCommand("vllm", "some/model", {}, "win32");
58+
expect(command).toBe("wsl.exe");
59+
expect(args.slice(0, 4)).toEqual(["--", "vllm", "serve", "some/model"]);
3960
});
4061
});
4162

@@ -47,4 +68,8 @@ describe("describeSpawnFailure", () => {
4768
it("points rocm failures at the llama-server binary setting", () => {
4869
expect(describeSpawnFailure("rocm")).toMatch(/llama-server/);
4970
});
71+
72+
it("points vLLM failures at installation rather than endpoint configuration", () => {
73+
expect(describeSpawnFailure("vllm")).toMatch(/pip install vllm/);
74+
});
5075
});

app/src/local-server-manager.ts

Lines changed: 115 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,36 +12,65 @@ import { logger } from "./logger";
1212
// - "rocm": AMD-GPU inference via a ROCm/HIP build of llama.cpp's
1313
// `llama-server` binary (the official llama.cpp releases ship one), run
1414
// against the same GGUF files the built-in llama.cpp backend uses.
15-
export type LocalBackendId = "mlx" | "rocm";
15+
export type LocalBackendId = "mlx" | "rocm" | "vllm";
1616

1717
export interface LocalBackendConfig {
1818
// Path to the ROCm llama-server binary. No sensible default beyond PATH
1919
// lookup — the user downloads a HIP build themselves.
2020
rocmServerPath?: string;
2121
// Python interpreter used to launch mlx_lm.server (needs `pip install mlx-lm`).
2222
mlxPythonPath?: string;
23+
// Optional override; managed vLLM uses the `vllm` command from PATH.
24+
vllmCommand?: string;
2325
}
2426

2527
interface RunningServer {
2628
process: ChildProcess;
2729
model: string;
2830
baseUrl: string;
2931
exited: boolean;
32+
activeRequests: number;
33+
idleTimer: NodeJS.Timeout | null;
3034
}
3135

3236
// Fixed per-backend ports so a restarted app reconnects rather than leaking
3337
// orphan servers across random ports.
34-
const PORTS: Record<LocalBackendId, number> = { mlx: 8790, rocm: 8791 };
38+
const PORTS: Record<LocalBackendId, number> = { mlx: 8790, rocm: 8791, vllm: 8792 };
3539
// First startup can include downloading/loading a multi-GB model.
3640
const STARTUP_TIMEOUT_MS = 180_000;
3741
const HEALTH_POLL_MS = 750;
42+
const configuredIdleMinutes = Number(process.env.OLLAMA_CUSTOM_UI_LOCAL_BACKEND_IDLE_MINUTES ?? 10);
43+
const IDLE_TIMEOUT_MS = Number.isFinite(configuredIdleMinutes)
44+
? Math.max(0, configuredIdleMinutes) * 60_000
45+
: 10 * 60_000;
3846

3947
const servers = new Map<LocalBackendId, RunningServer>();
48+
const serverStarts = new Map<LocalBackendId, { model: string; promise: Promise<string> }>();
49+
50+
function clearIdleTimer(server: RunningServer): void {
51+
if (!server.idleTimer) return;
52+
clearTimeout(server.idleTimer);
53+
server.idleTimer = null;
54+
}
55+
56+
function scheduleIdleStop(backend: LocalBackendId, server: RunningServer): void {
57+
clearIdleTimer(server);
58+
if (IDLE_TIMEOUT_MS === 0 || server.activeRequests > 0 || server.exited) return;
59+
server.idleTimer = setTimeout(() => {
60+
server.idleTimer = null;
61+
if (servers.get(backend) === server && server.activeRequests === 0) {
62+
logger.info(`Stopping idle ${backend} runtime to release GPU memory`);
63+
stopServer(backend);
64+
}
65+
}, IDLE_TIMEOUT_MS);
66+
server.idleTimer.unref();
67+
}
4068

4169
export function buildServerCommand(
4270
backend: LocalBackendId,
4371
model: string,
44-
config: LocalBackendConfig
72+
config: LocalBackendConfig,
73+
platform: NodeJS.Platform = process.platform
4574
): { command: string; args: string[] } {
4675
const port = PORTS[backend];
4776
if (backend === "mlx") {
@@ -50,6 +79,16 @@ export function buildServerCommand(
5079
args: ["-m", "mlx_lm.server", "--model", model, "--port", String(port), "--host", "127.0.0.1"],
5180
};
5281
}
82+
if (backend === "vllm") {
83+
const args = ["serve", model, "--port", String(port), "--host", "127.0.0.1"];
84+
if (!config.vllmCommand?.trim() && platform === "win32") {
85+
return { command: "wsl.exe", args: ["--", "vllm", ...args] };
86+
}
87+
return {
88+
command: config.vllmCommand?.trim() || "vllm",
89+
args,
90+
};
91+
}
5392
return {
5493
command: config.rocmServerPath?.trim() || "llama-server",
5594
args: [
@@ -63,9 +102,13 @@ export function buildServerCommand(
63102
}
64103

65104
export function describeSpawnFailure(backend: LocalBackendId): string {
66-
return backend === "mlx"
67-
? "Couldn't launch the MLX server — it needs Python with the mlx-lm package (pip install mlx-lm), available on Apple Silicon Macs."
68-
: "Couldn't launch llama-server — set the path to a ROCm (HIP) build of llama.cpp's llama-server binary in Settings.";
105+
if (backend === "mlx") {
106+
return "Couldn't launch the managed MLX runtime — install mlx-lm (pip install mlx-lm) on an Apple Silicon Mac.";
107+
}
108+
if (backend === "vllm") {
109+
return "Couldn't launch the managed vLLM runtime — install vLLM so the vllm command is available (pip install vllm).";
110+
}
111+
return "Couldn't launch the managed ROCm runtime — install a ROCm/HIP llama-server build and make llama-server available on PATH.";
69112
}
70113

71114
// Any HTTP response means the server socket is up (a 404 from a route probe
@@ -83,7 +126,7 @@ function sleep(ms: number): Promise<void> {
83126
return new Promise((resolve) => setTimeout(resolve, ms));
84127
}
85128

86-
export async function ensureServer(
129+
async function startOrReuseServer(
87130
backend: LocalBackendId,
88131
model: string,
89132
config: LocalBackendConfig
@@ -94,6 +137,10 @@ export async function ensureServer(
94137
// Process alive but unresponsive — restart it below.
95138
}
96139
if (existing) {
140+
if (existing.activeRequests > 0) {
141+
throw new Error(`The ${backend} runtime is busy. Wait for the active response before changing models.`);
142+
}
143+
clearIdleTimer(existing);
97144
existing.process.kill();
98145
servers.delete(backend);
99146
}
@@ -109,7 +156,14 @@ export async function ensureServer(
109156
throw new Error(describeSpawnFailure(backend));
110157
}
111158

112-
const entry: RunningServer = { process: child, model, baseUrl, exited: false };
159+
const entry: RunningServer = {
160+
process: child,
161+
model,
162+
baseUrl,
163+
exited: false,
164+
activeRequests: 0,
165+
idleTimer: null,
166+
};
113167
servers.set(backend, entry);
114168

115169
let spawnError: string | null = null;
@@ -129,8 +183,10 @@ export async function ensureServer(
129183
servers.delete(backend);
130184
throw new Error(
131185
backend === "mlx"
132-
? "The MLX server exited during startup — check that mlx-lm is installed and the model id is valid."
133-
: "llama-server exited during startup — check that the binary is a working ROCm build and the model file is a valid GGUF."
186+
? "The MLX runtime exited during startup — check that mlx-lm is installed and the model id is valid."
187+
: backend === "vllm"
188+
? "The vLLM runtime exited during startup — check that vLLM supports this model and that enough GPU memory is available."
189+
: "The ROCm runtime exited during startup — check that llama-server is a working HIP build and the model is a valid GGUF."
134190
);
135191
}
136192
if (await isReachable(baseUrl)) return baseUrl;
@@ -141,15 +197,64 @@ export async function ensureServer(
141197
throw new Error(`The ${backend} server didn't become reachable within ${STARTUP_TIMEOUT_MS / 1000}s.`);
142198
}
143199

200+
export async function ensureServer(
201+
backend: LocalBackendId,
202+
model: string,
203+
config: LocalBackendConfig
204+
): Promise<string> {
205+
const pending = serverStarts.get(backend);
206+
if (pending) {
207+
if (pending.model === model) return pending.promise;
208+
await pending.promise.catch(() => undefined);
209+
return ensureServer(backend, model, config);
210+
}
211+
212+
const promise = startOrReuseServer(backend, model, config);
213+
serverStarts.set(backend, { model, promise });
214+
try {
215+
return await promise;
216+
} finally {
217+
if (serverStarts.get(backend)?.promise === promise) serverStarts.delete(backend);
218+
}
219+
}
220+
221+
export async function acquireServer(
222+
backend: LocalBackendId,
223+
model: string,
224+
config: LocalBackendConfig
225+
): Promise<{ baseUrl: string; release(): void }> {
226+
const current = servers.get(backend);
227+
if (current) clearIdleTimer(current);
228+
const baseUrl = await ensureServer(backend, model, config);
229+
const server = servers.get(backend);
230+
if (!server || server.exited || server.model !== model) {
231+
throw new Error(`The ${backend} runtime stopped before the request could start.`);
232+
}
233+
server.activeRequests++;
234+
let released = false;
235+
return {
236+
baseUrl,
237+
release(): void {
238+
if (released) return;
239+
released = true;
240+
if (servers.get(backend) !== server) return;
241+
server.activeRequests = Math.max(0, server.activeRequests - 1);
242+
scheduleIdleStop(backend, server);
243+
},
244+
};
245+
}
246+
144247
export function stopServer(backend: LocalBackendId): void {
145248
const entry = servers.get(backend);
146249
if (entry) {
250+
clearIdleTimer(entry);
147251
entry.process.kill();
148252
servers.delete(backend);
149253
}
150254
}
151255

152256
export function stopAll(): void {
257+
serverStarts.clear();
153258
for (const backend of [...servers.keys()]) stopServer(backend);
154259
}
155260

app/src/main.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { setupMenu } from "./menu";
3232
import { setupAutoUpdater, checkForUpdatesManually } from "./updater";
3333
import type { ChatMessage, ChatChunk, ChatOptions, ProviderId, ToolDefinition } from "./providers/types";
3434

35-
const PROVIDER_SECRET_KEYS: Record<Exclude<ProviderId, "ollama" | "llamacpp" | "custom" | "mlx" | "rocm">, string> = {
35+
const PROVIDER_SECRET_KEYS: Record<Exclude<ProviderId, "ollama" | "llamacpp" | "custom" | "mlx" | "rocm" | "vllm">, string> = {
3636
openai: "openai_api_key",
3737
anthropic: "anthropic_api_key",
3838
gemini: "gemini_api_key",
@@ -186,7 +186,7 @@ async function dispatchChat(
186186
} else if (provider === "llamacpp") {
187187
const modelPath = path.join(getLlamaCppModelsDir(), model);
188188
await llamacpp.chat(modelPath, messages, options, onToken, signal, tools);
189-
} else if (provider === "mlx" || provider === "rocm") {
189+
} else if (provider === "mlx" || provider === "rocm" || provider === "vllm") {
190190
const settings = settingsStore.getSettings();
191191
// ROCm serves the same GGUF files as the llama.cpp backend, so the
192192
// model ref is a filename that must stay inside the models dir; MLX
@@ -200,21 +200,27 @@ async function dispatchChat(
200200
}
201201
serverModel = resolved;
202202
}
203-
const baseUrl = await localServers.ensureServer(provider, serverModel, {
203+
const lease = await localServers.acquireServer(provider, serverModel, {
204204
mlxPythonPath: settings.mlxPythonPath,
205205
rocmServerPath: settings.rocmServerPath,
206+
vllmCommand: settings.vllmCommand,
206207
});
207-
// These servers are local and unauthenticated — the "api key" is a
208-
// placeholder the OpenAI-compatible client requires but they ignore.
209-
await createOpenAiCompatibleChat(`${baseUrl}/v1`, provider === "mlx" ? "MLX" : "ROCm llama-server")(
210-
"local",
211-
model,
212-
messages,
213-
options,
214-
onToken,
215-
signal,
216-
tools
217-
);
208+
try {
209+
// Managed runtimes are local and unauthenticated; the key is a
210+
// compatibility placeholder for their OpenAI-shaped APIs.
211+
const providerLabel = provider === "mlx" ? "MLX" : provider === "vllm" ? "vLLM" : "ROCm llama-server";
212+
await createOpenAiCompatibleChat(`${lease.baseUrl}/v1`, providerLabel)(
213+
"local",
214+
model,
215+
messages,
216+
options,
217+
onToken,
218+
signal,
219+
tools
220+
);
221+
} finally {
222+
lease.release();
223+
}
218224
} else if (provider === "custom") {
219225
// model is "<customProviderId>::<actual model id>" — see
220226
// frontend/src/lib/providers.ts's formatCustomModelRef.

app/src/providers/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export interface ChatChunk {
5050
toolCalls?: ToolCall[];
5151
}
5252

53-
export type ProviderId = "ollama" | "openai" | "anthropic" | "llamacpp" | "gemini" | "custom" | "mlx" | "rocm";
53+
export type ProviderId = "ollama" | "openai" | "anthropic" | "llamacpp" | "gemini" | "custom" | "mlx" | "rocm" | "vllm";
5454

5555
export interface ChatOptions {
5656
temperature?: number;

app/src/settings-store.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ export interface AppSettings {
9595
// Path to a ROCm/HIP build of llama.cpp's llama-server binary — enables
9696
// the "rocm" provider against the same GGUF dir as the llama.cpp backend.
9797
rocmServerPath?: string;
98+
// Hugging Face model ids or local model paths served by the app-managed
99+
// vLLM runtime. The `vllm` executable is discovered from PATH by default.
100+
vllmModels?: string[];
101+
vllmCommand?: string;
98102
}
99103

100104
const DEFAULTS: AppSettings = {

frontend/src/lib/providers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ export const PROVIDER_LABELS: Record<ProviderId, string> = {
1414
custom: "Custom",
1515
mlx: "MLX (Apple Silicon)",
1616
rocm: "ROCm (AMD)",
17+
vllm: "vLLM (managed)",
1718
};
1819

1920
// Providers that run models on this machine — no API key, no per-token cost.
20-
export const LOCAL_PROVIDERS: ProviderId[] = ["ollama", "llamacpp", "mlx", "rocm"];
21+
export const LOCAL_PROVIDERS: ProviderId[] = ["ollama", "llamacpp", "mlx", "rocm", "vllm"];
2122

2223
// Curated as of this app's last update — model lineups change often, so the
2324
// model picker also lets you type a custom model ID directly.

frontend/src/pages/Chat.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1320,7 +1320,7 @@ export default function Chat() {
13201320
))}
13211321
</SelectGroup>
13221322
)}
1323-
{!!settings?.rocmServerPath && llamaCppModels.length > 0 && (
1323+
{llamaCppModels.length > 0 && (
13241324
<SelectGroup>
13251325
<SelectLabel>ROCm (AMD)</SelectLabel>
13261326
{llamaCppModels.map((m) => (
@@ -1330,6 +1330,16 @@ export default function Chat() {
13301330
))}
13311331
</SelectGroup>
13321332
)}
1333+
{(settings?.vllmModels ?? []).length > 0 && (
1334+
<SelectGroup>
1335+
<SelectLabel>vLLM (managed)</SelectLabel>
1336+
{settings!.vllmModels!.map((id) => (
1337+
<SelectItem key={id} value={formatModelRef("vllm", id)}>
1338+
{id}
1339+
</SelectItem>
1340+
))}
1341+
</SelectGroup>
1342+
)}
13331343
<SelectGroup>
13341344
<SelectLabel>ChatGPT</SelectLabel>
13351345
{OPENAI_MODELS.map((m) => (

0 commit comments

Comments
 (0)