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

86 statements  

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

1"""Catalog dataclasses and pydantic types. Imports only the catalog's leaf modules.""" 

2 

3import re 

4from dataclasses import dataclass 

5 

6from pydantic import BaseModel 

7 

8from lilbee.catalog.refs import quant_label 

9from lilbee.catalog.types import ModelCompat, ModelTask 

10 

11# Minimum recommended floor so a tiny model still reports a sane RAM ask. 

12_MIN_RAM_FLOOR_GB = 2.0 

13# Working-set multiple over the on-disk size (weights + KV cache + overhead). 

14_RAM_OVER_SIZE_FACTOR = 1.5 

15 

16_BYTES_PER_GB = 1024**3 

17 

18# Whole-file bytes per parameter for each llama.cpp quantization. 

19# 

20# Not the same quantity as ``gguf.GGML_QUANT_SIZES``, which gives the block size 

21# of one ggml tensor type. llama.cpp never writes a homogeneous file: it promotes 

22# ``output.weight`` and tied ``token_embd`` to Q6_K/Q8_0 whatever the ftype, and 

23# leaves norms in F32, so a real file always costs more per weight than its 

24# nominal type. These are measured file sizes; Qwen3-8B-GGUF publishes 0.614 

25# (Q4_K_M), 0.699 (Q5_0), 0.714 (Q5_K_M), 0.821 (Q6_K) and 1.063 (Q8_0). 

26# 

27# No entry may sit below its base type's bytes per weight, which is physically 

28# impossible; ``test_measured_quants_are_above_their_ggml_floor`` checks each one 

29# against ``gguf.constants.GGML_QUANT_SIZES`` so a typo cannot survive review. 

30_BYTES_PER_PARAM: dict[str, float] = { 

31 "Q2_K": 0.33, 

32 "Q3_K_S": 0.45, 

33 "Q3_K_M": 0.488, 

34 "Q3_K_L": 0.53, 

35 "IQ4_XS": 0.532, 

36 "Q4_0": 0.569, 

37 "Q4_K_S": 0.575, 

38 "Q4_K_M": 0.614, 

39 "Q5_0": 0.699, 

40 "Q5_K_S": 0.688, 

41 "Q5_K_M": 0.714, 

42 "Q6_K": 0.821, 

43 "Q8_0": 1.063, 

44 "F16": 2.0, 

45 "BF16": 2.0, 

46 "F32": 4.0, 

47} 

48 

49# Q4_K_M heads the pull path's quant preference, so a label naming no bit width 

50# at all estimates as if it were the quant a pull would most likely land on. 

51_DEFAULT_BYTES_PER_PARAM = _BYTES_PER_PARAM["Q4_K_M"] 

52 

53# A quant the table does not name still says how many bits it packs. One fp16 

54# scale per group costs an eighth on top, whatever the width, because a group is 

55# sized to the width: 1-bit in groups of 128, 2-bit in 64, 4-bit in 32 all carry 

56# two bytes per group. Reading the width beats falling back to Q4_K_M, which 

57# reports a 2-bit file at more than twice its size. 

58_SCALE_OVERHEAD = 1.125 

59_BITS_PER_BYTE = 8 

60 

61 

62def _packed_bits(quant: str) -> int | None: 

63 """The bit width *quant* packs each weight into, or None if it names none.""" 

64 match = re.match(r"I?Q(\d)", quant) 

65 return int(match.group(1)) if match else None 

66 

67 

68def _quant_bytes_per_param(gguf_filename: str) -> float: 

69 """Bytes per weight for the quant *gguf_filename* names.""" 

70 quant = quant_label(gguf_filename) 

71 measured = _BYTES_PER_PARAM.get(quant) 

72 if measured is not None: 

73 return measured 

74 bits = _packed_bits(quant) 

75 if bits is None: 

76 return _DEFAULT_BYTES_PER_PARAM 

77 return bits / _BITS_PER_BYTE * _SCALE_OVERHEAD 

78 

79 

80def estimate_min_ram_gb(size_gb: float) -> float: 

