Coverage for src/lilbee/server/handlers/models.py: 100%

239 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Model catalog, role assignment, install/delete, and external listing handlers.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import logging 

7from collections.abc import AsyncGenerator 

8from typing import TYPE_CHECKING, Literal 

9 

10from cachetools import TTLCache 

11from pydantic import BaseModel 

12 

13from lilbee.app.models import list_models_data 

14from lilbee.app.services import get_services 

15from lilbee.app.settings import apply_settings_update 

16from lilbee.catalog import ( 

17 CatalogResult, 

18 ModelFamily, 

19 enrich_catalog, 

20 get_catalog, 

21 get_families, 

22 get_picks, 

23 page_window, 

24 picks_for, 

25) 

26from lilbee.catalog.refs import hf_repo_from_ref, is_bare_hf_repo 

27from lilbee.catalog.types import CatalogSize, CatalogSort, KeyStatus, ModelSource, ModelTask 

28from lilbee.core.config import cfg 

29from lilbee.modelhub.model_manager import classify_all_remote_models, discover_api_models 

30from lilbee.modelhub.model_manager.types import RemoteModel 

31from lilbee.modelhub.role_validator import MODEL_FIELD_TO_TASK, validate_model_task_assignment 

32from lilbee.providers.key_check import provider_key_status 

33from lilbee.providers.local_servers import local_server_for_label 

34from lilbee.providers.model_ref import format_remote_ref, parse_model_ref 

35from lilbee.providers.sdk_backend import PROVIDER_KEYS 

36from lilbee.runtime.cancellation import TaskCancelledError 

37from lilbee.runtime.hardware import ( 

38 FitLevel, 

39 SizeVariantInfo, 

40 available_memory_for_fit, 

41 family_size_variants, 

42 fit_for_size, 

43 make_fit_filter, 

44) 

45from lilbee.runtime.progress import SseEvent 

46from lilbee.server.handlers.sse import SseStream, sse_error, sse_event 

47from lilbee.server.models import ( 

48 CatalogEntryResponse, 

49 ExternalModelsResponse, 

50 ModelsCatalogResponse, 

51 ModelsDeleteResponse, 

52 ModelsInstalledResponse, 

53 ModelsShowResponse, 

54 SetModelResponse, 

55) 

56 

57if TYPE_CHECKING: 

58 from lilbee.catalog import CatalogModel 

59 from lilbee.catalog.formatting import EnrichedModel 

60 

61log = logging.getLogger(__name__) 

62 

63 

64class ModelCatalogEntry(BaseModel): 

65 """A single model in the catalog.""" 

66 

67 name: str 

68 size_gb: float 

69 min_ram_gb: float 

70 description: str 

71 installed: bool 

72 

73 

74class ModelCatalogSection(BaseModel): 

75 """A single-role catalog section with active model and installed list.""" 

76 

77 active: str 

78 catalog: list[ModelCatalogEntry] 

79 installed: list[str] 

80 

81 

82class ModelsResponse(BaseModel): 

83 """Response for GET /api/models: one catalog section per role.""" 

84 

85 chat: ModelCatalogSection 

86 embedding: ModelCatalogSection 

87 vision: ModelCatalogSection 

88 reranker: ModelCatalogSection 

89 

90 

91# ``ModelTask.RERANK.value`` is ``"rerank"`` but the route is ``/api/models/reranker``, 

92# so this mapping is needed to build correct redirect URLs in 422 responses. 

93TASK_ENDPOINT_PATH: dict[ModelTask, str] = { 

94 ModelTask.CHAT: "chat", 

95 ModelTask.EMBEDDING: "embedding", 

96 ModelTask.VISION: "vision", 

97 ModelTask.RERANK: "reranker", 

98} 

99 

100 

101def format_task_mismatch(ref: str, entry_task: ModelTask, expected_task: ModelTask) -> str: 

