@@ -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
1717export 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
2527interface 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.
3640const STARTUP_TIMEOUT_MS = 180_000 ;
3741const 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
3947const 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
4169export 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
65104export 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+
144247export 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
152256export function stopAll ( ) : void {
257+ serverStarts . clear ( ) ;
153258 for ( const backend of [ ...servers . keys ( ) ] ) stopServer ( backend ) ;
154259}
155260
0 commit comments