Coverage for src / lilbee / app / models.py: 100%

171 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-06-28 01:01 +0000

1"""Surface-agnostic model lifecycle use-cases (list / show / pull / remove).""" 

2 

3from __future__ import annotations 

4 

5from enum import StrEnum 

6from typing import TYPE_CHECKING 

7 

8from pydantic import BaseModel, Field 

9 

10from lilbee.app.services import get_services 

11from lilbee.catalog.types import ModelCompat, ModelTask 

12from lilbee.core.config import cfg 

13from lilbee.modelhub.registry import ModelRegistry 

14 

15if TYPE_CHECKING: 

16 from collections.abc import Callable 

17 

18 from lilbee.catalog import CatalogModel, DownloadProgress 

19 from lilbee.catalog.types import ModelSource 

20 from lilbee.modelhub.model_manager import RemoteModel 

21 from lilbee.modelhub.registry import ModelManifest 

22 

23 

24_BYTES_PER_GB = 1024**3 # Model sizes are reported to users in GiB. 

25_BACKEND_LIST_TIMEOUT_S = 2.0 # Keep `model list` snappy when backend is down. 

26 

27 

28def _bytes_to_gb(n: int) -> float: 

29 """Convert bytes to GiB rounded to 2 decimals for user display.""" 

30 return round(n / _BYTES_PER_GB, 2) 

31 

32 

33class ModelCommand(StrEnum): 

34 """Command field values for model sub-app JSON output.""" 

35 

36 LIST = "model list" 

37 SHOW = "model show" 

38 PULL = "model pull" 

39 RM = "model rm" 

40 

41 

42class PullStatus(StrEnum): 

43 OK = "ok" 

44 ALREADY_INSTALLED = "already_installed" 

45 

46 

47class AdoptStatus(StrEnum): 

48 ADOPTED = "adopted" 

49 ALREADY_ACTIVE = "already_active" 

50 

51 

52class PullEvent(StrEnum): 

53 PROGRESS = "progress" 

54 DONE = "done" 

55 

56 

57class ModelEntry(BaseModel): 

58 """One row of `lilbee model list` output.""" 

59 

60 name: str 

61 source: str 

62 task: ModelTask | None = None 

63 size_gb: float | None = None 

64 display_name: str = "" 

65 

66 @classmethod 

67 def from_native(cls, ref: str, manifest: ModelManifest | None) -> ModelEntry: 

68 # heavy: lilbee.catalog (>50ms; huggingface_hub) + lilbee.modelhub.model_manager (>50ms) 

69 from lilbee.catalog import clean_display_name 

70 from lilbee.catalog.types import ModelSource 

71 

72 return cls( 

73 name=ref, 

74 source=ModelSource.NATIVE.value, 

75 task=manifest.task if manifest else None, 

76 size_gb=_bytes_to_gb(manifest.size_bytes) if manifest else None, 

77 display_name=clean_display_name(manifest.hf_repo) if manifest else "", 

78 ) 

79 

80 @classmethod 

81 def from_backend(cls, ref: str, remote: RemoteModel | None, source: ModelSource) -> ModelEntry: 

82 from lilbee.providers.local_servers import canonical_local_ref 

83 

84 return cls( 

85 name=canonical_local_ref(ref, source.value), 

86 source=source.value, 

87 task=remote.task if remote else None, 

88 size_gb=None, 

89 display_name=remote.parameter_size if remote else "", 

90 ) 

91 

92 

93class ListModelsResult(BaseModel): 

94 command: str = ModelCommand.LIST 

95 models: list[ModelEntry] 

96 total: int 

97 

98 

99class CatalogEntryData(BaseModel): 

100 ref: str 

101 display_name: str 

102 hf_repo: str 

103 gguf_filename: str 

104 size_gb: float 

105 min_ram_gb: float 

106 description: str 

107 task: ModelTask 

108 featured: bool 

109 recommended: bool 

110 architecture: str = "" 

111 compat: ModelCompat = ModelCompat.UNKNOWN 

112 

113 @classmethod 

114 def from_catalog_model(cls, entry: CatalogModel) -> CatalogEntryData: 

115 return cls( 

116 ref=entry.ref, 

117 display_name=entry.display_name, 

118 hf_repo=entry.hf_repo, 

119 gguf_filename=entry.gguf_filename, 

120 size_gb=entry.size_gb, 

121 min_ram_gb=entry.min_ram_gb, 

122 description=entry.description, 

123 task=entry.task, 

124 featured=entry.featured, 

125 recommended=entry.recommended, 

126 architecture=entry.architecture, 

127 compat=entry.compat, 

128 ) 

129 

130 

131class ManifestData(BaseModel): 

132 ref: str 