102 """Build the 422 body when a role slot is assigned a model of the wrong task.""" 

103 endpoint = TASK_ENDPOINT_PATH[entry_task] 

104 return ( 

105 f"Model '{ref}' is a {entry_task} model, not {expected_task}. " 

106 f"Set it via PUT /api/models/{endpoint} instead." 

107 ) 

108 

109 

110def _catalog_section( 

111 featured: tuple[CatalogModel, ...], 

112 active: str, 

113 installed: set[str], 

114) -> ModelCatalogSection: 

115 """Build a ModelCatalogSection from a featured-catalog tuple. 

116 

117 A featured row is "installed" when at least one quant of its 

118 ``hf_repo`` has a manifest. Installed refs are full 

119 ``hf_repo/filename`` strings, so the membership test compares the 

120 leading ``hf_repo`` segment. Bare ``hf_repo`` entries are accepted 

121 too (e.g. older clients that report just the repo). 

122 """ 

123 installed_repos = {hf_repo_from_ref(ref) for ref in installed} 

124 return ModelCatalogSection( 

125 active=active, 

126 catalog=[ 

127 ModelCatalogEntry( 

128 name=m.display_name, 

129 size_gb=m.size_gb, 

130 min_ram_gb=m.min_ram_gb, 

131 description=m.description, 

132 installed=m.hf_repo in installed_repos, 

133 ) 

134 for m in featured 

135 ], 

136 installed=sorted(installed), 

137 ) 

138 

139 

140async def list_models() -> ModelsResponse: 

141 """Return per-role catalogs (chat, embedding, vision, reranker) with active selections. 

142 

143 Uses the unfiltered installed set so a single ref lights up in every 

144 catalog section it legitimately matches. 

145 """ 

146 # list_installed walks the model filesystem; offload it like list_external_models. 

147 installed = set(await asyncio.to_thread(get_services().model_manager.list_installed)) 

148 # Resolving the picks hits HuggingFace on the first call of the process, so 

149 # offload it too rather than stalling the event loop. The four picks_for 

150 # reads below are served from the memo this fills. 

151 await asyncio.to_thread(get_picks) 

152 

153 return ModelsResponse( 

154 chat=_catalog_section(picks_for(ModelTask.CHAT), cfg.chat_model, installed), 

155 embedding=_catalog_section(picks_for(ModelTask.EMBEDDING), cfg.embedding_model, installed), 

156 vision=_catalog_section(picks_for(ModelTask.VISION), cfg.vision_model, installed), 

157 reranker=_catalog_section(picks_for(ModelTask.RERANK), cfg.reranker_model, installed), 

158 ) 

159 

160 

161async def _set_model( 

162 field: Literal["chat_model", "embedding_model", "vision_model", "reranker_model"], 

163 model: str, 

164) -> SetModelResponse: 

165 """Persist a model field through the shared write boundary.""" 

166 result = await asyncio.to_thread(apply_settings_update, {field: model}) 

167 return SetModelResponse(model=model, warnings=list(result.warnings)) 

168 

169 

170def _resolve_via_available_repo(model: str, available: set[str]) -> str | None: 

171 """Resolve a bare ``hf_repo`` to whichever quant of it *available* lists. 

172 

173 Sorted scan so the pick is deterministic when several quants are installed. 

174 """ 

175 if not is_bare_hf_repo(model): 

176 return None 

177 return next((ref for ref in sorted(available) if ref.startswith(f"{model}/")), None) 

178 

179 

180def _resolve_via_parse(model: str, available: set[str]) -> str | None: 

181 """Resolve a provider-prefixed ref against *available*. 

182 

183 The backend lists hosted models under bare names while selections carry the 

184 routing prefix. When the bare name is visible, return the prefixed ref so it 

185 keeps provider routing instead of falling through to the native check. 

186 """ 

187 try: 

188 parsed = parse_model_ref(model) 

189 except ValueError: 

190 return None 

