Coverage for src/lilbee/cli/tui/screens/catalog_utils.py: 100%

142 statements  

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

1"""Catalog data types, row builders, and formatting helpers. 

2 

3The catalog renders two distinct row shapes side by side: locally 

4installed / installable GGUFs (``LocalCatalogRow``) and cloud chat 

5models accessed through a provider's API (``FrontierCatalogRow``). 

6They share enough surface area that grouping and search reuse the 

7same helpers, but they carry different metadata and pull from 

8different sources, so they're separate types under a sealed 

9``CatalogRow`` union rather than a single optional-fields dataclass. 

10""" 

11 

12from __future__ import annotations 

13 

14import re 

15from collections.abc import Callable 

16from dataclasses import dataclass, field 

17from enum import StrEnum 

18from typing import Any, Literal 

19 

20from lilbee.catalog import PARAM_COUNT_RE, CatalogModel, ModelFamily, ModelVariant, extract_quant 

21from lilbee.catalog.types import KeyStatus, ModelCompat, ModelTask 

22from lilbee.modelhub.model_manager import RemoteModel 

23from lilbee.providers.model_ref import format_remote_ref 

24from lilbee.runtime.hardware import FitChip 

25 

26# Backend label for local GGUF models. Renderers drop this pill (the backend is 

27# implied for local models) and only show it for non-native SDK backends. 

28NATIVE_BACKEND = "native" 

29 

30 

31class CatalogRowKind(StrEnum): 

32 """Discriminator for the sealed CatalogRow union.""" 

33 

34 LOCAL = "local" 

35 FRONTIER = "frontier" 

36 

37 

38# Tab IDs for the 6-tab catalog shell. Discover is the curated landing, 

39# the four task tabs each render a single per-task grid, Library is the 

40# personal-encyclopedia view of installed local + activated cloud APIs. 

41TAB_DISCOVER = "discover" 

42TAB_CHAT = "chat" 

43TAB_EMBED = "embed" 

44TAB_VISION = "vision" 

45TAB_RERANK = "rerank" 

46TAB_LIBRARY = "library" 

47 

48# Order matters: numbered shortcuts 1-6 follow this sequence. 

49ALL_TAB_IDS: tuple[str, ...] = ( 

50 TAB_DISCOVER, 

51 TAB_CHAT, 

52 TAB_EMBED, 

53 TAB_VISION, 

54 TAB_RERANK, 

55 TAB_LIBRARY, 

56) 

57TASK_TAB_IDS: tuple[str, ...] = (TAB_CHAT, TAB_EMBED, TAB_VISION, TAB_RERANK) 

58 

59# Maps ModelTask -> the per-task tab id that renders its rows. Featured 

60# items still appear pinned at the top of their task tab; cross-task 

61# discovery happens on the Discover landing. 

62TASK_TO_TAB_ID: dict[ModelTask, str] = { 

63 ModelTask.CHAT: TAB_CHAT, 

64 ModelTask.EMBEDDING: TAB_EMBED, 

65 ModelTask.VISION: TAB_VISION, 

66 ModelTask.RERANK: TAB_RERANK, 

67} 

68TAB_ID_TO_TASK: dict[str, ModelTask] = {v: k for k, v in TASK_TO_TAB_ID.items()} 

69 

70 

71class SourceMode(StrEnum): 

72 """Per-task tab filter for which row source backs the visible cards. 

73 

74 LOCAL hides frontier rows (the default; mirrors the legacy mega-grid 

75 behavior). CLOUD shows only frontier rows for the active task. BOTH 

76 unions them so users can compare a local Llama with a cloud Llama 

77 side by side. Cycled via the ``c`` keybinding. 

78 """ 

79 

80 LOCAL = "local" 

81 CLOUD = "cloud" 

82 BOTH = "both" 

83 

84 

85_SOURCE_MODE_CYCLE: tuple[SourceMode, ...] = (SourceMode.LOCAL, SourceMode.CLOUD, SourceMode.BOTH) 

86 

87 

88def next_source_mode(current: SourceMode) -> SourceMode: 

89 """Return the next SourceMode in the LOCAL -> CLOUD -> BOTH -> LOCAL cycle.""" 

90 idx = _SOURCE_MODE_CYCLE.index(current) 