133 display_name: str 

134 task: ModelTask 

135 size_gb: float 

136 size_bytes: int 

137 hf_repo: str 

138 gguf_filename: str 

139 downloaded_at: str 

140 

141 @classmethod 

142 def from_manifest(cls, manifest: ModelManifest) -> ManifestData: 

143 from lilbee.catalog import clean_display_name 

144 

145 return cls( 

146 ref=manifest.ref, 

147 display_name=clean_display_name(manifest.hf_repo), 

148 task=manifest.task, 

149 size_gb=_bytes_to_gb(manifest.size_bytes), 

150 size_bytes=manifest.size_bytes, 

151 hf_repo=manifest.hf_repo, 

152 gguf_filename=manifest.gguf_filename, 

153 downloaded_at=manifest.downloaded_at, 

154 ) 

155 

156 

157class ShowModelResult(BaseModel): 

158 command: str = ModelCommand.SHOW 

159 model: str 

160 catalog: CatalogEntryData | None = None 

161 installed: bool = False 

162 source: str | None = None 

163 path: str | None = None 

164 manifest: ManifestData | None = None 

165 

166 

167class PullResult(BaseModel): 

168 command: str = ModelCommand.PULL 

169 model: str 

170 source: str 

171 status: PullStatus 

172 path: str | None = None 

173 

174 

175class PullProgressEvent(BaseModel): 

176 command: str = ModelCommand.PULL 

177 event: str = PullEvent.PROGRESS 

178 model: str 

179 percent: float 

180 detail: str 

181 cache_hit: bool 

182 

183 

184class RemoveResult(BaseModel): 

185 command: str = ModelCommand.RM 

186 model: str 

187 deleted: bool 

188 freed_gb: float = Field(default=0.0) 

189 

190 

191class AdoptResult(BaseModel): 

192 """Outcome of adopting a downloaded index's embedder.""" 

193 

194 model: str 

195 status: AdoptStatus 

196 reindex_required: bool = False 

197 

198 

199def adopt_embedder(ref: str) -> AdoptResult: 

200 """Switch lilbee to embedder *ref*, downloading it first if missing. 

201 

202 Makes a downloaded index searchable under its own embedder without a 

203 rebuild: the persisted vectors already match *ref*, so the switch routes 

204 through the settings boundary and ``reindex_required`` stays false. 

205 """ 

206 from lilbee.app.settings import apply_settings_update 

207 from lilbee.catalog.types import ModelSource 

208 

209 manager = get_services().model_manager 

210 already_active = cfg.embedding_model == ref and manager.is_installed(ref, ModelSource.NATIVE) 

211 if not manager.is_installed(ref, ModelSource.NATIVE): 

212 pull_model_data(ref, ModelSource.NATIVE) 

213 result = apply_settings_update({"embedding_model": ref}) 

214 return AdoptResult( 

215 model=ref, 

216 status=AdoptStatus.ALREADY_ACTIVE if already_active else AdoptStatus.ADOPTED, 

217 reindex_required=result.reindex_required, 

218 ) 

219 

220 

221def _native_manifest_index() -> dict[str, ModelManifest]: 

222 """Map ref string ('hf_repo/filename') to manifest for every installed native model.""" 

223 registry = ModelRegistry(cfg.models_dir) 

224 return {m.ref: m for m in registry.list_installed()} 

225 

226 

227def _resolve_native_path(ref: str) -> str | None: 

228 """Return the on-disk path of an installed native model, if resolvable. 

229 

230 Swallows ``KeyError`` (manifest present but blob missing) and 

231 ``ValueError`` (malformed ref) so callers can treat the path as 

232 optional metadata. 

233 """ 

234 try: 

235 return str(ModelRegistry(cfg.models_dir).resolve(ref)) 

236 except (KeyError, ValueError): 

237 return None 

238 

239 

240def _collect_native_entries() -> list[ModelEntry]: 

241 # heavy: lilbee.modelhub.model_manager (>50ms; huggingface_hub fanout) 

242 from lilbee.catalog.types import ModelSource 

243 

244 manifests = _native_manifest_index() 

245 refs = get_services().model_manager.list_installed(source=ModelSource.NATIVE) 

246 return [ModelEntry.from_native(ref, manifests.get(ref)) for ref in refs] 

247 

248 

249def _collect_backend_entries() -> list[ModelEntry]: 

250 # heavy: lilbee.modelhub.model_manager (>50ms; huggingface_hub fanout) 

251 from lilbee.catalog.types import ModelSource 

252 from lilbee.modelhub.model_manager import classify_all_remote_models 

253 from lilbee.providers.local_servers import local_server_for_label 

254 

255 def _source(remote: RemoteModel) -> ModelSource: 

