Coverage for src/lilbee/modelhub/model_manager/core.py: 100%

167 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-31 21:55 +0000

1"""ModelManager: native and SDK-backed model lifecycle operations.""" 

2 

3import logging 

4import time 

5from collections.abc import Callable 

6from dataclasses import replace 

7from pathlib import Path 

8 

9from lilbee.catalog.compat import UnsupportedQuantError 

10from lilbee.catalog.models import CatalogModel 

11from lilbee.catalog.types import ModelSource 

12from lilbee.core.config import DEFAULT_HTTP_TIMEOUT 

13from lilbee.core.security import validate_path_within 

14from lilbee.modelhub.model_manager.types import ModelNotFoundError 

15from lilbee.modelhub.registry import ModelRegistry 

16from lilbee.providers.local_servers import LOCAL_SERVERS, local_server_for_key 

17from lilbee.providers.model_ref import parse_model_ref 

18from lilbee.runtime.cancellation import CancelSignal 

19 

20log = logging.getLogger(__name__) 

21 

22_INSTALLED_CACHE_TTL_SECONDS = 30.0 

23 

24 

25def _prefixed_source(model: str) -> ModelSource | None: 

26 """Map a provider-prefixed ref to its source, or ``None`` for a bare ref. 

27 

28 Local-server prefixes (``ollama/``, ``lm_studio/``) map to that server's 

29 source; API-provider prefixes are FRONTIER. A bare name returns ``None`` 

30 so the caller falls back to backend membership. 

31 """ 

32 for spec in LOCAL_SERVERS: 

33 if model.startswith(spec.wire_prefix): 

34 return ModelSource(spec.key) 

35 try: 

36 ref = parse_model_ref(model) 

37 except ValueError: 

38 return None 

39 return ModelSource.FRONTIER if ref.is_api else None 

40 

41 

42class ModelManager: 

43 """Manages model lifecycle with distinct sources.""" 

44 

45 def __init__(self, models_dir: Path) -> None: 

46 self._models_dir = models_dir 

47 self._registry = ModelRegistry(self._models_dir) 

48 # Memoize list_installed results to avoid walking the registry 

49 # filesystem and hitting the backend HTTP endpoint on every call. 

50 # The catalog filter path fires this per request. Time-based TTL 

51 # plus explicit invalidation on pull/remove keeps freshness. 

52 self._installed_cache: dict[ModelSource | None, tuple[float, list[str]]] = {} 

53 # Identity cache: refs + hf_repos of installed natives. The catalog 

54 # screen reads this to mark rows as installed without re-walking 

55 # the registry on every screen mount (~150-300 ms saved). 

56 self._native_identities_cache: tuple[float, frozenset[str]] | None = None 

57 

58 def list_installed(self, source: ModelSource | None = None) -> list[str]: 

59 """List installed model names. ``source=None`` lists all sources. 

60 

61 Memoized with a ``_INSTALLED_CACHE_TTL_SECONDS`` TTL and 

62 invalidated eagerly by ``pull``/``remove``. 

63 """ 

64 now = time.monotonic() 

65 cached = self._installed_cache.get(source) 

66 if cached is not None: 

67 cached_at, cached_result = cached 

68 if now - cached_at < _INSTALLED_CACHE_TTL_SECONDS: 

69 return cached_result 

70 

71 if source is None: 

72 native = set(self._list_native()) 

73 remote = set(self._list_remote()) 

74 result = sorted(native | remote) 

75 elif source is ModelSource.NATIVE: 

76 result = self._list_native() 

77 else: 

78 result = self._list_remote() 

79 

80 self._installed_cache[source] = (now, result) 

81 return result 

82 

83 def list_native_identities(self) -> frozenset[str]: 

84 """Return refs + hf_repos of installed native models. 

85 

86 Same TTL as ``list_installed``. The catalog screen reads this to 

87 mark catalog rows as installed without re-walking the registry 

88 on every screen mount. 

89 """ 

90 now = time.monotonic() 

91 if self._native_identities_cache is not None: 

92 cached_at, cached_result = self._native_identities_cache 

93 if now - cached_at < _INSTALLED_CACHE_TTL_SECONDS: 

94 return cached_result 

95 identities: set[str] = set() 

96 try: 

97 for m in self._registry.list_installed(): 

98 identities.add(m.ref) 

99 identities.add(m.hf_repo) 

100 except Exception: 

101 log.debug("ModelRegistry.list_installed failed", exc_info=True) 

102 result = frozenset(identities) 

103 self._native_identities_cache = (now, result) 

104 return result 

105 

