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

241 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-08 09:20 +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.services import get_services 

14from lilbee.app.settings import apply_settings_update 

15from lilbee.catalog import ( 

16 ModelFamily, 

17 enrich_catalog, 

18 get_catalog, 

19 get_families, 

20 get_picks, 

21 picks_for, 

22) 

23from lilbee.catalog.refs import hf_repo_from_ref, is_bare_hf_repo 

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

25from lilbee.core.config import cfg 

26from lilbee.modelhub.model_manager import classify_all_remote_models, discover_api_models 

27from lilbee.modelhub.model_manager.types import RemoteModel 

28from lilbee.modelhub.role_validator import MODEL_FIELD_TO_TASK, validate_model_task_assignment 

29from lilbee.providers.local_servers import canonical_local_ref, local_server_for_label 

30from lilbee.providers.model_ref import format_remote_ref, parse_model_ref 

31from lilbee.providers.sdk_backend import PROVIDER_KEYS, get_provider_api_key 

32from lilbee.runtime.cancellation import TaskCancelledError 

33from lilbee.runtime.hardware import ( 

34 FitLevel, 

35 SizeVariantInfo, 

36 available_memory_for_fit, 

37 compute_fit, 

38 family_size_variants, 

39) 

40from lilbee.runtime.progress import SseEvent 

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

42from lilbee.server.models import ( 

43 CatalogEntryResponse, 

44 ExternalModelsResponse, 

45 InstalledModelEntry, 

46 ModelsCatalogResponse, 

47 ModelsDeleteResponse, 

48 ModelsInstalledResponse, 

49 ModelsShowResponse, 

50 SetModelResponse, 

51) 

52 

53if TYPE_CHECKING: 

54 from lilbee.catalog import CatalogModel 

55 from lilbee.catalog.formatting import EnrichedModel 

56 

57log = logging.getLogger(__name__) 

58 

59 

60class ModelCatalogEntry(BaseModel): 

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

62 

63 name: str 

64 size_gb: float 

65 min_ram_gb: float 

66 description: str 

67 installed: bool 

68 

69 

70class ModelCatalogSection(BaseModel): 

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

72 

73 active: str 

74 catalog: list[ModelCatalogEntry] 

75 installed: list[str] 

76 

77 

78class ModelsResponse(BaseModel): 

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

80 

81 chat: ModelCatalogSection 

82 embedding: ModelCatalogSection 

83 vision: ModelCatalogSection 

84 reranker: ModelCatalogSection 

85 

86 

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

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

89TASK_ENDPOINT_PATH: dict[ModelTask, str] = { 

90 ModelTask.CHAT: "chat", 

91 ModelTask.EMBEDDING: "embedding", 

92 ModelTask.VISION: "vision", 

93 ModelTask.RERANK: "reranker", 

94} 

95 

96 

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

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

99 endpoint = TASK_ENDPOINT_PATH[entry_task] 

100 return ( 

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

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

103 ) 

104 

105 

106def _catalog_section( 

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

108 active: str, 

109 installed: set[str], 

110) -> ModelCatalogSection: 

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

112 

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

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

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

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

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

118 """ 

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

120 return ModelCatalogSection( 

121 active=active, 

122 catalog=[ 

123 ModelCatalogEntry( 

124 name=m.display_name, 

125 size_gb=m.size_gb, 

126 min_ram_gb=m.min_ram_gb, 

127 description=m.description, 

128 installed=m.hf_repo in installed_repos, 

129 ) 

130 for m in featured 

131 ], 

132 installed=sorted(installed), 

133 ) 

134 

135 

136async def list_models() -> ModelsResponse: 

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

138 

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

140 catalog section it legitimately matches. 

141 """ 

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

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

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

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

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

147 await asyncio.to_thread(get_picks) 

148 

149 return ModelsResponse( 

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

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

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

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

154 ) 

155 

156 

157async def _set_model( 

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

159 model: str, 

160) -> SetModelResponse: 

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

162 apply_settings_update({field: model}) 

163 return SetModelResponse(model=model) 

164 