191 return model if parsed.name in available else None 

192 

193 

194def _resolve_via_provider_key(model: str) -> str | None: 

195 """Accept an API-provider-prefixed ref when that provider's key is set and not rejected. 

196 

197 Frontier models surface through ``discover_api_models``, not the default 

198 ``list_models()``, so they never appear in *available*. With a usable key, 

199 the SDK routes the ref (and validates the model name at call time). 

200 """ 

201 try: 

202 parsed = parse_model_ref(model) 

203 except ValueError: 

204 return None 

205 if parsed.is_api and provider_key_status(parsed.provider) is KeyStatus.READY: 

206 return model 

207 return None 

208 

209 

210def _require_model_available(model: str) -> str: 

211 """Return the installed-and-routable form of *model*, or raise.""" 

212 not_available = ValueError( 

213 f"Model '{model}' is not available. Pull it first or check the name." 

214 ) 

215 if not model: 

216 raise not_available 

217 available = set(get_services().provider.list_models()) 

218 if model in available: 

219 return model 

220 hit = ( 

221 _resolve_via_available_repo(model, available) 

222 or _resolve_via_parse(model, available) 

223 or _resolve_via_provider_key(model) 

224 ) 

225 if hit is None: 

226 raise not_available 

227 return hit 

228 

229 

230def _build_task_to_field() -> dict[ModelTask, str]: 

231 """Invert ``MODEL_FIELD_TO_TASK`` so the two maps stay in sync.""" 

232 return {ModelTask(task): field for field, task in MODEL_FIELD_TO_TASK.items()} 

233 

234 

235_TASK_TO_FIELD: dict[ModelTask, str] = _build_task_to_field() 

236 

237 

238def _require_model_for_task(model: str, expected: ModelTask, *, allow_empty: bool = False) -> str: 

239 """Validate *model* is installed locally AND passes the catalog task check. 

240 

241 Empty string unsets the role when *allow_empty* is True. Catalog + 

242 task validation delegates to ``validate_model_task_assignment`` so 

243 the handler and config paths share a single implementation. 

244 """ 

245 if allow_empty and not model.strip(): 

246 return "" 

247 normalized = _require_model_available(model) 

248 return validate_model_task_assignment(_TASK_TO_FIELD[expected], normalized, allow_bypass=False) 

249 

250 

251async def set_chat_model(model: str) -> SetModelResponse: 

252 """Switch active chat model. Validates installation and catalog task.""" 

253 normalized = await asyncio.to_thread(_require_model_for_task, model, ModelTask.CHAT) 

254 return await _set_model("chat_model", normalized) 

255 

256 

257async def set_embedding_model(model: str) -> SetModelResponse: 

258 """Switch embedding model. Validates installation and catalog task. 

259 

260 Returns ``reindex_required=True`` when the new model differs from the 

261 embedding model that built the persisted vector store. The caller is 

262 expected to trigger a rebuild (``lilbee rebuild`` or ``POST /api/sync`` 

263 with ``force_rebuild=true``). Search and ingest will refuse to operate 

264 until that happens. The settings boundary pins legacy store meta to 

265 the OLD ref before the write and computes ``reindex_required`` after. 

266 """ 

267 normalized = await asyncio.to_thread(_require_model_for_task, model, ModelTask.EMBEDDING) 

268 result = await asyncio.to_thread(apply_settings_update, {"embedding_model": normalized}) 

269 return SetModelResponse(model=normalized, reindex_required=result.reindex_required) 

270 

271 

272async def set_vision_model(model: str) -> SetModelResponse: 

273 """Switch vision OCR model. Empty string unsets it (vision OCR disabled).""" 

274 normalized = await asyncio.to_thread( 

275 _require_model_for_task, model, ModelTask.VISION, allow_empty=True 

276 ) 

277 return await _set_model("vision_model", normalized) 

278 

279 

280async def set_reranker_model(model: str) -> SetModelResponse: 