91 return _SOURCE_MODE_CYCLE[(idx + 1) % len(_SOURCE_MODE_CYCLE)] 

92 

93 

94def task_to_tab_id(task: ModelTask | str) -> str: 

95 """Return the per-task tab id for a ModelTask or its string value. 

96 

97 Accepts the string form because catalog rows carry ``task`` as a 

98 raw string (matching how HF API and the row builders return it), 

99 while the routing tables are keyed on the enum. 

100 """ 

101 if isinstance(task, ModelTask): 

102 return TASK_TO_TAB_ID[task] 

103 try: 

104 return TASK_TO_TAB_ID[ModelTask(task)] 

105 except (KeyError, ValueError) as exc: 

106 raise KeyError(f"unknown task: {task!r}") from exc 

107 

108 

109# SI thresholds for short download counts ("12.3M" / "456K") and binary 

110# thresholds for sizes ("4.2 GB" / "768 MB"). 

111_DOWNLOADS_PER_M = 1_000_000 

112_DOWNLOADS_PER_K = 1_000 

113_MB_PER_GB = 1024 

114 

115 

116@dataclass(frozen=True) 

117class SizeVariant: 

118 """One size/quant variant of a model family for the family-as-card strip. 

119 

120 ``label`` renders inline on the card (e.g. "8B Q4_K_M"). ``ref`` is 

121 the canonical pull target for this specific variant. ``fit`` is the 

122 fit chip computed against the host's available memory; ``None`` 

123 when the hardware probe has not yet run. 

124 """ 

125 

126 label: str 

127 quant: str 

128 size_gb: float 

129 ref: str 

130 fit: FitChip | None = None 

131 

132 

133@dataclass 

134class LocalCatalogRow: 

135 """A row in the catalog backed by a local GGUF (installable or installed). 

136 

137 ``name`` is the human-readable display label (e.g. "Qwen3 0.6B"). 

138 ``ref`` is the canonical identifier used for config persistence: 

139 ``hf_repo`` for catalog rows, ``hf_repo/filename`` for installed 

140 native models, and the provider's ref shape for remote/API rows. 

141 ``size_variants`` carries every quant for a family-aggregated row, 

142 so the card can render an inline chip strip and the detail drawer 

143 can list all sizes. ``fit`` is the chip for the row's primary 

144 variant. 

145 """ 

146 

147 name: str 

148 task: str 

149 params: str 

150 size: str 

151 quant: str 

152 downloads: str 

153 featured: bool 

154 installed: bool 

155 sort_downloads: int 

156 sort_size: float 

157 ref: str = "" 

158 backend: str = "" 

159 variant: ModelVariant | None = None 

160 family: ModelFamily | None = None 

161 catalog_model: CatalogModel | None = None 

162 remote_model: RemoteModel | None = None 

163 size_variants: list[SizeVariant] = field(default_factory=list) 

164 fit: FitChip | None = None 

165 compat: ModelCompat = ModelCompat.UNKNOWN 

166 kind: Literal[CatalogRowKind.LOCAL] = CatalogRowKind.LOCAL 

167 

168 

169@dataclass 

170class FrontierCatalogRow: 

171 """A row in the catalog backed by a cloud provider's chat API. 

172 

173 Frontier rows skip the local-model fields (size on disk, quant, 

174 GGUF filename) because they don't apply: the model lives on the 

175 provider's infrastructure. 

176 """ 

177 

178 name: str 

179 ref: str 

180 task: str 

181 provider: str # Display label, e.g. "Gemini" / "OpenAI" / "Anthropic". 

182 provider_id: str # Canonical id used for the API key field, e.g. "gemini". 

183 key_status: KeyStatus 

184 kind: Literal[CatalogRowKind.FRONTIER] = CatalogRowKind.FRONTIER 

185 

186 

187# Sealed union discriminated on .kind. Pattern-match (or compare) on row.kind 

188# to dispatch instead of isinstance, so adding a new row type is one place. 

189CatalogRow = LocalCatalogRow | FrontierCatalogRow 

190 

191 

192def parse_param_label(name: str) -> str: 

193 """Extract parameter count label from model name (e.g. '8B', '0.6B').""" 

