wuenlp_tools.utils.local_ollama
Local Ollama lifecycle: per-job serve, model pull with flock, health checks.
1"""Local Ollama lifecycle: per-job serve, model pull with flock, health checks.""" 2 3from __future__ import annotations 4 5import atexit 6import fcntl 7import os 8import re 9import shutil 10import socket 11import subprocess 12import time 13from contextlib import contextmanager 14from pathlib import Path 15from typing import Iterator 16from urllib.parse import urlparse 17 18import requests 19from loguru import logger 20 21from wuenlp_tools.config import get_local_llm_config, get_pull_lock_dir 22 23_ollama_process: subprocess.Popen | None = None 24_resolved_base_url: str | None = None 25 26_DEFAULT_PORT = 11434 27_STARTUP_TIMEOUT_S = 120.0 28_PULL_LOCK_TIMEOUT_S = 7200.0 29_TRUTHY = frozenset({"1", "true", "yes", "on"}) 30_FALSY = frozenset({"0", "false", "no", "off"}) 31 32 33def _is_slurm() -> bool: 34 return bool(os.environ.get("SLURM_JOB_ID")) 35 36 37def _env_flag(name: str) -> bool | None: 38 raw = os.environ.get(name, "").strip().lower() 39 if raw in _TRUTHY: 40 return True 41 if raw in _FALSY: 42 return False 43 return None 44 45 46def _resolve_manage_process(setting: str | bool) -> bool: 47 override = _env_flag("WUENLP_LOCAL_LLM_MANAGE_PROCESS") 48 if override is not None: 49 return override 50 if isinstance(setting, bool): 51 return setting 52 if setting == "auto": 53 return _is_slurm() 54 return bool(setting) 55 56 57def _pick_free_port() -> int: 58 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: 59 sock.bind(("127.0.0.1", 0)) 60 return int(sock.getsockname()[1]) 61 62 63def _resolve_port(port_setting: str | int) -> int: 64 if isinstance(port_setting, int): 65 return port_setting 66 if port_setting == "auto": 67 return _pick_free_port() if _is_slurm() else _DEFAULT_PORT 68 return _DEFAULT_PORT 69 70 71def _base_url_from_port(port: int) -> str: 72 return f"http://127.0.0.1:{port}/v1/" 73 74 75def _normalize_base_url(url: str) -> str: 76 url = url.rstrip("/") + "/" 77 if not url.endswith("/v1/"): 78 if url.endswith("/v1"): 79 url += "/" 80 else: 81 url = url.rstrip("/") + "/v1/" 82 return url 83 84 85def _ollama_host_from_base_url(base_url: str) -> str: 86 parsed = urlparse(base_url) 87 host = parsed.hostname or "127.0.0.1" 88 port = parsed.port or _DEFAULT_PORT 89 return f"{host}:{port}" 90 91 92def _api_root(base_url: str) -> str: 93 return _normalize_base_url(base_url).removesuffix("/v1/") 94 95 96_OLLAMA_MISSING_MSG = ( 97 "Ollama is required for provider='local' but the `ollama` executable was not " 98 "found on PATH. Install from https://ollama.com or use provider='ollama' for " 99 "the remote team server." 100) 101 102 103def _require_ollama_on_path() -> str: 104 path = shutil.which("ollama") 105 if not path: 106 raise RuntimeError(_OLLAMA_MISSING_MSG) 107 return path 108 109 110def _ollama_binary() -> str: 111 return _require_ollama_on_path() 112 113 114def _wait_for_ollama(base_url: str, timeout: float = _STARTUP_TIMEOUT_S) -> None: 115 tags_url = f"{_api_root(base_url)}/api/tags" 116 deadline = time.monotonic() + timeout 117 while time.monotonic() < deadline: 118 try: 119 response = requests.get(tags_url, timeout=2.0) 120 if response.status_code == 200: 121 return 122 except requests.RequestException: 123 pass 124 if _ollama_process is not None and _ollama_process.poll() is not None: 125 raise RuntimeError( 126 f"ollama serve exited with code {_ollama_process.returncode}" 127 ) 128 time.sleep(0.25) 129 raise TimeoutError(f"Ollama did not become ready at {base_url} within {timeout}s") 130 131 132def _start_ollama_serve(host: str) -> subprocess.Popen: 133 env = os.environ.copy() 134 env["OLLAMA_HOST"] = host 135 logger.info("Starting ollama serve (OLLAMA_HOST={})", host) 136 process = subprocess.Popen( 137 [_ollama_binary(), "serve"], 138 env=env, 139 stdout=subprocess.DEVNULL, 140 stderr=subprocess.DEVNULL, 141 ) 142 return process 143 144 145def _stop_ollama_serve() -> None: 146 global _ollama_process 147 if _ollama_process is None: 148 return 149 if _ollama_process.poll() is None: 150 logger.debug("Stopping job-local ollama serve (pid={})", _ollama_process.pid) 151 _ollama_process.terminate() 152 try: 153 _ollama_process.wait(timeout=10) 154 except subprocess.TimeoutExpired: 155 _ollama_process.kill() 156 _ollama_process.wait(timeout=5) 157 _ollama_process = None 158 159 160def _ollama_is_reachable(base_url: str) -> bool: 161 try: 162 response = requests.get(f"{_api_root(base_url)}/api/tags", timeout=2.0) 163 return response.status_code == 200 164 except requests.RequestException: 165 return False 166 167 168def _is_loopback_host(hostname: str | None) -> bool: 169 return hostname in ("127.0.0.1", "localhost", "::1") 170 171 172def _assert_local_base_url(base_url: str) -> str: 173 parsed = urlparse(base_url) 174 if not _is_loopback_host(parsed.hostname): 175 raise ValueError( 176 f"provider='local' requires a loopback Ollama URL, got {base_url!r}. " 177 "Use provider='ollama' for remote servers." 178 ) 179 return _normalize_base_url(base_url) 180 181 182def _unreachable_error(base_url: str, *, manage_hint: bool) -> RuntimeError: 183 if shutil.which("ollama") is None: 184 return RuntimeError(_OLLAMA_MISSING_MSG) 185 hint = ( 186 "Start `ollama serve`, enable manage_process in local_llm config, " 187 "or set WUENLP_LOCAL_LLM_MANAGE_PROCESS=1." 188 if manage_hint 189 else "Start `ollama serve` on that host/port." 190 ) 191 return RuntimeError(f"Local Ollama is not reachable at {base_url}. {hint}") 192 193 194def _ensure_reachable(base_url: str, *, manage: bool) -> None: 195 if _ollama_is_reachable(base_url): 196 return 197 if manage: 198 _require_ollama_on_path() 199 host = _ollama_host_from_base_url(base_url) 200 global _ollama_process 201 _ollama_process = _start_ollama_serve(host) 202 atexit.register(_stop_ollama_serve) 203 _wait_for_ollama(base_url) 204 return 205 raise _unreachable_error(base_url, manage_hint=True) 206 207 208def restart_local_ollama() -> str: 209 """Stop the job-local serve process and start a fresh one (after timeouts/hangs).""" 210 global _resolved_base_url 211 _stop_ollama_serve() 212 _resolved_base_url = None 213 return ensure_local_ollama() 214 215 216def ensure_local_ollama() -> str: 217 """Ensure a local Ollama server is reachable; return OpenAI-compatible base URL.""" 218 global _ollama_process, _resolved_base_url 219 220 if _resolved_base_url is not None: 221 return _resolved_base_url 222 223 env_url = os.environ.get("WUENLP_LOCAL_LLM_BASE_URL") 224 if env_url: 225 _resolved_base_url = _assert_local_base_url(env_url) 226 manage = _resolve_manage_process(get_local_llm_config().manage_process) 227 _ensure_reachable(_resolved_base_url, manage=manage) 228 return _resolved_base_url 229 230 cfg = get_local_llm_config() 231 if cfg.base_url: 232 _resolved_base_url = _assert_local_base_url(cfg.base_url) 233 manage = _resolve_manage_process(cfg.manage_process) 234 _ensure_reachable(_resolved_base_url, manage=manage) 235 return _resolved_base_url 236 237 port = _resolve_port(cfg.port) 238 base_url = _base_url_from_port(port) 239 manage = _resolve_manage_process(cfg.manage_process) 240 if cfg.manage_process == "auto" and not _is_slurm(): 241 manage = not _ollama_is_reachable(base_url) 242 243 if manage: 244 if _ollama_is_reachable(base_url): 245 logger.debug("Using existing Ollama at {}", base_url) 246 else: 247 _ensure_reachable(base_url, manage=True) 248 elif not _ollama_is_reachable(base_url): 249 raise _unreachable_error(base_url, manage_hint=True) 250 251 _resolved_base_url = _assert_local_base_url(base_url) 252 return _resolved_base_url 253 254 255def _safe_lock_name(model: str) -> str: 256 return re.sub(r"[^A-Za-z0-9._-]+", "_", model) 257 258 259@contextmanager 260def _pull_lock(model: str) -> Iterator[None]: 261 lock_dir = get_pull_lock_dir() 262 lock_dir.mkdir(parents=True, exist_ok=True) 263 lock_path = lock_dir / f"{_safe_lock_name(model)}.lock" 264 with open(lock_path, "a+", encoding="utf-8") as lock_file: 265 logger.debug("Waiting for pull lock: {}", lock_path) 266 deadline = time.monotonic() + _PULL_LOCK_TIMEOUT_S 267 while True: 268 try: 269 fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) 270 break 271 except BlockingIOError: 272 if time.monotonic() > deadline: 273 raise TimeoutError( 274 f"Timed out waiting for pull lock {lock_path} " 275 f"(remove the lock file if a job crashed mid-pull)" 276 ) 277 logger.info( 278 "Waiting for another job to finish pulling {}…", model 279 ) 280 time.sleep(2.0) 281 try: 282 yield 283 finally: 284 fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) 285 286 287def model_exists(base_url: str, model: str) -> bool: 288 try: 289 response = requests.get(f"{_api_root(base_url)}/api/tags", timeout=30.0) 290 response.raise_for_status() 291 except requests.RequestException as exc: 292 logger.debug("Could not list Ollama models: {}", exc) 293 return False 294 295 for entry in response.json().get("models", []): 296 name = entry.get("name", "") 297 if name == model or name.startswith(f"{model}:"): 298 return True 299 return False 300 301 302def _run_ollama_pull(base_url: str, model: str) -> None: 303 host = _ollama_host_from_base_url(base_url) 304 env = os.environ.copy() 305 env["OLLAMA_HOST"] = f"http://{host}" 306 logger.info("Pulling Ollama model {} (OLLAMA_HOST={})", model, env["OLLAMA_HOST"]) 307 try: 308 subprocess.run( 309 [_ollama_binary(), "pull", model], 310 env=env, 311 check=True, 312 ) 313 except subprocess.CalledProcessError as exc: 314 raise RuntimeError( 315 f"Failed to pull Ollama model {model!r} from {env['OLLAMA_HOST']}. " 316 "If this is a custom GGUF, import it with `ollama create` first, " 317 "then use the tag from `ollama list`." 318 ) from exc 319 320 321def ensure_model_pulled(base_url: str, model: str) -> None: 322 cfg = get_local_llm_config() 323 auto_pull = _env_flag("WUENLP_LOCAL_LLM_AUTO_PULL") 324 if auto_pull is None: 325 auto_pull = cfg.auto_pull 326 if not auto_pull: 327 return 328 329 if model_exists(base_url, model): 330 return 331 332 with _pull_lock(model): 333 if model_exists(base_url, model): 334 return 335 _require_ollama_on_path() 336 _run_ollama_pull(base_url, model) 337 338 339def _warmup_timeout_s() -> float: 340 raw = os.environ.get("WUENLP_LOCAL_LLM_WARMUP_TIMEOUT", "600").strip() 341 try: 342 return float(raw) 343 except ValueError: 344 return 600.0 345 346 347def _warmup_keep_alive() -> str: 348 return os.environ.get("WUENLP_LOCAL_LLM_KEEP_ALIVE", "-1").strip() or "-1" 349 350 351def _warmup_generate(base_url: str, model: str) -> None: 352 api = _api_root(base_url) 353 response = requests.post( 354 f"{api}/api/generate", 355 json={ 356 "model": model, 357 "prompt": "OK", 358 "stream": False, 359 "keep_alive": _warmup_keep_alive(), 360 "options": {"num_predict": 4}, 361 }, 362 timeout=_warmup_timeout_s(), 363 ) 364 response.raise_for_status() 365 366 367def warm_up_model(base_url: str, model: str) -> None: 368 """Load model weights with a tiny generate call (avoids hung first chat request).""" 369 if _env_flag("WUENLP_LOCAL_LLM_WARMUP") is False: 370 return 371 logger.info("Warming up Ollama model {} …", model) 372 last_exc: requests.RequestException | None = None 373 for attempt in range(2): 374 try: 375 _warmup_generate(base_url, model) 376 return 377 except requests.RequestException as exc: 378 last_exc = exc 379 if attempt == 0: 380 logger.warning( 381 "Warm-up attempt 1 failed for {} ({}); restarting Ollama", 382 model, 383 exc, 384 ) 385 base_url = restart_local_ollama() 386 continue 387 logger.warning( 388 "Ollama warm-up failed for {} after retry ({}); continuing without warm-up", 389 model, 390 last_exc, 391 ) 392 393 394def is_model_not_found_error(exc: BaseException) -> bool: 395 message = str(exc).lower() 396 return any( 397 token in message 398 for token in ("not found", "404", "model ''", "does not exist") 399 )
def
restart_local_ollama() -> str:
209def restart_local_ollama() -> str: 210 """Stop the job-local serve process and start a fresh one (after timeouts/hangs).""" 211 global _resolved_base_url 212 _stop_ollama_serve() 213 _resolved_base_url = None 214 return ensure_local_ollama()
Stop the job-local serve process and start a fresh one (after timeouts/hangs).
def
ensure_local_ollama() -> str:
217def ensure_local_ollama() -> str: 218 """Ensure a local Ollama server is reachable; return OpenAI-compatible base URL.""" 219 global _ollama_process, _resolved_base_url 220 221 if _resolved_base_url is not None: 222 return _resolved_base_url 223 224 env_url = os.environ.get("WUENLP_LOCAL_LLM_BASE_URL") 225 if env_url: 226 _resolved_base_url = _assert_local_base_url(env_url) 227 manage = _resolve_manage_process(get_local_llm_config().manage_process) 228 _ensure_reachable(_resolved_base_url, manage=manage) 229 return _resolved_base_url 230 231 cfg = get_local_llm_config() 232 if cfg.base_url: 233 _resolved_base_url = _assert_local_base_url(cfg.base_url) 234 manage = _resolve_manage_process(cfg.manage_process) 235 _ensure_reachable(_resolved_base_url, manage=manage) 236 return _resolved_base_url 237 238 port = _resolve_port(cfg.port) 239 base_url = _base_url_from_port(port) 240 manage = _resolve_manage_process(cfg.manage_process) 241 if cfg.manage_process == "auto" and not _is_slurm(): 242 manage = not _ollama_is_reachable(base_url) 243 244 if manage: 245 if _ollama_is_reachable(base_url): 246 logger.debug("Using existing Ollama at {}", base_url) 247 else: 248 _ensure_reachable(base_url, manage=True) 249 elif not _ollama_is_reachable(base_url): 250 raise _unreachable_error(base_url, manage_hint=True) 251 252 _resolved_base_url = _assert_local_base_url(base_url) 253 return _resolved_base_url
Ensure a local Ollama server is reachable; return OpenAI-compatible base URL.
def
model_exists(base_url: str, model: str) -> bool:
288def model_exists(base_url: str, model: str) -> bool: 289 try: 290 response = requests.get(f"{_api_root(base_url)}/api/tags", timeout=30.0) 291 response.raise_for_status() 292 except requests.RequestException as exc: 293 logger.debug("Could not list Ollama models: {}", exc) 294 return False 295 296 for entry in response.json().get("models", []): 297 name = entry.get("name", "") 298 if name == model or name.startswith(f"{model}:"): 299 return True 300 return False
def
ensure_model_pulled(base_url: str, model: str) -> None:
322def ensure_model_pulled(base_url: str, model: str) -> None: 323 cfg = get_local_llm_config() 324 auto_pull = _env_flag("WUENLP_LOCAL_LLM_AUTO_PULL") 325 if auto_pull is None: 326 auto_pull = cfg.auto_pull 327 if not auto_pull: 328 return 329 330 if model_exists(base_url, model): 331 return 332 333 with _pull_lock(model): 334 if model_exists(base_url, model): 335 return 336 _require_ollama_on_path() 337 _run_ollama_pull(base_url, model)
def
warm_up_model(base_url: str, model: str) -> None:
368def warm_up_model(base_url: str, model: str) -> None: 369 """Load model weights with a tiny generate call (avoids hung first chat request).""" 370 if _env_flag("WUENLP_LOCAL_LLM_WARMUP") is False: 371 return 372 logger.info("Warming up Ollama model {} …", model) 373 last_exc: requests.RequestException | None = None 374 for attempt in range(2): 375 try: 376 _warmup_generate(base_url, model) 377 return 378 except requests.RequestException as exc: 379 last_exc = exc 380 if attempt == 0: 381 logger.warning( 382 "Warm-up attempt 1 failed for {} ({}); restarting Ollama", 383 model, 384 exc, 385 ) 386 base_url = restart_local_ollama() 387 continue 388 logger.warning( 389 "Ollama warm-up failed for {} after retry ({}); continuing without warm-up", 390 model, 391 last_exc, 392 )
Load model weights with a tiny generate call (avoids hung first chat request).
def
is_model_not_found_error(exc: BaseException) -> bool: