Coverage for src/lilbee/catalog/models.py: 100%
88 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Catalog dataclasses and pydantic types: leaf module, no sibling imports."""
3import re
4from dataclasses import dataclass
6from pydantic import BaseModel
8from lilbee.catalog.types import ModelCompat, ModelTask
10# Minimum recommended floor so a tiny model still reports a sane RAM ask.
11_MIN_RAM_FLOOR_GB = 2.0
12# Working-set multiple over the on-disk size (weights + KV cache + overhead).
13_RAM_OVER_SIZE_FACTOR = 1.5
15_BYTES_PER_GB = 1024**3
17# Whole-file bytes per parameter for each llama.cpp quantization.
18#
19# Not the same quantity as ``gguf.GGML_QUANT_SIZES``, which gives the block size
20# of one ggml tensor type. llama.cpp never writes a homogeneous file: it promotes
21# ``output.weight`` and tied ``token_embd`` to Q6_K/Q8_0 whatever the ftype, and
22# leaves norms in F32, so a real file always costs more per weight than its
23# nominal type. These are measured file sizes; Qwen3-8B-GGUF publishes 0.614
24# (Q4_K_M), 0.699 (Q5_0), 0.714 (Q5_K_M), 0.821 (Q6_K) and 1.063 (Q8_0).
25#
26# GGML_QUANT_SIZES is still used, as the physical floor: no file can be smaller
27# than its base type, so _quant_bytes_per_param clamps to it and a typo below the
28# floor cannot survive.
29_BYTES_PER_PARAM: dict[str, float] = {
30 "Q2_K": 0.33,
31 "Q3_K_S": 0.45,
32 "Q3_K_M": 0.488,
33 "Q3_K_L": 0.53,
34 "IQ4_XS": 0.53,
35 "Q4_0": 0.569,
36 "Q4_K_S": 0.575,
37 "Q4_K_M": 0.614,
38 "Q5_0": 0.699,
39 "Q5_K_S": 0.688,
40 "Q5_K_M": 0.714,
41 "Q6_K": 0.821,
42 "Q8_0": 1.063,
43 "F16": 2.0,
44 "BF16": 2.0,
45 "F32": 4.0,
46}
48# Q4_K_M is what ``pick_best_gguf`` prefers, so an unrecognized quant label
49# estimates as if it were the quant a pull would most likely land on.
50_DEFAULT_BYTES_PER_PARAM = _BYTES_PER_PARAM["Q4_K_M"]
52# Quant label as written in a GGUF filename. Matches the K/legacy quants
53# (``Q4_K_M``), the IQ family (``IQ4_XS``) and the unquantized float types,
54# which ``formatting.extract_quant`` does not: it exists to label a row for
55# display and only recognizes ``Q``-prefixed names.
56_QUANT_IN_FILENAME = re.compile(r"\b(I?Q\d[A-Z0-9_]*|BF16|F16|F32)\b", re.IGNORECASE)
59def _ggml_floor(quant: str) -> float | None:
60 """Bytes per weight of *quant*'s base ggml type, or None if it names none."""
61 from gguf.constants import GGML_QUANT_SIZES, GGMLQuantizationType
63 base = quant.split("_")[0] if quant.startswith(("F", "BF")) else quant
64 for name in (quant, base, "_".join(quant.split("_")[:2])):
65 try:
66 block, type_size = GGML_QUANT_SIZES[GGMLQuantizationType[name]]
67 except KeyError:
68 continue
69 return type_size / block
70 return None
73def _quant_bytes_per_param(gguf_filename: str) -> float:
74 """Bytes per weight for the quant *gguf_filename* names, never below its floor."""
75 match = _QUANT_IN_FILENAME.search(gguf_filename)
76 quant = match.group(1).upper() if match else ""
77 measured = _BYTES_PER_PARAM.get(quant, _DEFAULT_BYTES_PER_PARAM)
78 floor = _ggml_floor(quant)
79 return max(measured, floor) if floor is not None else measured
82def estimate_min_ram_gb(size_gb: float) -> float:
83 """Estimate the RAM a model needs from its on-disk size (single source)."""
84 return round(max(_MIN_RAM_FLOOR_GB, size_gb * _RAM_OVER_SIZE_FACTOR), 1)
87def estimate_size_gb(params: int, gguf_filename: str) -> float:
88 """Estimate the on-disk GB of *gguf_filename* from a model's parameter count.
90 The HF listing API reports a parameter count (``gguf.total``) but no
91 per-file byte size; siblings carry no ``size`` on either the list or the
92 detail endpoint, and ``gguf.totalFileSize`` sums every quant in the repo
93 rather than the one file a pull fetches. Per-file bytes are only available
94 from ``/tree/main``, which is one extra request per repo and unaffordable
95 for a catalog page. Parameters times the quant's bytes-per-weight gets
96 within a few percent for a fraction of the cost.
97 """
98 if params <= 0:
99 return 0.0 # unknown: display as "?" in UI
100 return round(params * _quant_bytes_per_param(gguf_filename) / _BYTES_PER_GB, 1)
103class HfGgufMeta(BaseModel):
104 """GGUF metadata returned by the HF API when expand=gguf is requested.
106 ModelInfo.gguf is typed as ``dict | None`` upstream, so we validate it ourselves.
108 ``total`` is the model's parameter count, not a byte size; ``totalFileSize``
109 holds bytes. Verified against repos that name their own parameter count:
110 Qwen3-8B-GGUF reports ``total=8_190_000_000`` against 4.7 GB of files.
111 """
113 total: int = 0
114 architecture: str = ""
115 context_length: int = 0
118@dataclass
119class DownloadProgress:
120 """Human-readable snapshot of download progress.
122 ``percent`` is a float (0.0 to 100.0) so the ProgressBar renders smooth
123 fractional movement during multi-GB downloads. Call sites that need
124 an integer for display format it themselves.
125 """
127 percent: float
128 detail: str
129 is_cache_hit: bool
132@dataclass(frozen=True)
133class CatalogModel:
134 """One catalog entry, keyed by HuggingFace repo. ``gguf_filename`` may be a glob."""
136 hf_repo: str
137 gguf_filename: str
138 size_gb: float
139 min_ram_gb: float
140 description: str
141 featured: bool
142 downloads: int
143 task: ModelTask
144 architecture: str = ""
145 compat: ModelCompat = ModelCompat.UNKNOWN
146 # Parameter count. Size buckets key off this rather than on-disk bytes so a
147 # model keeps its bucket across quants. 0 when the repo publishes no GGUF
148 # metadata.
149 params: int = 0
151 @property
152 def ref(self) -> str:
153 """Browse-time ref (the HF repo); concrete filename is resolved at install."""
154 return self.hf_repo
156 @property
157 def display_name(self) -> str:
158 """Human-readable label derived from the HuggingFace repo id."""
159 # Local import keeps models.py a leaf (no sibling imports at module top).
160 from lilbee.catalog.formatting import clean_display_name
162 return clean_display_name(self.hf_repo)
165@dataclass(frozen=True)
166class CatalogResult:
167 """Paginated catalog result."""
169 total: int
170 limit: int
171 offset: int
172 models: list[CatalogModel]
173 has_more: bool = False
176@dataclass(frozen=True)
177class HfPage:
178 """One page of HuggingFace API results."""
180 models: list[CatalogModel]
181 has_more: bool
184@dataclass(frozen=True)
185class ModelVariant:
186 """One quantization within a model family. ``filename`` may be a glob."""
188 hf_repo: str
189 filename: str
190 param_count: str
191 quant: str
192 size_mb: int
193 mmproj_filename: str = ""
194 compat: ModelCompat = ModelCompat.UNKNOWN
197@dataclass(frozen=True)
198class ModelFamily:
199 """A group of related model variants (e.g. Qwen3 in multiple sizes)."""
201 slug: str # family slug for building refs: "qwen3"
202 name: str # display name: "Qwen3"
203 task: ModelTask
204 description: str
205 variants: tuple[ModelVariant, ...]