106 def _invalidate_installed_cache(self) -> None: 

107 """Drop all cached list_installed results and the route-layer cache.""" 

108 self._installed_cache.clear() 

109 self._native_identities_cache = None 

110 from lilbee.app.services import peek_services 

111 

112 # peek_services is None for a standalone ModelManager (test setup); 

113 # the route isn't running so there's nothing to invalidate. 

114 services = peek_services() 

115 if services is not None: 

116 services.known_models.invalidate() 

117 

118 def _list_native(self) -> list[str]: 

119 """List native models from the registry only.""" 

120 return sorted(m.ref for m in self._registry.list_installed()) 

121 

122 def _list_remote(self) -> list[str]: 

123 """List model names across every configured local server (Ollama, LM Studio). 

124 

125 Reuses the discovery dispatch so each listing endpoint matches its 

126 server (Ollama ``/api/tags`` vs LM Studio ``/v1/models``). Returns 

127 ``[]`` when the backends are unreachable. 

128 """ 

129 # circular: discovery -> app.services -> model_manager.__init__ -> core 

130 from lilbee.modelhub.model_manager.discovery import classify_all_remote_models 

131 

132 models = classify_all_remote_models(timeout=DEFAULT_HTTP_TIMEOUT) 

133 return [m.name for m in models] 

134 

135 def is_installed(self, model: str, source: ModelSource | None = None) -> bool: 

136 """Check if model exists in specified source.""" 

137 if source is None: 

138 return self._is_native(model) or self._is_remote(model) 

139 if source is ModelSource.NATIVE: 

140 return self._is_native(model) 

141 return self._is_remote(model) 

142 

143 def _is_native(self, model: str) -> bool: 

144 if self._registry.is_installed(model): 

145 return True 

146 try: 

147 validate_path_within(self._models_dir / model, self._models_dir) 

148 except ValueError: 

149 return False 

150 return (self._models_dir / model).is_file() 

151 

152 def _is_remote(self, model: str) -> bool: 

153 return model in self.list_installed(ModelSource.REMOTE) 

154 

155 def get_source(self, model: str) -> ModelSource | None: 

156 """Return the granular source a model lives in. Native takes precedence. 

157 

158 A provider-prefixed ref classifies without a network call; a bare name 

159 that a backend reports installed is ``REMOTE`` (the prefix is what names 

160 the specific server). ``None`` when the model is in no known source. 

161 """ 

162 if self._is_native(model): 

163 return ModelSource.NATIVE 

164 prefixed = _prefixed_source(model) 

165 if prefixed is not None: 

166 return prefixed 

167 if self._is_remote(model): 

168 return ModelSource.REMOTE 

169 return None 

170 

171 def pull( 

172 self, 

173 model: str, 

174 source: ModelSource, 

175 *, 

176 on_bytes: Callable[[int, int], None] | None = None, 

177 allow_unsupported: bool = False, 

178 cancel: CancelSignal | None = None, 

179 ) -> Path | None: 

180 """Download a native GGUF model and return its path. 

181 

182 lilbee pulls native models only. Local servers (Ollama, LM Studio) 

183 are read-only: their models are managed in their own app and surface 

184 here once present, so a non-native *source* is refused. 

185 

186 Native pulls of architectures the bundled llama.cpp doesn't support 

187 are refused with ``UnsupportedArchError``, and files whose tensors it 

188 cannot decode with ``UnsupportedQuantError``, unless *allow_unsupported* 

189 is True. *on_bytes* receives (downloaded_bytes, total_bytes) progress. 

190 A *cancel* signal makes the download cancellable mid-transfer; see 

191 :func:`~lilbee.catalog.download_model`. 

192 """ 

193 if source is not ModelSource.NATIVE: 

194 spec = local_server_for_key(source.value) 

195 where = spec.display_name if spec is not None else "the configured server" 

196 raise ValueError( 

197 f"lilbee runs {where} models but doesn't download them. " 

198 f"Add the model in {where}, then pick it here." 

199 ) 

200 if not allow_unsupported: 

201 self.enforce_arch_compat(model) 

202 try: 

203 return self._pull_native( 

204 model, on_bytes=on_bytes, cancel=cancel, allow_unsupported=allow_unsupported 

205 ) 

206 finally: 

207 self._invalidate_installed_cache() 

208 

209 def enforce_arch_compat(self, ref: str) -> None: 

210 """Raise UnsupportedArchError if *ref*'s architecture isn't in the supported set. 

211 

212 Public because the pull preflight on the HTTP surface runs the same 

213 check before starting a download, so a caller learns the model is 

214 unsupported before any bytes move. 

215 """ 

