1010import shutil
1111import signal
1212import threading
13+ import urllib .error
14+ import urllib .request
1315
1416from nemo_platform import NeMoPlatform , NotFoundError
1517from nemo_platform .types .models import ModelEntity
@@ -64,6 +66,15 @@ def __init__(self, stop_signal: threading.Event | None = None):
6466 # which scans the directory and does not require this equality.
6567 self .base_model_name_override = os .getenv ("VLLM_LORA_BASE_MODEL_OVERRIDE" , "" )
6668
69+ # vLLM-only: base URL of the sibling vLLM server (e.g. http://localhost:49152).
70+ # When set, the sidecar eagerly (un)loads adapters through vLLM's runtime LoRA
71+ # API so they are advertised in ``/v1/models`` immediately, instead of relying
72+ # on the filesystem resolver to lazily load them on the first inference request
73+ # (which leaves the adapter invisible to model-provider discovery). Unset for
74+ # NIM, which scans ``NIM_PEFT_SOURCE`` itself.
75+ self .vllm_endpoint = os .getenv ("VLLM_ENDPOINT" , "" ).rstrip ("/" )
76+ self .vllm_request_timeout = float (os .getenv ("VLLM_LORA_LOAD_TIMEOUT" , "120" ))
77+
6778 self ._stop_signal = stop_signal
6879
6980 self ._loop = asyncio .new_event_loop ()
@@ -107,7 +118,19 @@ def step(self):
107118 self ._update_prompt_tuned_models (dirs_to_keep )
108119
109120 for name in set (os .listdir (self .nim_peft_source )) - dirs_to_keep :
110- shutil .rmtree (f"{ self .nim_peft_source } /{ name } " )
121+ # Staging temp dirs (".{dir}.tmp") were never loaded into vLLM;
122+ # just reap them.
123+ if name .startswith ("." ):
124+ shutil .rmtree (f"{ self .nim_peft_source } /{ name } " )
125+ continue
126+ # Unload from vLLM before deleting on disk so a removed/disabled
127+ # adapter stops being served (no-op for NIM). Only delete the
128+ # directory once the unload is confirmed (or vLLM has no endpoint):
129+ # if vLLM is currently unreachable, keep the dir so the unload is
130+ # retried next cycle rather than orphaning a still-loaded adapter
131+ # in vLLM until it restarts (the dir is the only state driving GC).
132+ if self ._unload_vllm_adapter (name ):
133+ shutil .rmtree (f"{ self .nim_peft_source } /{ name } " )
111134
112135 except Exception :
113136 logger .exception (f"Failed to fetch { self .workspace } /{ self .model_name } 's model_entity" )
@@ -195,7 +218,7 @@ def _adapter_changed(self, adapter_dir: str, adapter: Adapter) -> bool:
195218
196219 return False
197220
198- def _download_adapter (self , adapter_dir : str , adapter : Adapter , adapter_workspace : str ) -> None :
221+ def _download_adapter (self , adapter_dir : str , adapter : Adapter , adapter_workspace : str ) -> bool :
199222 """Download adapter fileset atomically using a temp directory + rename.
200223
201224 Downloads into a sibling temp directory inside nim_peft_source, then
@@ -216,6 +239,13 @@ def _download_adapter(self, adapter_dir: str, adapter: Adapter, adapter_workspac
216239 means it lives in the adapter's workspace. Using the base model's workspace
217240 instead would silently mis-route fileset fetches whenever the adapter and
218241 base model live in different workspaces.
242+
243+ Returns ``True`` if the adapter was (re)published into ``adapter_dir``;
244+ ``False`` if the download produced no files (e.g. an empty fileset), in
245+ which case ``adapter_dir`` is left untouched (a new adapter has no
246+ directory; an existing one keeps its previous weights). Raises on
247+ unexpected errors after cleaning up the staging directory. Callers use
248+ the result to decide whether to (re)register the adapter with vLLM.
219249 """
220250 parts = adapter .fileset .split ("/" , 1 )
221251 if len (parts ) == 1 :
@@ -243,8 +273,10 @@ def _download_adapter(self, adapter_dir: str, adapter: Adapter, adapter_workspac
243273 if os .path .isdir (adapter_dir ):
244274 shutil .rmtree (adapter_dir )
245275 os .rename (temp_dir , adapter_dir )
276+ return True
246277 else :
247278 shutil .rmtree (temp_dir , ignore_errors = True )
279+ return False
248280 except Exception :
249281 shutil .rmtree (temp_dir , ignore_errors = True )
250282 raise
@@ -267,6 +299,93 @@ def _resolve_adapter_workspace(adapter: Adapter, base_model_workspace: str) -> s
267299 """
268300 return getattr (adapter , "workspace" , None ) or base_model_workspace
269301
302+ def _vllm_api_call (self , route : str , payload : dict ) -> tuple [int , str ]:
303+ """POST ``payload`` to the local vLLM server at ``route``.
304+
305+ Returns ``(status_code, body)``. HTTP error responses (4xx/5xx) are
306+ returned, not raised. Transport-level failures (vLLM not yet reachable)
307+ raise ``urllib.error.URLError``/``OSError`` so callers can retry next cycle.
308+ """
309+ url = f"{ self .vllm_endpoint } { route } "
310+ data = json .dumps (payload ).encode ("utf-8" )
311+ req = urllib .request .Request (url , data = data , headers = {"Content-Type" : "application/json" }, method = "POST" )
312+ try :
313+ with urllib .request .urlopen (req , timeout = self .vllm_request_timeout ) as resp :
314+ return resp .status , resp .read ().decode ("utf-8" , "replace" )
315+ except urllib .error .HTTPError as e :
316+ return e .code , e .read ().decode ("utf-8" , "replace" )
317+
318+ def _load_vllm_adapter (self , lora_name : str , adapter_dir : str ) -> None :
319+ """Register an adapter with vLLM via ``POST /v1/load_lora_adapter``.
320+
321+ Tolerates the "already loaded" response so this is safe to call every
322+ reconcile cycle. Raises on transport errors (handled by the caller).
323+ """
324+ status , body = self ._vllm_api_call ("/v1/load_lora_adapter" , {"lora_name" : lora_name , "lora_path" : adapter_dir })
325+ if status == 200 :
326+ logger .info (f"Loaded LoRA adapter { lora_name !r} into vLLM from { adapter_dir } " )
327+ elif status == 400 and "already" in body .lower ():
328+ logger .debug (f"LoRA adapter { lora_name !r} already loaded in vLLM" )
329+ else :
330+ logger .warning (f"vLLM load_lora_adapter failed for { lora_name !r} (status={ status } ): { body [:300 ]} " )
331+
332+ def _unload_vllm_adapter (self , lora_name : str ) -> bool :
333+ """Best-effort ``POST /v1/unload_lora_adapter``; never raises.
334+
335+ Returns ``True`` when it is safe to drop the adapter's on-disk directory:
336+ either there is no vLLM endpoint (NIM, which unloads purely by directory
337+ removal) or vLLM *confirmed* the unload — a 2xx, or a non-server-error
338+ response such as 404 "not loaded" (the adapter is not loaded either way).
339+ Returns ``False`` when vLLM was unreachable **or** returned a 5xx server
340+ error: the unload was not confirmed and the adapter may still be loaded,
341+ so the GC caller keeps the directory (the only state driving
342+ reconciliation) and retries next cycle instead of orphaning a
343+ still-loaded adapter in vLLM until it restarts.
344+ """
345+ if not self .vllm_endpoint :
346+ return True
347+ try :
348+ status , body = self ._vllm_api_call ("/v1/unload_lora_adapter" , {"lora_name" : lora_name })
349+ except (urllib .error .URLError , OSError ) as e :
350+ logger .debug (f"vLLM unload_lora_adapter for { lora_name !r} skipped (vLLM unreachable): { e } " )
351+ return False
352+ if status == 200 :
353+ logger .info (f"Unloaded LoRA adapter { lora_name !r} from vLLM" )
354+ return True
355+ if status >= 500 :
356+ # Server-side error: the unload was not confirmed and the adapter may
357+ # still be loaded. Keep the directory and retry next cycle rather than
358+ # deleting it and orphaning the adapter in vLLM until a restart.
359+ logger .warning (
360+ f"vLLM unload_lora_adapter for { lora_name !r} returned server error "
361+ f"(status={ status } ), will retry: { body [:200 ]} "
362+ )
363+ return False
364+ # Non-2xx, non-5xx (e.g. 404 "not loaded"): vLLM answered and the adapter
365+ # is not loaded, so it is safe to drop the directory.
366+ logger .debug (f"vLLM unload_lora_adapter for { lora_name !r} (status={ status } ): { body [:200 ]} " )
367+ return True
368+
369+ def _ensure_vllm_adapter_loaded (self , lora_name : str , adapter_dir : str , reload : bool ) -> None :
370+ """Eagerly (re)register a downloaded adapter with vLLM.
371+
372+ No-op for NIM (``vllm_endpoint`` unset), which discovers adapters by
373+ scanning ``NIM_PEFT_SOURCE``. For vLLM, this makes the adapter appear in
374+ ``/v1/models`` without requiring a first inference request, so the
375+ model-provider reconciler can discover and surface it through the gateway.
376+ """
377+ if not self .vllm_endpoint :
378+ return
379+ try :
380+ if reload :
381+ # Adapter dir was (re)written; drop any stale copy so vLLM loads
382+ # the new weights rather than keeping the previous version.
383+ self ._unload_vllm_adapter (lora_name )
384+ self ._load_vllm_adapter (lora_name , adapter_dir )
385+ except (urllib .error .URLError , OSError ) as e :
386+ # vLLM may still be initializing; retried on the next reconcile cycle.
387+ logger .warning (f"Could not reach vLLM to load adapter { lora_name !r} , will retry: { e } " )
388+
270389 def _update_lora_adapters (self , dirs_to_keep : set [str ]):
271390 """Materialize each enabled LoRA adapter into ``{nim_peft_source}/{adapter_ws}--{adapter_name}/``.
272391
@@ -297,8 +416,22 @@ def _update_lora_adapters(self, dirs_to_keep: set[str]):
297416 dir_name = f"{ adapter_workspace } --{ adapter .name } "
298417 dirs_to_keep .add (dir_name )
299418 adapter_dir = f"{ self .nim_peft_source } /{ dir_name } "
300- if not os .path .isdir (adapter_dir ) or self ._adapter_changed (adapter_dir , adapter ):
301- self ._download_adapter (adapter_dir , adapter , adapter_workspace )
419+ dir_existed = os .path .isdir (adapter_dir )
420+ needs_download = not dir_existed or self ._adapter_changed (adapter_dir , adapter )
421+ published = False
422+ if needs_download :
423+ published = self ._download_adapter (adapter_dir , adapter , adapter_workspace )
424+ # Eagerly register with vLLM so the adapter is advertised in /v1/models
425+ # without waiting for a first inference request (no-op for NIM). Gate on
426+ # the adapter actually being on disk: a brand-new adapter whose download
427+ # failed (e.g. empty fileset) has no directory to load, and a changed
428+ # adapter whose re-download failed keeps its previous weights, which must
429+ # not be unloaded. Only unload first when we just re-published over an
430+ # adapter we already had on disk (``published and dir_existed``); a new
431+ # adapter has nothing to unload, and an unchanged or download-failed one
432+ # is (idempotently) reloaded so it survives a vLLM restart without flapping.
433+ if os .path .isdir (adapter_dir ):
434+ self ._ensure_vllm_adapter_loaded (dir_name , adapter_dir , reload = (published and dir_existed ))
302435
303436
304437def get_health_status () -> dict :
@@ -370,4 +503,12 @@ def run(parent_stop_signal: threading.Event | None = None):
370503
371504
372505if __name__ == "__main__" :
506+ # Configure root logging so the controller's INFO progress (adapter
507+ # download/load, GC) is visible in `docker logs`. Without this only
508+ # WARNING+ reaches stderr via logging's last-resort handler, which made
509+ # the sidecar look idle during normal operation.
510+ logging .basicConfig (
511+ level = os .getenv ("LOG_LEVEL" , "INFO" ).upper (),
512+ format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" ,
513+ )
373514 run ()
0 commit comments