194 from lilbee.catalog import PARAM_COUNT_RE 

195 

196 match = PARAM_COUNT_RE.search(name) 

197 return match.group(1).upper() if match else "--" 

198 

199 

200def _format_downloads(n: int) -> str: 

201 if n >= _DOWNLOADS_PER_M: 

202 return f"{n / _DOWNLOADS_PER_M:.1f}M" 

203 if n >= _DOWNLOADS_PER_K: 

204 return f"{n / _DOWNLOADS_PER_K:.0f}K" 

205 return str(n) 

206 

207 

208def _format_size_mb(size_mb: int) -> str: 

209 """Format size in MB to a human-readable string.""" 

210 if size_mb == 0: 

211 return "--" 

212 if size_mb >= _MB_PER_GB: 

213 return f"{size_mb / _MB_PER_GB:.1f} GB" 

214 return f"{size_mb} MB" 

215 

216 

217def format_size_gb(size_gb: float) -> str: 

218 """Format size in GB to a human-readable string.""" 

219 if size_gb <= 0: 

220 return "--" 

221 return f"{size_gb:.1f} GB" 

222 

223 

224def _is_param_count(label: str) -> bool: 

225 """True when label looks like a parameter count (e.g. '8B', '0.6B').""" 

226 return bool(PARAM_COUNT_RE.fullmatch(label)) 

227 

228 

229def family_to_size_variants(family: ModelFamily) -> list[SizeVariant]: 

230 """Build the size-chip strip for a featured ModelFamily. 

231 

232 Variants are returned in increasing size order so the chip strip 

233 reads compact-to-large left-to-right. ``fit`` is left ``None``; 

234 the catalog screen fills it in once the hardware probe has run. 

235 """ 

236 variants = sorted(family.variants, key=lambda v: v.size_mb) 

237 return [ 

238 SizeVariant( 

239 label=_size_variant_label(v), 

240 quant=v.quant or "--", 

241 size_gb=v.size_mb / 1024, 

242 ref=v.hf_repo, 

243 fit=None, 

244 ) 

245 for v in variants 

246 ] 

247 

248 

249def _size_variant_label(v: ModelVariant) -> str: 

250 """Render a compact label for a ModelVariant chip (e.g. '8B Q4_K_M').""" 

251 pieces = [p for p in (v.param_count, v.quant) if p] 

252 return " ".join(pieces) if pieces else "--" 

253 

254 

255def variant_to_row(v: ModelVariant, f: ModelFamily, installed: bool) -> LocalCatalogRow: 

256 """Convert a ModelVariant + family to a LocalCatalogRow.""" 

257 # Avoid duplicating the param count when the family name already ends with it. 

258 if v.param_count and not f.name.endswith(v.param_count): 

259 label = f"{f.name} {v.param_count}" 

260 else: 

261 label = f.name 

262 params = v.param_count if _is_param_count(v.param_count) else "--" 

263 return LocalCatalogRow( 

264 name=label, 

265 task=f.task, 

266 params=params, 

267 size=_format_size_mb(v.size_mb), 

268 quant=v.quant or "--", 

269 downloads="--", 

270 featured=True, 

271 installed=installed, 

272 sort_downloads=0, 

273 sort_size=v.size_mb / 1024, 

274 ref=v.hf_repo, 

275 backend=NATIVE_BACKEND, 

276 variant=v, 

277 family=f, 

278 compat=v.compat, 

279 ) 

280 

281 

282def catalog_to_row(m: CatalogModel, installed: bool) -> LocalCatalogRow: 

283 """Convert a CatalogModel to a LocalCatalogRow.""" 

284 quant = extract_quant(m.gguf_filename) 

285 return LocalCatalogRow( 

286 name=m.display_name, 

287 task=m.task, 

288 params=parse_param_label(m.display_name), 

289 size=format_size_gb(m.size_gb), 

290 quant=quant or "--", 

291 downloads=_format_downloads(m.downloads) if m.downloads > 0 else "--", 

292 featured=m.featured, 

293 installed=installed, 

294 sort_downloads=m.downloads, 

295 sort_size=m.size_gb, 

296 ref=m.ref, 

297 backend=NATIVE_BACKEND, 

298 catalog_model=m, 

299 # An installed model demonstrably runs, whatever the catalog probe said. 

300 compat=ModelCompat.SUPPORTED if installed else m.compat, 

301 ) 

