@@ -39,6 +39,52 @@ const modelLoads = new Map<string, Promise<LlamaModel>>();
3939const modelLastUsed = new Map < string , number > ( ) ;
4040const activeModelUsers = new Map < string , number > ( ) ;
4141let maxCachedModels = 2 ;
42+ // Keep warm weights for fast follow-up prompts, then release their RAM/VRAM
43+ // after inactivity. Headless deployments can override the default without a
44+ // new UI setting; 0 disables time-based eviction.
45+ const configuredIdleMinutes = Number ( process . env . OLLAMA_CUSTOM_UI_LLAMA_IDLE_MINUTES ?? 15 ) ;
46+ const modelIdleTimeoutMs = Number . isFinite ( configuredIdleMinutes )
47+ ? Math . max ( 0 , configuredIdleMinutes ) * 60_000
48+ : 15 * 60_000 ;
49+ let idleEvictionTimer : NodeJS . Timeout | null = null ;
50+
51+ function clearIdleEvictionTimer ( ) : void {
52+ if ( ! idleEvictionTimer ) return ;
53+ clearTimeout ( idleEvictionTimer ) ;
54+ idleEvictionTimer = null ;
55+ }
56+
57+ function scheduleIdleEviction ( ) : void {
58+ clearIdleEvictionTimer ( ) ;
59+ if ( modelIdleTimeoutMs === 0 || modelCache . size === 0 ) return ;
60+
61+ const now = Date . now ( ) ;
62+ const nextExpiry = [ ...modelCache . keys ( ) ]
63+ . filter ( ( key ) => ( activeModelUsers . get ( key ) ?? 0 ) === 0 )
64+ . map ( ( key ) => ( modelLastUsed . get ( key ) ?? now ) + modelIdleTimeoutMs )
65+ . sort ( ( a , b ) => a - b ) [ 0 ] ;
66+ if ( nextExpiry === undefined ) return ;
67+
68+ idleEvictionTimer = setTimeout ( ( ) => {
69+ idleEvictionTimer = null ;
70+ void evictExpiredModels ( ) ;
71+ } , Math . max ( 1_000 , nextExpiry - now ) ) ;
72+ idleEvictionTimer . unref ( ) ;
73+ }
74+
75+ async function evictExpiredModels ( ) : Promise < void > {
76+ const cutoff = Date . now ( ) - modelIdleTimeoutMs ;
77+ const expiredModels : LlamaModel [ ] = [ ] ;
78+ for ( const [ key , model ] of modelCache ) {
79+ if ( ( activeModelUsers . get ( key ) ?? 0 ) > 0 ) continue ;
80+ if ( ( modelLastUsed . get ( key ) ?? 0 ) > cutoff ) continue ;
81+ modelCache . delete ( key ) ;
82+ modelLastUsed . delete ( key ) ;
83+ expiredModels . push ( model ) ;
84+ }
85+ await Promise . allSettled ( expiredModels . map ( ( model ) => model . dispose ( ) ) ) ;
86+ scheduleIdleEviction ( ) ;
87+ }
4288
4389export function setModelCacheLimit ( limit : number ) : void {
4490 if ( ! Number . isFinite ( limit ) ) return ;
@@ -55,6 +101,9 @@ export async function setGpuBackend(backend: GpuBackend): Promise<void> {
55101 throw new Error ( `Unsupported llama.cpp GPU backend: ${ String ( backend ) } ` ) ;
56102 }
57103 if ( backend === activeBackend ) return ;
104+ if ( [ ...activeModelUsers . values ( ) ] . some ( ( users ) => users > 0 ) ) {
105+ throw new Error ( "The GPU backend cannot be changed while a llama.cpp response is being generated." ) ;
106+ }
58107 const oldModels = [ ...modelCache . values ( ) ] ;
59108 const oldLlama = llamaInstance ;
60109 activeBackend = backend ;
@@ -68,6 +117,7 @@ export async function setGpuBackend(backend: GpuBackend): Promise<void> {
68117 modelLoads . clear ( ) ;
69118 modelLastUsed . clear ( ) ;
70119 activeModelUsers . clear ( ) ;
120+ clearIdleEvictionTimer ( ) ;
71121
72122 // Native model buffers can outlive their JS references. Explicitly
73123 // dispose them so a backend switch returns VRAM before reallocating it.
@@ -116,6 +166,7 @@ async function loadModel(modelPath: string, gpuLayers?: number): Promise<LlamaMo
116166 const cached = modelCache . get ( key ) ;
117167 if ( cached ) {
118168 modelLastUsed . set ( key , Date . now ( ) ) ;
169+ scheduleIdleEviction ( ) ;
119170 return cached ;
120171 }
121172 const pending = modelLoads . get ( key ) ;
@@ -134,6 +185,7 @@ async function loadModel(modelPath: string, gpuLayers?: number): Promise<LlamaMo
134185 modelCache . set ( key , model ) ;
135186 modelLastUsed . set ( key , Date . now ( ) ) ;
136187 await evictIdleModels ( key ) ;
188+ scheduleIdleEviction ( ) ;
137189 return model ;
138190 } ) ( ) ;
139191 modelLoads . set ( key , load ) ;
@@ -155,10 +207,12 @@ async function evictIdleModels(protectedKey?: string): Promise<void> {
155207 modelLastUsed . delete ( candidate ) ;
156208 if ( model ) await model . dispose ( ) ;
157209 }
210+ scheduleIdleEviction ( ) ;
158211}
159212
160213export async function dispose ( ) : Promise < void > {
161214 backendRevision ++ ;
215+ clearIdleEvictionTimer ( ) ;
162216 const models = [ ...modelCache . values ( ) ] ;
163217 const llama = llamaInstance ;
164218 modelCache . clear ( ) ;
@@ -195,20 +249,34 @@ export function listLoadedModels(): string[] {
195249 return [ ...new Set ( [ ...modelCache . keys ( ) ] . map ( ( key ) => key . split ( "\0" , 1 ) [ 0 ] ) ) ] ;
196250}
197251
198- export function deleteModel ( modelsDir : string , name : string ) : void {
252+ export async function deleteModel ( modelsDir : string , name : string ) : Promise < void > {
199253 const root = path . resolve ( modelsDir ) ;
200254 const target = path . resolve ( root , name ) ;
201- if ( target !== root && ! target . startsWith ( root + path . sep ) ) {
255+ if ( path . basename ( name ) !== name || ! name . toLowerCase ( ) . endsWith ( ".gguf" ) ) {
202256 throw new Error ( "Invalid model file name." ) ;
203257 }
204- fs . rmSync ( target , { force : true } ) ;
258+ if ( target === root || ! target . startsWith ( root + path . sep ) ) {
259+ throw new Error ( "Invalid model file name." ) ;
260+ }
261+ const matchingKeys = [ ...modelCache . keys ( ) ] . filter ( ( key ) => key . startsWith ( `${ target } \0` ) ) ;
262+ if ( matchingKeys . some ( ( key ) => ( activeModelUsers . get ( key ) ?? 0 ) > 0 ) ) {
263+ throw new Error ( "This model cannot be deleted while it is generating a response." ) ;
264+ }
265+ if ( [ ...modelLoads . keys ( ) ] . some ( ( key ) => key . startsWith ( `${ target } \0` ) ) ) {
266+ throw new Error ( "This model cannot be deleted while it is still loading." ) ;
267+ }
268+
269+ const modelsToDispose : LlamaModel [ ] = [ ] ;
205270 for ( const [ key , model ] of modelCache ) {
206271 if ( key . startsWith ( `${ target } \0` ) ) {
207272 modelCache . delete ( key ) ;
208273 modelLastUsed . delete ( key ) ;
209- void model . dispose ( ) ;
274+ modelsToDispose . push ( model ) ;
210275 }
211276 }
277+ await Promise . allSettled ( modelsToDispose . map ( ( model ) => model . dispose ( ) ) ) ;
278+ fs . rmSync ( target , { force : true } ) ;
279+ scheduleIdleEviction ( ) ;
212280}
213281
214282// Maps this app's provider-agnostic ChatMessage[] (system/user/assistant,
@@ -288,5 +356,6 @@ export async function chat(
288356 else activeModelUsers . set ( cacheKey , users ) ;
289357 modelLastUsed . set ( cacheKey , Date . now ( ) ) ;
290358 await evictIdleModels ( ) ;
359+ scheduleIdleEviction ( ) ;
291360 }
292361}
0 commit comments