Coverage for src/lilbee/catalog/query.py: 100%

123 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-17 10:02 +0000

1"""Catalog filtering, sorting, lookup, and ad-hoc HF resolution.""" 

2 

3import logging 

4from collections.abc import Callable 

5from typing import Any 

6 

7from huggingface_hub.utils import HFValidationError, validate_repo_id 

8 

9from lilbee.app.services import get_services 

10from lilbee.catalog.models import ( 

11 CatalogModel, 

12 CatalogResult, 

13 HfPage, 

14 PageWindow, 

15 dedupe_models, 

16 page_window, 

17) 

18from lilbee.catalog.picks import get_picks 

19from lilbee.catalog.refs import ( 

20 GGUF_GLOB, 

21 GGUF_SUFFIX, 

22 NATIVE_GGUF_REF_MIN_SLASHES, 

23 hf_repo_from_ref, 

24) 

25from lilbee.catalog.types import CatalogSize, CatalogSort, ModelTask 

26 

27log = logging.getLogger(__name__) 

28 

29 

30def _search_blob(m: CatalogModel) -> str: 

31 """Lowercased join of searchable fields on a catalog row. 

32 

33 Null char joins the fields so a search term never straddles them. 

34 """ 

35 return f"{m.display_name}\0{m.hf_repo}\0{m.description}".lower() 

36 

37 

38# Upper bound in billions of parameters for each bucket below HUGE, which takes 

39# everything above the last one. Keyed on parameters rather than on-disk bytes so 

40# a model keeps its bucket whichever quant is picked, and so buckets match how 

41# model sizes are actually talked about ("a 70B"). HUGE starts where consumer 

42# hardware stops. 

43_PARAM_TIER_CEILINGS: tuple[tuple[float, CatalogSize], ...] = ( 

44 (4.0, CatalogSize.SMALL), 

45 (20.0, CatalogSize.MEDIUM), 

46 (70.0, CatalogSize.LARGE), 

47) 

48 

49_PARAMS_PER_BILLION = 1e9 

50 

51 

52def size_bucket(params: int) -> CatalogSize | None: 

53 """Bucket a parameter count. None when the repo publishes no count.""" 

54 if params <= 0: 

55 return None 

56 billions = params / _PARAMS_PER_BILLION 

57 for ceiling, bucket in _PARAM_TIER_CEILINGS: 

58 if billions < ceiling: 

59 return bucket 

60 return CatalogSize.HUGE 

61 

62 

63def get_catalog( 

64 task: ModelTask | None = None, 

65 *, 

66 search: str = "", 

67 size: CatalogSize | None = None, 

68 installed: bool | None = None, 

69 featured: bool | None = None, 

70 fit_filter: Callable[[CatalogModel], bool] | None = None, 

71 sort: CatalogSort = CatalogSort.FEATURED, 

72 limit: int = 20, 

73 offset: int = 0, 

74 model_manager: Any = None, 

75) -> CatalogResult: 

76 """One catalog page: the picks lead and the HuggingFace rows fill the rest of the window. 

77 

78 A window that ends inside the picks makes no HuggingFace request. The 

79 browse total is unknown because HuggingFace exposes no count, so it is 

80 None and clients page on has_more. 

81 """ 

82 picks = get_picks() 

83 installed_filter = _installed_filter(installed, model_manager) 

84 

85 def keep(models: list[CatalogModel]) -> list[CatalogModel]: 

86 return _filter_models( 

87 models, 

88 task=task, 

89 search=search, 

90 size=size, 

91 installed_filter=installed_filter, 

92 fit_filter=fit_filter, 

93 featured=featured, 

94 ) 

95 

96 leading = _sort_models(keep(list(picks)), sort) 

97 window = page_window(len(leading), offset, limit) 

98 page = leading[offset : offset + limit] 

99 hf_models: list[CatalogModel] = [] 

100 if featured: 

101 has_more = offset + limit < len(leading) 

102 elif window.rest_limit == 0: 

103 has_more = True # the HuggingFace rows start on the next page 

104 else: 

105 hf_page = _fetch_hf_page(task, search, window) 

106 # Deduplicate: skip HF models already shown as a pick 

107 pick_repos = {m.hf_repo for m in picks} 

108 hf_models = keep([m for m in hf_page.models if m.hf_repo not in pick_repos]) 

109 page.extend(_sort_models(hf_models, sort)) 

110 has_more = hf_page.has_more 

111 

112 return CatalogResult( 

113 total=len(leading) if featured else None, 

114 limit=limit, 

115 offset=offset, 

116 models=page, 

117 has_more=has_more, 

118 ) 

119 

120 

121def _fetch_hf_page(task: ModelTask | None, search: str, window: PageWindow) -> HfPage: 

122 """The HuggingFace rows that fill the rest of *window*.""" 

123 hf_tags, hf_library = task_to_pipeline(task) 

124 fetch_limit = window.rest_offset + window.rest_limit 

125 pages = [ 

126 get_services().hf_client.fetch_models( 

127 pipeline_tag=tag, 

128 limit=fetch_limit, 

129 offset=0, 

130 library=hf_library, 

131 search=search, 

132 ) 

133 for tag in hf_tags 

134 ] 

135 merged = dedupe_models([m for page in pages for m in page.models]) 

136 merged.sort(key=lambda m: m.downloads, reverse=True) 

137 end = window.rest_offset + window.rest_limit 

138 return HfPage( 

139 models=merged[window.rest_offset : end], 

140 has_more=any(page.has_more for page in pages) or len(merged) > end, 

141 ) 

142 

143 

144def _installed_filter( 

145 installed: bool | None, model_manager: Any 

146) -> Callable[[CatalogModel], bool] | None: 

147 """Row predicate for the installed filter, or None when it is off.""" 

148 if installed is None or model_manager is None: 

149 return None 

150 # A repo is installed if any of its quants has a manifest. 

151 installed_repos = {hf_repo_from_ref(ref) for ref in _get_installed_models(model_manager)} 

152 return lambda m: (m.hf_repo in installed_repos) == installed 

153 

154 

155def _filter_models( 

156 models: list[CatalogModel], 

157 *, 

158 task: ModelTask | None, 

159 search: str, 

160 size: CatalogSize | None, 

161 installed_filter: Callable[[CatalogModel], bool] | None, 

162 fit_filter: Callable[[CatalogModel], bool] | None, 

163 featured: bool | None, 

164) -> list[CatalogModel]: 

165 """The rows of *models* that pass every requested filter.""" 

166 if task: 

167 models = [m for m in models if m.task == task] 

168 if search: 

169 search_lower = search.lower() 

170 models = [m for m in models if search_lower in _search_blob(m)] 

171 if size is not None: 

172 models = [m for m in models if size_bucket(m.params) == size] 

173 if installed_filter is not None: 

174 models = [m for m in models if installed_filter(m)] 

175 if fit_filter is not None: 

176 models = [m for m in models if fit_filter(m)] 

177 if featured is not None: 

178 models = [m for m in models if m.featured == featured] 

179 return models 

180 

181 

182def task_to_pipeline(task: ModelTask | None) -> tuple[tuple[str, ...], str | None]: 

183 """Map task name to HuggingFace pipeline tags and library filter.""" 

184 mapping: dict[ModelTask, tuple[tuple[str, ...], str | None]] = { 

185 ModelTask.CHAT: (("text-generation",), None), 

186 ModelTask.EMBEDDING: ( 

187 ("feature-extraction", "sentence-similarity"), 

188 "sentence-transformers", 

189 ), 

190 ModelTask.VISION: (("image-text-to-text", "image-to-text"), None), 

191 ModelTask.RERANK: (("text-classification", "text-ranking"), None), 

192 } 

193 return mapping.get(task or ModelTask.CHAT, (("text-generation",), None)) 

194 

195 

196_PIPELINE_TO_TASK: dict[str, ModelTask] = { 

197 "text-generation": ModelTask.CHAT, 

198 "feature-extraction": ModelTask.EMBEDDING, 

199 "sentence-similarity": ModelTask.EMBEDDING, 

200 "image-text-to-text": ModelTask.VISION, 

201 "image-to-text": ModelTask.VISION, 

202 "text-classification": ModelTask.RERANK, 

203 "text-ranking": ModelTask.RERANK, 

204} 

205 

206 

207def pipeline_to_task(pipeline_tag: str) -> ModelTask: 

208 """Map HuggingFace pipeline tag to internal task name.""" 

209 return _PIPELINE_TO_TASK.get(pipeline_tag, ModelTask.CHAT) 

210 

211 

212def _get_installed_models(model_manager: Any) -> set[str]: 

213 """Get set of installed model names from model_manager. 

214 

215 Treats a manager failure as "nothing installed" so the browse list still 

216 renders, but logs it: silently swallowing would hide a broken registry that 

217 makes every model look uninstalled. 

218 """ 

219 try: 

220 return set(model_manager.list_installed()) 

221 except Exception: 

222 log.warning("Could not read installed models; treating as none installed", exc_info=True) 

223 return set() 

224 

225 

226_SORT_KEYS: dict[CatalogSort, tuple] = { 

227 CatalogSort.DOWNLOADS: (lambda m: m.downloads, True), 

228 CatalogSort.NAME: (lambda m: m.display_name.lower(), False), 

229 CatalogSort.SIZE_ASC: (lambda m: m.size_gb, False), 

230 CatalogSort.SIZE_DESC: (lambda m: m.size_gb, True), 

231 CatalogSort.FEATURED: (lambda m: (not m.featured, -m.downloads), False), 

232} 

233 

234 

235def _sort_models(models: list[CatalogModel], sort: CatalogSort) -> list[CatalogModel]: 

236 """Sort models according to the specified sort order.""" 

237 key_fn, reverse = _SORT_KEYS[sort] 

