1use std::path::{Path, PathBuf};
9
10use chrono::{DateTime, Utc};
11
12use crate::domain::{
13 DefaultsOrigin, GgufMetadata, InferenceConfig, NameSource, NewModel, resolve_model_name,
14};
15use crate::download::Quantization;
16use crate::ports::GgufParserPort;
17
18pub enum ModelOrigin<'a> {
25 LocalFile { param_count_override: Option<f64> },
26 HuggingFace(HfOrigin<'a>),
27}
28
29impl ModelOrigin<'_> {
30 const fn name_source(&self) -> NameSource<'_> {
31 match self {
32 Self::LocalFile { .. } => NameSource::LocalFile,
33 Self::HuggingFace(hf) => NameSource::HuggingFace {
34 repo_id: hf.repo_id,
35 },
36 }
37 }
38}
39
40pub struct HfOrigin<'a> {
42 pub repo_id: &'a str,
43 pub commit_sha: &'a str,
44 pub hf_tags: &'a [String],
45 pub quantization_fallback: Quantization,
47 pub file_paths: Option<&'a [PathBuf]>,
49}
50
51fn filter_hf_tags(tags: &[String]) -> Vec<String> {
55 tags.iter()
56 .filter(|tag| {
57 let tag_lower = tag.to_lowercase();
58 !tag_lower.starts_with("arxiv:")
59 && !tag_lower.starts_with("region:")
60 && !tag_lower.starts_with("license:")
61 && !tag_lower.starts_with("dataset:")
62 && tag_lower != "gguf"
63 })
64 .cloned()
65 .collect()
66}
67
68fn merge_tags(gguf_tags: Vec<String>, hf_tags: &[String]) -> Vec<String> {
72 use std::collections::HashSet;
73
74 let mut seen = HashSet::new();
75 let mut result = Vec::new();
76
77 for tag in gguf_tags {
78 if seen.insert(tag.clone()) {
79 result.push(tag);
80 }
81 }
82 for tag in filter_hf_tags(hf_tags) {
83 if seen.insert(tag.clone()) {
84 result.push(tag);
85 }
86 }
87
88 result
89}
90
91#[must_use]
100pub fn build_new_model(
101 file_path: &Path,
102 gguf: Option<&GgufMetadata>,
103 parser: &dyn GgufParserPort,
104 origin: &ModelOrigin<'_>,
105 added_at: DateTime<Utc>,
106) -> NewModel {
107 let name = resolve_model_name(gguf, file_path, origin.name_source());
108
109 let param_count_b = match origin {
110 ModelOrigin::LocalFile {
111 param_count_override,
112 } => param_count_override
113 .or_else(|| gguf.and_then(|g| g.param_count_b))
114 .unwrap_or(0.0),
115 ModelOrigin::HuggingFace(_) => gguf.and_then(|g| g.param_count_b).unwrap_or(0.0),
116 };
117
118 let gguf_tags = gguf.map_or_else(Vec::new, |g| parser.detect_capabilities(g).to_tags());
119
120 let mut model = NewModel::new(name, file_path.to_path_buf(), param_count_b, added_at);
121 model.architecture = gguf.and_then(|g| g.architecture.clone());
122 model.context_length = gguf.and_then(|g| g.context_length);
123 model.expert_count = gguf.and_then(|g| g.expert_count);
124 model.expert_used_count = gguf.and_then(|g| g.expert_used_count);
125 model.expert_shared_count = gguf.and_then(|g| g.expert_shared_count);
126 if let Some(g) = gguf {
127 model.metadata.clone_from(&g.metadata);
128 }
129
130 match origin {
131 ModelOrigin::LocalFile { .. } => {
132 model.quantization = gguf.and_then(|g| g.quantization.clone());
133 model.tags = gguf_tags;
134 }
135 ModelOrigin::HuggingFace(hf) => {
136 model.quantization = gguf
137 .and_then(|g| g.quantization.clone())
138 .or_else(|| Some(hf.quantization_fallback.to_string()));
139 model.hf_repo_id = Some(hf.repo_id.to_string());
140 model.hf_commit_sha = Some(hf.commit_sha.to_string());
141 model.hf_filename = Some(file_path.file_name().unwrap().to_string_lossy().to_string());
142 model.download_date = Some(Utc::now());
143 model.file_paths = hf.file_paths.map(<[PathBuf]>::to_vec);
144 model.tags = merge_tags(gguf_tags, hf.hf_tags);
145 }
146 }
147
148 if model.inference_defaults.is_none()
157 && model
158 .tags
159 .iter()
160 .any(|t| t.eq_ignore_ascii_case("reasoning"))
161 {
162 model.inference_defaults = Some(InferenceConfig::reasoning_profile());
163 model.defaults_origin = Some(DefaultsOrigin::AutoDetected);
164 }
165
166 let template = model
172 .metadata
173 .get("tokenizer.chat_template")
174 .map(String::as_str);
175 let declared = crate::domain::declared_name(gguf);
176 let from_template = crate::domain::infer_from_chat_template(template, declared);
177 let from_arch = crate::domain::capabilities_from_architecture(model.architecture.as_deref());
178 model.capabilities = from_template | from_arch;
179
180 model
181}
182
183#[cfg(test)]
184#[allow(clippy::float_cmp)] mod tests {
186 use super::*;
187 use crate::ports::NoopGgufParser;
188 use std::collections::HashMap;
189
190 fn gguf_with(pairs: &[(&str, &str)]) -> GgufMetadata {
191 let mut metadata = HashMap::new();
192 for (k, v) in pairs {
193 metadata.insert((*k).to_string(), (*v).to_string());
194 }
195 GgufMetadata {
196 metadata,
197 ..Default::default()
198 }
199 }
200
201 fn hf_origin<'a>(repo_id: &'a str, hf_tags: &'a [String]) -> ModelOrigin<'a> {
202 ModelOrigin::HuggingFace(HfOrigin {
203 repo_id,
204 commit_sha: "abc123",
205 hf_tags,
206 quantization_fallback: Quantization::Q4KM,
207 file_paths: None,
208 })
209 }
210
211 #[test]
212 fn local_param_override_beats_gguf_metadata() {
213 let gguf = GgufMetadata {
214 param_count_b: Some(7.0),
215 ..Default::default()
216 };
217 let origin = ModelOrigin::LocalFile {
218 param_count_override: Some(13.0),
219 };
220 let model = build_new_model(
221 Path::new("/models/m.gguf"),
222 Some(&gguf),
223 &NoopGgufParser,
224 &origin,
225 Utc::now(),
226 );
227 assert_eq!(model.param_count_b, 13.0);
228 }
229
230 #[test]
231 fn local_param_falls_back_to_gguf_metadata() {
232 let gguf = GgufMetadata {
233 param_count_b: Some(7.0),
234 ..Default::default()
235 };
236 let origin = ModelOrigin::LocalFile {
237 param_count_override: None,
238 };
239 let model = build_new_model(
240 Path::new("/models/m.gguf"),
241 Some(&gguf),
242 &NoopGgufParser,
243 &origin,
244 Utc::now(),
245 );
246 assert_eq!(model.param_count_b, 7.0);
247 }
248
249 #[test]
250 fn hf_quant_fallback_used_only_when_header_has_none() {
251 let hf_tags: Vec<String> = vec![];
252 let origin = hf_origin("unsloth/Qwen3-8B-GGUF", &hf_tags);
253 let model = build_new_model(
254 Path::new("/models/m.gguf"),
255 None,
256 &NoopGgufParser,
257 &origin,
258 Utc::now(),
259 );
260 assert_eq!(model.quantization, Some(Quantization::Q4KM.to_string()));
261
262 let gguf = GgufMetadata {
263 quantization: Some("Q8_0".to_string()),
264 ..Default::default()
265 };
266 let model = build_new_model(
267 Path::new("/models/m.gguf"),
268 Some(&gguf),
269 &NoopGgufParser,
270 &origin,
271 Utc::now(),
272 );
273 assert_eq!(model.quantization, Some("Q8_0".to_string()));
274 }
275
276 #[test]
277 fn hf_tags_are_merged_deduped_and_filtered() {
278 let hf_tags = vec![
279 "chat".to_string(),
280 "arxiv:1234.5678".to_string(),
281 "region:us".to_string(),
282 "license:apache-2.0".to_string(),
283 "dataset:foo".to_string(),
284 "gguf".to_string(),
285 "chat".to_string(),
286 ];
287 let origin = hf_origin("unsloth/Qwen3-8B-GGUF", &hf_tags);
288 let model = build_new_model(
289 Path::new("/models/m.gguf"),
290 None,
291 &NoopGgufParser,
292 &origin,
293 Utc::now(),
294 );
295 assert_eq!(model.tags, vec!["chat".to_string()]);
296 }
297
298 #[test]
299 fn reasoning_tag_sets_inference_defaults_on_both_origins() {
300 let hf_tags = vec!["reasoning".to_string()];
301 let hf = hf_origin("unsloth/Qwen3-8B-GGUF", &hf_tags);
302 let hf_model = build_new_model(
303 Path::new("/models/m.gguf"),
304 None,
305 &NoopGgufParser,
306 &hf,
307 Utc::now(),
308 );
309 assert_eq!(
310 hf_model.inference_defaults,
311 Some(InferenceConfig::reasoning_profile())
312 );
313 assert_eq!(
314 hf_model.defaults_origin,
315 Some(DefaultsOrigin::AutoDetected),
316 "gglib's own guess, not a user choice — must rank below global settings"
317 );
318
319 let gguf = gguf_with(&[]);
320 let local = ModelOrigin::LocalFile {
321 param_count_override: None,
322 };
323 let local_model = build_new_model(
329 Path::new("/models/m.gguf"),
330 Some(&gguf),
331 &NoopGgufParser,
332 &local,
333 Utc::now(),
334 );
335 assert_eq!(local_model.inference_defaults, None);
336 assert_eq!(local_model.defaults_origin, None);
337 }
338
339 #[test]
340 fn gguf_none_falls_back_to_repo_rung_and_hf_only_tags() {
341 let hf_tags = vec!["chat".to_string()];
342 let origin = hf_origin("unsloth/Qwen3.6-27B-MTP-GGUF", &hf_tags);
343 let model = build_new_model(
344 Path::new("/models/m.gguf"),
345 None,
346 &NoopGgufParser,
347 &origin,
348 Utc::now(),
349 );
350 assert_eq!(model.name, "Qwen3.6-27B-MTP");
351 assert_eq!(model.tags, vec!["chat".to_string()]);
352 assert_eq!(
353 model.hf_repo_id,
354 Some("unsloth/Qwen3.6-27B-MTP-GGUF".to_string())
355 );
356 }
357}