281 """Switch reranker model. Empty string unsets it (reranking disabled).""" 

282 normalized = await asyncio.to_thread( 

283 _require_model_for_task, model, ModelTask.RERANK, allow_empty=True 

284 ) 

285 return await _set_model("reranker_model", normalized) 

286 

287 

288async def models_show(model: str) -> ModelsShowResponse: 

289 """Return model metadata/parameters. Returns empty model if unavailable.""" 

290 provider = get_services().provider 

291 # show_model dispatches to the SDK backend, which does a network call to the 

292 # local server; offload so a hung backend doesn't block the event loop. 

293 result = await asyncio.to_thread(provider.show_model, model) 

294 return ModelsShowResponse(**(result or {})) 

295 

296 

297def _parse_source(source: str) -> ModelSource: 

298 """Convert a source string to ModelSource enum.""" 

299 return ModelSource(source) 

300 

301 

302def _row_fit(enriched: EnrichedModel, available_bytes: int | None) -> FitLevel | None: 

303 """Fit level for *enriched*, or None when host memory or row size can't be measured.""" 

304 if enriched.source != ModelSource.NATIVE.value: 

305 return None 

306 return fit_for_size(enriched.size_gb, available_bytes) 

307 

308 

309def _families_by_repo() -> dict[str, ModelFamily]: 

310 """Index featured ModelFamilies by every variant's ``hf_repo`` for size-variant lookup.""" 

311 index: dict[str, ModelFamily] = {} 

312 for family in get_families(): 

313 for variant in family.variants: 

314 index[variant.hf_repo] = family 

315 return index 

316 

317 

318def _row_size_variants( 

319 enriched: EnrichedModel, families_by_repo: dict[str, ModelFamily] 

320) -> list[SizeVariantInfo]: 

321 """Size-variant strip for *enriched*; empty when the row isn't part of a family.""" 

322 family = families_by_repo.get(enriched.hf_repo) 

323 if family is None: 

324 return [] 

325 return family_size_variants(family) 

326 

327 

328def _build_catalog_entry( 

329 enriched: EnrichedModel, 

330 *, 

331 available_bytes: int | None, 

332 families_by_repo: dict[str, ModelFamily], 

333) -> CatalogEntryResponse: 

334 """Translate one enriched catalog model into its HTTP response row.""" 

335 return CatalogEntryResponse( 

336 hf_repo=enriched.hf_repo, 

337 gguf_filename=enriched.gguf_filename, 

338 task=enriched.task, 

339 display_name=enriched.display_name, 

340 param_count=enriched.param_count, 

341 size_gb=enriched.size_gb, 

342 min_ram_gb=enriched.min_ram_gb, 

343 description=enriched.description, 

344 quality_tier=enriched.quality_tier, 

345 featured=enriched.featured, 

346 downloads=enriched.downloads, 

347 installed=enriched.installed, 

348 source=enriched.source, 

349 fit=_row_fit(enriched, available_bytes), 

350 size_variants=_row_size_variants(enriched, families_by_repo), 

351 architecture=enriched.architecture, 

352 compat=enriched.compat, 

353 safety_stripped=enriched.safety_stripped, 

354 ) 

355 

356 

357def _hosted_entry(rm: RemoteModel, source: ModelSource) -> CatalogEntryResponse: 

358 """Build a selectable, no-download catalog row for a discovered hosted model.""" 

359 return CatalogEntryResponse( 

360 hf_repo=format_remote_ref(rm.name, rm.provider), 

361 gguf_filename="", 

362 task=rm.task, 

363 display_name=rm.name, 

364 param_count=rm.parameter_size, 

365 size_gb=0, 

366 min_ram_gb=0, 

367 description="", 

368 quality_tier="", 

369 featured=False, 

370 downloads=0, 

371 installed=True, 

372 source=source, 

373 fit=None, 

374 size_variants=[], 

375 provider=rm.provider, 

376 key_status=KeyStatus.READY if source is ModelSource.FRONTIER else None, 

377 ) 