238 return sorted(models, key=key_fn, reverse=reverse) 

239 

240 

241def is_rerank_ref(model_ref: str) -> bool: 

242 """Return True iff *model_ref* names a reranker.""" 

243 if not model_ref: 

244 return False 

245 return reclassify_by_name(model_ref, ModelTask.CHAT) == ModelTask.RERANK 

246 

247 

248def _is_hf_repo_id(value: str) -> bool: 

249 """True if *value* is a well-formed ``owner/name`` HuggingFace repo id.""" 

250 if "/" not in value: 

251 return False 

252 try: 

253 validate_repo_id(value) 

254 except HFValidationError: 

255 return False 

256 return True 

257 

258 

259def build_adhoc_entry( 

260 hf_repo: str, 

261 *, 

262 gguf_filename: str = GGUF_GLOB, 

263 task: ModelTask = ModelTask.CHAT, 

264) -> CatalogModel: 

265 """Minimal CatalogModel for a HuggingFace GGUF repo. 

266 

267 *gguf_filename* defaults to the ``*.gguf`` glob (bare-repo pull picks the best 

268 quant); pass a concrete filename, which may include a repo subdirectory, to 

269 pin the exact file the user named. 

270 """ 

271 return CatalogModel( 

272 hf_repo=hf_repo, 

273 gguf_filename=gguf_filename, 

274 size_gb=0.0, 

275 min_ram_gb=2.0, 

276 description="", 

277 featured=False, 

278 downloads=0, 

279 task=task, 

280 ) 

281 

282 

283def resolve_pull_target(model: str) -> CatalogModel | None: 

284 """Resolve *model* to a pullable entry, HF-first. 

285 

286 A ref naming a concrete ``.gguf`` file (flat or in a repo subdir) is honored 

287 exactly. A bare ``owner/name`` repo pulls through the ``*.gguf`` glob, which 

288 picks the best quant. Returns None when *model* is not a usable repo id. 

289 """ 

290 # circular: modelhub.registry imports catalog.query at top 

291 from lilbee.modelhub.registry import parse_hf_ref 

292 

293 if model.endswith(GGUF_SUFFIX) and model.count("/") >= NATIVE_GGUF_REF_MIN_SLASHES: 

294 try: 

295 hf_repo, gguf_filename = parse_hf_ref(model) 

296 except ValueError: 

297 return None 

298 task = ModelTask(reclassify_by_name(model, ModelTask.CHAT)) 

299 return build_adhoc_entry(hf_repo, gguf_filename=gguf_filename, task=task) 

300 if not _is_hf_repo_id(model): 

301 return None 

302 return build_adhoc_entry(model, task=ModelTask(reclassify_by_name(model, ModelTask.CHAT))) 

303 

304 

305# Embedding detection by name, for servers (LM Studio) that report ids but no 

306# family. Trailing hyphens keep chat models that merely contain the letters out. 

307EMBEDDING_NAME_PATTERNS: frozenset[str] = frozenset({"embed", "bge-", "e5-", "gte-"}) 

308VISION_NAME_PATTERNS: frozenset[str] = frozenset( 

309 {"llava", "vision", "moondream", "ocr", "minicpm-v"} 

310) 

311# Reranker detection runs before embedding detection so ``bge-reranker-*`` is 

312# not misclassified as EMBEDDING. 

313RERANKER_NAME_PATTERNS: frozenset[str] = frozenset({"reranker", "rerank", "cross-encoder"}) 

314 

315 

316def reclassify_by_name(ref: str, declared_task: str) -> str: 

317 """Override declared_task to RERANK / VISION / EMBEDDING when ref names a known role. 

318 

319 Defends against manifests that stored ``task="chat"`` for models whose ref 

320 obviously identifies them as rerankers (e.g. ``bge-reranker-*``), vision 

321 loaders, or embedders. Embedders on a chat decoder arch (e.g. 

322 ``Qwen3-Embedding-*``, a qwen3 backbone + pooling head) classify as chat by 

323 architecture, so the name is the only signal short of probing the GGUF 

324 pooling type. 

325 

326 Check order (rerank, embedding, vision) matches 

327 :func:`lilbee.modelhub.model_manager.discovery._classify_remote_task` so the 

328 manifest and remote-discovery paths never disagree. Reranker is checked first 

329 so ``bge-reranker`` (which also matches the ``bge-`` embedder pattern) stays a 

330 reranker; embedding is checked before vision so an image embedder like 

331 ``nomic-embed-vision`` (matching both ``embed`` and ``vision``) stays an 

332 embedder. 

333 """ 

334 name_lower = ref.lower() 

335 if any(rp in name_lower for rp in RERANKER_NAME_PATTERNS): 

336 return ModelTask.RERANK 

337 if any(ep in name_lower for ep in EMBEDDING_NAME_PATTERNS): 

338 return ModelTask.EMBEDDING 

339 if any(vp in name_lower for vp in VISION_NAME_PATTERNS): 

340 return ModelTask.VISION 

341 return declared_task