81 """Estimate the RAM a model needs from its on-disk size (single source).""" 

82 return round(max(_MIN_RAM_FLOOR_GB, size_gb * _RAM_OVER_SIZE_FACTOR), 1) 

83 

84 

85def estimate_size_gb(params: int, gguf_filename: str) -> float: 

86 """Estimate the on-disk GB of *gguf_filename* from a model's parameter count. 

87 

88 The HF listing API reports a parameter count (``gguf.total``) but no 

89 per-file byte size; siblings carry no ``size`` on either the list or the 

90 detail endpoint, and ``gguf.totalFileSize`` sums every quant in the repo 

91 rather than the one file a pull fetches. Per-file bytes are only available 

92 from ``/tree/main``, which is one extra request per repo and unaffordable 

93 for a catalog page. Parameters times the quant's bytes-per-weight gets 

94 within a few percent for a fraction of the cost. 

95 """ 

96 if params <= 0: 

97 return 0.0 # unknown: display as "?" in UI 

98 return round(params * _quant_bytes_per_param(gguf_filename) / _BYTES_PER_GB, 1) 

99 

100 

101class HfGgufMeta(BaseModel): 

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

103 

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

105 

106 ``total`` is the model's parameter count, not a byte size; ``totalFileSize`` 

107 holds bytes. Verified against repos that name their own parameter count: 

108 Qwen3-8B-GGUF reports ``total=8_190_000_000`` against 4.7 GB of files. 

109 """ 

110 

111 total: int = 0 

112 architecture: str = "" 

113 context_length: int = 0 

114 

115 

116@dataclass 

117class DownloadProgress: 

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

119 

120 ``percent`` is a float (0.0 to 100.0) so the ProgressBar renders smooth 

121 fractional movement during multi-GB downloads. Call sites that need 

122 an integer for display format it themselves. 

123 """ 

124 

125 percent: float 

126 detail: str 

127 is_cache_hit: bool 

128 

129 

130@dataclass(frozen=True) 

131class CatalogModel: 

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

133 

134 hf_repo: str 

135 gguf_filename: str 

136 size_gb: float 

137 min_ram_gb: float 

138 description: str 

139 featured: bool 

140 downloads: int 

141 task: ModelTask 

142 architecture: str = "" 

143 compat: ModelCompat = ModelCompat.UNKNOWN 

144 # Parameter count. Size buckets key off this rather than on-disk bytes so a 

145 # model keeps its bucket across quants. 0 when the repo publishes no GGUF 

146 # metadata. 

147 params: int = 0 

148 

149 @property 

150 def ref(self) -> str: 

151 """Browse-time ref (the HF repo); concrete filename is resolved at install.""" 

152 return self.hf_repo 

153 

154 @property 

155 def display_name(self) -> str: 

156 """Human-readable label derived from the HuggingFace repo id.""" 

157 # circular: models -> formatting via clean_display_name 

158 from lilbee.catalog.formatting import clean_display_name 

159 

160 return clean_display_name(self.hf_repo) 

161 

162 

163@dataclass(frozen=True) 

164class CatalogResult: 

165 """Paginated catalog result.""" 

166 

167 total: int 

168 limit: int 

169 offset: int 

170 models: list[CatalogModel] 

171 has_more: bool = False 

172 

173 

174@dataclass(frozen=True) 

175class HfPage: 

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

177 

178 models: list[CatalogModel] 

179 has_more: bool 

180 

181 

182@dataclass(frozen=True) 

183class ModelVariant: 

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

185 

186 hf_repo: str 

187 filename: str 

188 param_count: str 

189 quant: str 

190 size_mb: int 

191 mmproj_filename: str = "" 

192 compat: ModelCompat = ModelCompat.UNKNOWN 

193 

194 

195@dataclass(frozen=True) 

196class ModelFamily: 

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

198 

199 slug: str # family slug for building refs: "qwen3" 

200 name: str # display name: "Qwen3" 

201 task: ModelTask 

202 description: str 

203 variants: tuple[ModelVariant, ...]