378 

379 

380_HOSTED_MODELS_TTL = 60 

381 

382 

383# Single-entry TTL caches keyed on the config tuple that produced the value: 

384# maxsize=1 means a lookup under a new key evicts the old one. 

385_hosted_cache: TTLCache[str, list[CatalogEntryResponse]] = TTLCache( 

386 maxsize=1, ttl=_HOSTED_MODELS_TTL 

387) 

388 

389 

390def _discover_hosted_sync() -> list[CatalogEntryResponse]: 

391 """All hosted rows (frontier + the configured local server), unfiltered. 

392 

393 Blocking; call via to_thread. Local rows take the detected server's source 

394 (Ollama or LM Studio). Both discovery calls fail soft when no keys are set 

395 or the endpoint is unreachable, so the catalog degrades to native-only. 

396 """ 

397 rows: list[CatalogEntryResponse] = [] 

398 for models in discover_api_models().values(): 

399 rows.extend(_hosted_entry(rm, ModelSource.FRONTIER) for rm in models) 

400 for rm in classify_all_remote_models(): 

401 spec = local_server_for_label(rm.provider) 

402 source = ModelSource(spec.key) if spec is not None else ModelSource.REMOTE 

403 rows.append(_hosted_entry(rm, source)) 

404 return rows 

405 

406 

407def _hosted_cache_key() -> str: 

408 """Cache key over the inputs that change discovery output. 

409 

410 Enumerates configured provider-key fields generically from 

411 ``PROVIDER_KEYS`` so adding a provider does not silently reuse a 

412 stale cache entry. 

413 """ 

414 keys = ":".join(getattr(cfg, field) or "" for _, field, *_ in PROVIDER_KEYS) 

415 return f"{cfg.ollama_base_url}:{cfg.lm_studio_base_url}:{keys}" 

416 

417 

418async def _collect_hosted_entries( 

419 *, task: ModelTask | None, search: str, source: ModelSource | None = None 

420) -> list[CatalogEntryResponse]: 

421 """Hosted catalog rows filtered by task/search/source, off the event loop + TTL-cached.""" 

422 key = _hosted_cache_key() 

423 rows = _hosted_cache.get(key) 

424 if rows is None: 

425 rows = await asyncio.to_thread(_discover_hosted_sync) 

426 _hosted_cache[key] = rows 

427 if task is not None: 

428 rows = [r for r in rows if r.task == task] 

429 if source is not None: 

430 rows = [r for r in rows if r.source == source] 

431 if search: 

432 needle = search.lower() 

433 rows = [r for r in rows if needle in r.display_name.lower()] 

434 return rows 

435 

436 

437async def models_catalog( 

438 task: str | None = None, 

439 search: str = "", 

440 size: str | None = None, 

441 installed: bool | None = None, 

442 featured: bool | None = None, 

443 max_fit: str | None = None, 

444 sort: str = "featured", 

445 limit: int = 20, 

446 offset: int = 0, 

447 source: str | None = None, 

448) -> ModelsCatalogResponse: 

449 """One page of the catalog rows that pass every filter, hosted rows first.""" 

450 # Validate every closed-set param at the HTTP boundary instead of 

451 # letting unknown values silently short-circuit the filter inside. 

452 parsed_task = ModelTask(task) if task else None 

453 parsed_size = CatalogSize(size) if size else None 

454 parsed_sort = CatalogSort(sort) 

455 parsed_max_fit = FitLevel(max_fit) if max_fit else None 

456 parsed_source = ModelSource.parse(source) 

457 

458 # Hosted rows (frontier + ollama) lead the listing; none for featured-only, 

459 # installed=False, or source=native. 

460 hosted: list[CatalogEntryResponse] = [] 

461 if not featured and installed is not False and parsed_source is not ModelSource.NATIVE: 