216 from lilbee.app.services import get_services 

217 from lilbee.catalog.compat import ( 

218 ModelCompat, 

219 UnsupportedArchError, 

220 classify, 

221 resolve_arch_for_pull, 

222 ) 

223 

224 arch = resolve_arch_for_pull(ref, get_services().hf_client) 

225 if classify(arch) is ModelCompat.UNSUPPORTED: 

226 raise UnsupportedArchError(ref, arch) 

227 

228 def _pull_native( 

229 self, 

230 model: str, 

231 *, 

232 on_bytes: Callable[[int, int], None] | None = None, 

233 cancel: CancelSignal | None = None, 

234 allow_unsupported: bool = False, 

235 ) -> Path: 

236 """Download a featured or ad-hoc HuggingFace model to the native GGUF directory.""" 

237 # heavy: lilbee.catalog (>50ms; huggingface_hub fanout) 

238 from lilbee.catalog import download_model, resolve_pull_target 

239 from lilbee.modelhub.registry import register_downloaded_model 

240 

241 entry = resolve_pull_target(model) 

242 if entry is None: 

243 raise ModelNotFoundError( 

244 f"Model '{model}' not recognized. " 

245 "Pass a HuggingFace repo id (owner/name) or a featured model name." 

246 ) 

247 entry = self._resolved_entry(entry, verify=not allow_unsupported) 

248 path = download_model( 

249 entry, on_progress=on_bytes, on_complete=register_downloaded_model, cancel=cancel 

250 ) 

251 log.info("Downloaded %s to %s", model, path) 

252 return path 

253 

254 @staticmethod 

255 def _resolved_entry(entry: CatalogModel, *, verify: bool) -> CatalogModel: 

256 """*entry* carrying the file the pull will fetch. 

257 

258 Resolution happens here because the engine's verdict is part of choosing 

259 the file, not a check on the file already chosen. A repo can publish the 

260 same weights in several packings, only some of which this build reads, 

261 and the resolver walks its ranking until one of them answers. Passing the 

262 resolved name down means the download does not work it out again. 

263 

264 A repo that will not resolve is not this check's verdict to deliver. The 

265 download reports the gated repo or the missing file properly, so a 

266 resolution failure here is left to it. 

267 """ 

268 # heavy: lilbee.catalog (>50ms; huggingface_hub fanout) 

269 from lilbee.catalog.download import resolve_filename 

270 from lilbee.catalog.hf_client import hf_token 

271 from lilbee.providers.fleet.loadability import assert_engine_can_load 

272 

273 token = hf_token() 

274 

275 def _can_load(hf_repo: str, filename: str) -> None: 

276 assert_engine_can_load(hf_repo, filename, token) 

277 

278 try: 

279 filename = resolve_filename(entry, can_load=_can_load if verify else None) 

280 except (PermissionError, RuntimeError) as exc: 

281 if isinstance(exc, UnsupportedQuantError): 

282 raise 

283 log.debug("Cannot name a file to fetch for %s: %s", entry.hf_repo, exc) 

284 return entry 

285 return replace(entry, gguf_filename=filename) 

286 

287 def remove(self, model: str, source: ModelSource | None = None) -> bool: 

288 """Remove an installed native model. Returns True if removed. 

289 

290 lilbee removes only native GGUF models it downloaded. Local servers 

291 (Ollama, LM Studio) are read-only: a model that lives on one is refused 

292 (mirrors ``pull``), since its lifecycle is managed in that app. A bare 

293 ``source`` is resolved so a local-server ref is caught either way. 

294 """ 

295 effective = source if source is not None else self.get_source(model) 

296 if effective is not None and effective is not ModelSource.NATIVE: 

297 spec = local_server_for_key(effective.value) 

298 where = spec.display_name if spec is not None else "the configured server" 

299 raise ValueError( 

300 f"lilbee runs {where} models but doesn't remove them. " 

301 f"Manage them in {where} instead." 

302 ) 

303 try: 

304 return self._remove_native(model) 

305 finally: 

306 self._invalidate_installed_cache() 

307 

308 def _remove_native(self, model: str) -> bool: 

309 if self._registry.remove(model): 

310 log.info("Removed native model %s from registry", model) 

311 return True 

312 try: 

313 path = validate_path_within(self._models_dir / model, self._models_dir) 

314 except ValueError: 

315 log.warning("Path traversal blocked: %s escapes %s", model, self._models_dir) 

316 return False 

317 if path.is_file(): 

318 path.unlink() 

319 log.info("Removed native model %s", model) 

320 return True 

321 return False