302 

303 

304def remote_to_row(rm: RemoteModel) -> LocalCatalogRow: 

305 """Convert a RemoteModel to a LocalCatalogRow. 

306 

307 ``ref`` is the canonical ``provider/name`` form so it round-trips 

308 through ``Config.chat_model``'s validator without a per-call-site 

309 fixup. 

310 """ 

311 return LocalCatalogRow( 

312 name=rm.name, 

313 task=rm.task, 

314 params=rm.parameter_size or "--", 

315 size="--", 

316 quant="--", 

317 downloads="--", 

318 featured=False, 

319 installed=True, 

320 sort_downloads=0, 

321 sort_size=0.0, 

322 ref=format_remote_ref(rm.name, rm.provider), 

323 backend=rm.provider.lower(), 

324 remote_model=rm, 

325 # The model is live on the reporting server, so it demonstrably runs. 

326 compat=ModelCompat.SUPPORTED, 

327 ) 

328 

329 

330def frontier_row_from_remote( 

331 rm: RemoteModel, *, provider_id: str, key_status: KeyStatus 

332) -> FrontierCatalogRow: 

333 """Convert a discovered cloud chat model to a FrontierCatalogRow. 

334 

335 ``ref`` is the canonical ``provider/name`` form so callers pass it 

336 straight to ``Config.chat_model`` without re-prefixing. 

337 """ 

338 return FrontierCatalogRow( 

339 name=rm.name, 

340 ref=format_remote_ref(rm.name, rm.provider), 

341 task=rm.task, 

342 provider=rm.provider, 

343 provider_id=provider_id, 

344 key_status=key_status, 

345 ) 

346 

347 

348# Column sort key extractors. Local-only because every column except 

349# Name reads a field FrontierCatalogRow doesn't carry, and the catalog 

350# screen sorts local and frontier rows independently before concat. 

351SORT_KEYS: dict[str, Callable[[LocalCatalogRow], Any]] = { 

352 "Name": lambda r: r.name.lower(), 

353 "Task": lambda r: r.task, 

354 "Backend": lambda r: r.backend.lower(), 

355 "Params": lambda r: _param_sort_value(r.params), 

356 "Size": lambda r: r.sort_size, 

357 "Quant": lambda r: r.quant, 

358 "Downloads": lambda r: r.sort_downloads, 

359} 

360 

361 

362def _param_sort_value(params: str) -> float: 

363 """Convert param label to sortable float (e.g. '8B' -> 8.0).""" 

364 match = re.search(r"(\d+\.?\d*)", params) 

365 return float(match.group(1)) if match else 0.0 

366 

367 

368def row_delete_id(row: CatalogRow) -> str | None: 

369 """Return the model_manager-compatible identifier for *row*. 

370 

371 Remote rows hand back the bare ``RemoteModel.name`` because the 

372 Ollama HTTP API keys models by bare name, while ``ref`` carries the 

373 canonical ``ollama/<name>`` chat_model form. 

374 """ 

375 if row.kind == CatalogRowKind.FRONTIER: 

376 return row.ref or None 

377 if row.remote_model is not None: 

378 return row.remote_model.name or None 

379 return row.ref or None 

380 

381 

382def matches_search(row: CatalogRow, search: str) -> bool: 

383 """Return True if the row matches the search text (hyphen/underscore-insensitive). 

384 

385 Local rows match against name/task/params/quant/backend; frontier 

386 rows match against name + provider so users can type "gemini" and 

387 see every Gemini model regardless of suffix. 

388 """ 

389 if not search: 

390 return True 

391 needle = _normalize_for_search(search) 

392 if row.kind == CatalogRowKind.FRONTIER: 

393 return any( 

394 needle in _normalize_for_search(field) 

395 for field in (row.name, row.provider, row.provider_id) 

396 ) 

397 return any( 

398 needle in _normalize_for_search(field) 

399 for field in (row.name, row.task, row.params, row.quant, row.backend) 

400 ) 

401 

402 

403def _normalize_for_search(value: str) -> str: 

404 return value.lower().replace("-", " ").replace("_", " ")