165 

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

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

168 

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

170 """ 

171 if not is_bare_hf_repo(model): 

172 return None 

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

174 

175 

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

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

178 

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

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

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

182 """ 

183 try: 

184 parsed = parse_model_ref(model) 

185 except ValueError: 

186 return None 

187 return model if parsed.name in available else None 

188 

189 

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

191 """Accept an API-provider-prefixed ref when that provider's key is configured. 

192 

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

194 ``list_models()``, so they never appear in *available*. With the key set, 

195 litellm routes the ref (and validates the model name at call time). 

196 """ 

197 try: 

198 parsed = parse_model_ref(model) 

199 except ValueError: 

200 return None 

201 if parsed.is_api and get_provider_api_key(parsed.provider) is not None: 

202 return model 

203 return None 

204 

205 

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

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

208 not_available = ValueError( 

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

210 ) 

211 if not model: 

212 raise not_available 

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

214 if model in available: 

215 return model 

216 hit = ( 

217 _resolve_via_available_repo(model, available) 

218 or _resolve_via_parse(model, available) 

219 or _resolve_via_provider_key(model) 

220 ) 

221 if hit is None: 

222 raise not_available 

223 return hit 

224 

225 

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

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

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

229 

230 

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

232 

233 

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

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

236 

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

238 task validation delegates to ``validate_model_task_assignment`` so 

239 the handler and config paths share a single implementation. 

240 """ 

241 if allow_empty and not model.strip(): 

242 return "" 

243 normalized = _require_model_available(model) 

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

245 

246 

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

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

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

250 return await _set_model("chat_model", normalized) 

251 

252 

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

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

255 

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

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

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

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

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

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

262 """ 

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

264 result = apply_settings_update({"embedding_model": normalized}) 

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

266 

267 

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

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

270 normalized = await asyncio.to_thread( 

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

272 ) 

273 return await _set_model("vision_model", normalized) 

274 

275 

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

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

278 normalized = await asyncio.to_thread( 

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

280 ) 

281 return await _set_model("reranker_model", normalized) 

282 

283 

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

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

286 provider = get_services().provider 

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

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

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

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

291 

292 

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

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

295 return ModelSource(source) 

296 

297 

298_BYTES_PER_GB = 1024**3 

299 

300 

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

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

303 if available_bytes is None: 

304 return None 

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

306 return None 

307 if enriched.size_gb <= 0: 

308 return None 

309 return compute_fit(int(enriched.size_gb * _BYTES_PER_GB), available_bytes).level 

310 

311 

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

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

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

315 for family in get_families(): 

316 for variant in family.variants: 

317 index[variant.hf_repo] = family 

318 return index 

319 

320 

321def _row_size_variants( 

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

323) -> list[SizeVariantInfo]: 

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

325 family = families_by_repo.get(enriched.hf_repo) 

326 if family is None: 

327 return [] 

328 return family_size_variants(family) 

329 

330 

331def _build_catalog_entry( 

332 enriched: EnrichedModel, 

333 *, 

334 available_bytes: int | None, 

335 families_by_repo: dict[str, ModelFamily], 

336) -> CatalogEntryResponse: 

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

338 return CatalogEntryResponse( 

339 hf_repo=enriched.hf_repo, 

340 gguf_filename=enriched.gguf_filename, 

341 task=enriched.task, 

342 display_name=enriched.display_name, 

343 param_count=enriched.param_count, 

344 size_gb=enriched.size_gb, 

345 min_ram_gb=enriched.min_ram_gb, 

346 description=enriched.description, 

347 quality_tier=enriched.quality_tier, 

348 featured=enriched.featured, 

349 downloads=enriched.downloads, 

350 installed=enriched.installed, 

351 source=enriched.source, 

352 fit=_row_fit(enriched, available_bytes), 

353 size_variants=_row_size_variants(enriched, families_by_repo), 

354 architecture=enriched.architecture, 

355 compat=enriched.compat, 

356 ) 

357 

358 

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

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