462 hosted = await _collect_hosted_entries( 

463 task=parsed_task, search=search, source=parsed_source 

464 ) 

465 window = page_window(len(hosted), offset, limit) 

466 

467 available_bytes = available_memory_for_fit() 

468 # A listing filtered to one hosted source has no native part. 

469 result = CatalogResult(total=0, limit=0, offset=0, models=[]) 

470 if parsed_source in (None, ModelSource.NATIVE): 

471 # get_catalog resolves the picks, which is HTTP on the first call. 

472 result = await asyncio.to_thread( 

473 get_catalog, 

474 task=parsed_task, 

475 search=search, 

476 size=parsed_size, 

477 installed=installed, 

478 featured=featured, 

479 fit_filter=make_fit_filter(parsed_max_fit, available_bytes), 

480 sort=parsed_sort, 

481 limit=window.rest_limit, 

482 offset=window.rest_offset, 

483 model_manager=get_services().model_manager, 

484 ) 

485 

486 registry = get_services().registry 

487 installed_refs = {m.ref for m in registry.list_installed()} 

488 enriched = enrich_catalog(result, installed_refs) 

489 

490 families_by_repo = _families_by_repo() 

491 

492 native_rows = [ 

493 _build_catalog_entry(e, available_bytes=available_bytes, families_by_repo=families_by_repo) 

494 for e in enriched 

495 ] 

496 has_more = len(hosted) > offset + limit or result.has_more 

497 return ModelsCatalogResponse( 

498 total=None if result.total is None else len(hosted) + result.total, 

499 limit=limit, 

500 offset=offset, 

501 has_more=has_more, 

502 next_offset=offset + limit if has_more else None, 

503 truncated=result.truncated, 

504 models=hosted[offset : offset + limit] + native_rows, 

505 ) 

506 

507 

508async def models_installed(task: ModelTask | None = None) -> ModelsInstalledResponse: 

509 """Installed models across every source, only those of *task* when given.""" 

510 # Walks the model filesystem and queries the local servers over HTTP. 

511 result = await asyncio.to_thread(list_models_data, task=task) 

512 return ModelsInstalledResponse(models=result.models) 

513 

514 

515async def enforce_pull_arch_compat( 

516 model: str, *, source: str = "native", allow_unsupported: bool = False 

517) -> None: 

518 """Raise HTTP 409 for an unsupported architecture before the pull stream opens. 

519 

520 The route must await this BEFORE returning ``Stream(models_pull(...))``: a raise 

521 inside the ``models_pull`` async generator fires only on first iteration, after 

522 Litestar has already flushed the 200 SSE headers, so it can no longer set the 

523 status. (``manager.pull`` re-enforces compatibility during the pull itself.) 

524 """ 

525 from litestar.exceptions import HTTPException 

526 

527 from lilbee.catalog.compat import SUPPORTED_ARCHS, UnsupportedArchError 

528 

529 if _parse_source(source) is not ModelSource.NATIVE or allow_unsupported: 

530 return 

531 manager = get_services().model_manager 

532 try: 

533 await asyncio.to_thread(manager.enforce_arch_compat, model) 

534 except UnsupportedArchError as exc: 

535 raise HTTPException( 

536 status_code=409, 

537 detail="unsupported_arch", 

538 extra={ 

539 "code": "unsupported_arch", 

540 "arch": exc.architecture, 

541 "ref": exc.ref, 

542 "supported_examples": sorted(SUPPORTED_ARCHS)[:5], 

543 "total_supported": len(SUPPORTED_ARCHS), 

544 }, 

545 ) from exc 

546 

547 

548async def models_pull( 

549 model: str, *, source: str = "native", allow_unsupported: bool = False 

550) -> AsyncGenerator[str, None]: 

