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

1"""Catalog dataclasses and pydantic types: leaf module, no sibling imports.""" 

2 

3import re 

4from dataclasses import dataclass 

5 

6from pydantic import BaseModel 

7 

8from lilbee.catalog.types import ModelCompat, ModelTask 

9 

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 

14 

15_BYTES_PER_GB = 1024**3 

16 

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} 

47 

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"] 

51 

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) 

57 

58 

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 

62 

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 

71 

72 

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 

80 

81 

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) 

85 

86 

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. 

89 

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) 

101 

102 

103class HfGgufMeta(BaseModel): 

104 """GGUF metadata returned by the HF API when expand=gguf is requested. 

105 

106 ModelInfo.gguf is typed as ``dict | None`` upstream, so we validate it ourselves. 

107 

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

112 

113 total: int = 0 

114 architecture: str = "" 

115 context_length: int = 0 

116 

117 

118@dataclass 

119class DownloadProgress: 

120 """Human-readable snapshot of download progress. 

121 

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

126 

127 percent: float 

128 detail: str 

129 is_cache_hit: bool 

130 

131 

132@dataclass(frozen=True) 

133class CatalogModel: 

134 """One catalog entry, keyed by HuggingFace repo. ``gguf_filename`` may be a glob.""" 

135 

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 

150 

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 

155 

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 

161 

162 return clean_display_name(self.hf_repo) 

163 

164 

165@dataclass(frozen=True) 

166class CatalogResult: 

167 """Paginated catalog result.""" 

168 

169 total: int 

170 limit: int 

171 offset: int 

172 models: list[CatalogModel] 

173 has_more: bool = False 

174 

175 

176@dataclass(frozen=True) 

177class HfPage: 

178 """One page of HuggingFace API results.""" 

179 

180 models: list[CatalogModel] 

181 has_more: bool 

182 

183 

184@dataclass(frozen=True) 

185class ModelVariant: 

186 """One quantization within a model family. ``filename`` may be a glob.""" 

187 

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 

195 

196 

197@dataclass(frozen=True) 

198class ModelFamily: 

199 """A group of related model variants (e.g. Qwen3 in multiple sizes).""" 

200 

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, ...]