361 return CatalogEntryResponse( 

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

363 gguf_filename="", 

364 task=rm.task, 

365 display_name=rm.name, 

366 param_count=rm.parameter_size, 

367 size_gb=0, 

368 min_ram_gb=0, 

369 description="", 

370 quality_tier="", 

371 featured=False, 

372 downloads=0, 

373 installed=True, 

374 source=source, 

375 fit=None, 

376 size_variants=[], 

377 provider=rm.provider, 

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

379 ) 

380 

381 

382_HOSTED_MODELS_TTL = 60 

383 

384 

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

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

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

388 maxsize=1, ttl=_HOSTED_MODELS_TTL 

389) 

390 

391 

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

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

394 

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

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

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

398 """ 

399 rows: list[CatalogEntryResponse] = [] 

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

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

402 for rm in classify_all_remote_models(): 

403 spec = local_server_for_label(rm.provider) 

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

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

406 return rows 

407 

408 

409def _hosted_cache_key() -> str: 

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

411 

412 Enumerates configured provider-key fields generically from 

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

414 stale cache entry. 

415 """ 

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

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

418 

419 

420async def _collect_hosted_entries( 

421 *, task: ModelTask | None, search: str 

422) -> list[CatalogEntryResponse]: 

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

424 key = _hosted_cache_key() 

425 rows = _hosted_cache.get(key) 

426 if rows is None: 

427 rows = await asyncio.to_thread(_discover_hosted_sync) 

428 _hosted_cache[key] = rows 

429 if task is not None: 

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

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 sort: str = "featured", 

444 limit: int = 20, 

445 offset: int = 0, 

446) -> ModelsCatalogResponse: 

447 """Return paginated model catalog with installed status.""" 

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

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

450 parsed_task = ModelTask(task) if task else None 

451 parsed_size = CatalogSize(size) if size else None 

452 parsed_sort = CatalogSort(sort) 

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

454 result = await asyncio.to_thread( 

455 get_catalog, 

456 task=parsed_task, 

457 search=search, 

458 size=parsed_size, 

459 installed=installed, 

460 featured=featured, 

461 sort=parsed_sort, 

462 limit=limit, 

463 offset=offset, 

464 model_manager=get_services().model_manager, 

465 ) 

466 

467 registry = get_services().registry 

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

469 enriched = enrich_catalog(result, installed_refs) 

470 

471 available_bytes = available_memory_for_fit() 

472 families_by_repo = _families_by_repo() 

473 

474 native_rows = [ 

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

476 for e in enriched 

477 ] 

478 # Hosted rows (frontier + ollama) are selectable and download-free, shown 

479 # on the first page only and skipped for featured-only / installed=False. 

480 # They stay out of ``total``: as an unpaginated overlay they made page 1 

481 # report a larger total than page 2 of the same listing. 

482 hosted_rows: list[CatalogEntryResponse] = [] 

483 if offset == 0 and not featured and installed is not False: 

484 hosted_rows = await _collect_hosted_entries(task=parsed_task, search=search) 

485 

486 return ModelsCatalogResponse( 

487 total=result.total, 

488 limit=result.limit, 

489 offset=result.offset, 

490 has_more=result.has_more, 

491 models=hosted_rows + native_rows, 

492 ) 

493 

494 

495def _installed_entries_sync() -> list[InstalledModelEntry]: 

496 """Blocking body of :func:`models_installed`: list installs and their sources.""" 

497 manager = get_services().model_manager 

498 entries = [] 

499 for name in manager.list_installed(): 

500 source = manager.get_source(name) or ModelSource.REMOTE 

501 entries.append( 

502 InstalledModelEntry(name=canonical_local_ref(name, source.value), source=source) 

503 ) 

504 return entries 

505 

506 

507async def models_installed() -> ModelsInstalledResponse: 

508 """Return installed models with their granular source and canonical ref.""" 

509 # list_installed walks the model filesystem and, on TTL expiry, queries the 

510 # configured local servers over HTTP; offload it like list_models does. 

511 entries = await asyncio.to_thread(_installed_entries_sync) 

512 return ModelsInstalledResponse(models=entries) 

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))