551 """Yield SSE progress events while pulling a model in real time. 

552 Sets a cancel event on client disconnect so the pull stops. 

553 

554 Architecture compatibility is enforced by the route via 

555 :func:`enforce_pull_arch_compat` before this stream opens; ``manager.pull`` 

556 re-enforces it during the pull. 

557 """ 

558 manager = get_services().model_manager 

559 src = _parse_source(source) 

560 

561 sse = SseStream() 

562 

563 def _pull_blocking() -> None: 

564 def _on_bytes(downloaded: int, total: int) -> None: 

565 # Raise, not return: the pull runs in a worker thread asyncio 

566 # cannot interrupt, so returning left a multi-GB download running 

567 # for a client that had gone. 

568 if sse.cancel.is_set(): 

569 raise TaskCancelledError 

570 payload = sse_event(SseEvent.PROGRESS, {"current": downloaded, "total": total}) 

571 sse.loop.call_soon_threadsafe(sse.queue.put_event_nowait, payload, SseEvent.PROGRESS) 

572 

573 try: 

574 manager.pull( 

575 model, 

576 src, 

577 on_bytes=_on_bytes, 

578 allow_unsupported=allow_unsupported, 

579 cancel=sse.cancel, 

580 ) 

581 except TaskCancelledError: 

582 log.info("Model pull for %s aborted: client disconnected", model) 

583 except Exception as exc: 

584 sse.loop.call_soon_threadsafe(sse.queue.put_nowait, sse_error(str(exc))) 

585 finally: 

586 sse.loop.call_soon_threadsafe(sse.queue.put_nowait, None) 

587 

588 task = asyncio.ensure_future(asyncio.to_thread(_pull_blocking)) 

589 try: 

590 async for event in sse.drain(task, "Model pull stream"): 

591 yield event 

592 finally: 

593 # Closing this generator means the client is gone. Set it here, not in 

594 # drain's cleanup, which runs only once that generator is collected; 

595 # until then the worker thread keeps downloading. 

596 sse.cancel.set() 

597 

598 

599async def models_delete(model: str, *, source: str = "native") -> ModelsDeleteResponse: 

600 """Delete a model. Returns deletion status, model name, and freed space. 

601 

602 lilbee removes only native models it downloaded; removing a read-only 

603 local-server model (Ollama, LM Studio) is refused with a 409. 

604 """ 

605 from litestar.exceptions import HTTPException 

606 

607 from lilbee.app.models import remove_model_data 

608 

609 src = _parse_source(source) 

610 try: 

611 # Delegate to the shared remove path so REST reports the same freed size 

612 # (full multi-shard total) as the CLI and MCP, not a hardcoded 0. 

613 result = remove_model_data(model, src) 

614 except ValueError as exc: 

615 raise HTTPException(status_code=409, detail=str(exc)) from exc 

616 return ModelsDeleteResponse( 

617 deleted=result.deleted, model=result.model, freed_gb=result.freed_gb 

618 ) 

619 

620 

621_EXTERNAL_MODELS_TTL = 60 

622 

623 

624_external_cache: TTLCache[str, ExternalModelsResponse] = TTLCache( 

625 maxsize=1, ttl=_EXTERNAL_MODELS_TTL 

626) 

627 

628 

629async def list_external_models() -> ExternalModelsResponse: 

630 """Query the provider for available models via its list_models() API.""" 

631 key = f"{cfg.ollama_base_url}:{cfg.lm_studio_base_url}:{cfg.llm_api_key or ''}" 

632 # ``is not None``, not truthiness: an empty model list is a real answer. 

633 cached = _external_cache.get(key) 

634 if cached is not None: 

635 return cached 

636 

637 try: 

638 models = await asyncio.to_thread(get_services().provider.list_models) 

639 result = ExternalModelsResponse(models=models) 

640 _external_cache[key] = result 

641 return result 

642 except Exception as exc: 

643 log.warning("Failed to list external models: %s", exc) 

644 return ExternalModelsResponse(models=[], error=str(exc))