256 spec = local_server_for_label(remote.provider) 

257 return ModelSource(spec.key) if spec is not None else ModelSource.REMOTE 

258 

259 remote_by_name = { 

260 rm.name: rm for rm in classify_all_remote_models(timeout=_BACKEND_LIST_TIMEOUT_S) 

261 } 

262 return [ 

263 ModelEntry.from_backend(name, rm, _source(rm)) 

264 for name, rm in sorted(remote_by_name.items()) 

265 ] 

266 

267 

268def list_models_data( 

269 source: ModelSource | None = None, 

270 task: ModelTask | None = None, 

271) -> ListModelsResult: 

272 """Build the list of installed models with source and task metadata. 

273 

274 Discovers remote models via a single HTTP call with a short timeout 

275 so the command stays responsive when the backend is down. 

276 """ 

277 # heavy: lilbee.modelhub.model_manager (>50ms; huggingface_hub fanout) 

278 from lilbee.catalog.types import ModelSource 

279 

280 entries: list[ModelEntry] = [] 

281 if source is None or source is ModelSource.NATIVE: 

282 entries.extend(_collect_native_entries()) 

283 if source is not ModelSource.NATIVE: 

284 backend = _collect_backend_entries() 

285 # A specific local-server source (ollama/lm_studio/frontier) narrows the 

286 # backend list; REMOTE and None keep every backend entry. 

287 if source is not None and source is not ModelSource.REMOTE: 

288 backend = [e for e in backend if e.source == source.value] 

289 entries.extend(backend) 

290 if task: 

291 entries = [e for e in entries if e.task == task] 

292 return ListModelsResult(models=entries, total=len(entries)) 

293 

294 

295def show_model_data(ref: str) -> ShowModelResult: 

296 """Return catalog and install metadata for *ref*. 

297 

298 Raises :class:`~lilbee.modelhub.model_manager.ModelNotFoundError` if the ref 

299 is unknown to both the catalog and the installed set. 

300 """ 

301 # heavy: lilbee.catalog (>50ms; huggingface_hub) + lilbee.modelhub.model_manager (>50ms) 

302 from lilbee.catalog import find_catalog_entry 

303 from lilbee.modelhub.model_manager import ModelNotFoundError 

304 

305 entry = find_catalog_entry(ref) 

306 source = get_services().model_manager.get_source(ref) 

307 if entry is None and source is None: 

308 raise ModelNotFoundError(f"model not found: {ref}") 

309 manifest = _native_manifest_index().get(ref) 

310 return ShowModelResult( 

311 model=ref, 

312 catalog=CatalogEntryData.from_catalog_model(entry) if entry else None, 

313 installed=source is not None, 

314 source=source.value if source else None, 

315 manifest=ManifestData.from_manifest(manifest) if manifest else None, 

316 path=_resolve_native_path(ref) if manifest is not None else None, 

317 ) 

318 

319 

320def pull_model_data( 

321 ref: str, 

322 source: ModelSource, 

323 *, 

324 on_update: Callable[[DownloadProgress], None] | None = None, 

325 allow_unsupported: bool = False, 

326) -> PullResult: 

327 """Pull *ref* from *source* and return a typed result. 

328 

329 Only native models are downloadable; a non-native *source* is refused by 

330 :meth:`ModelManager.pull`. Progress updates are throttled by 

331 :func:`~lilbee.catalog.make_download_callback`, so callers see at most 

332 roughly 10 Hz of progress events. 

333 """ 

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

335 from lilbee.catalog import make_download_callback 

336 

337 manager = get_services().model_manager 

338 

339 if manager.is_installed(ref, source): 

340 return PullResult(model=ref, source=source.value, status=PullStatus.ALREADY_INSTALLED) 

341 

342 bytes_cb = make_download_callback(on_update) if on_update is not None else None 

343 path = manager.pull( 

344 ref, 

345 source, 

346 on_bytes=bytes_cb, 

347 allow_unsupported=allow_unsupported, 

348 ) 

349 return PullResult( 

350 model=ref, 

351 source=source.value, 

352 status=PullStatus.OK, 

353 path=str(path) if path is not None else None, 

354 ) 

355 

356 

357def remove_model_data( 

358 ref: str, 

359 source: ModelSource | None = None, 

360) -> RemoveResult: 

361 """Remove *ref* and return a typed result with freed size.""" 

362 manager = get_services().model_manager 

363 manifests = _native_manifest_index() 

364 size_bytes = manifests[ref].size_bytes if ref in manifests else 0 

365 removed = manager.remove(ref, source=source) 

366 return RemoveResult( 

367 model=ref, 

368 deleted=removed, 

369 freed_gb=_bytes_to_gb(size_bytes), 

370 )