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;
17use tracing::{debug, info, warn};
18
19pub enum ModelOrigin<'a> {
26 LocalFile { param_count_override: Option<f64> },
27 HuggingFace(HfOrigin<'a>),
28}
29
30impl ModelOrigin<'_> {
31 const fn name_source(&self) -> NameSource<'_> {
32 match self {
33 Self::LocalFile { .. } => NameSource::LocalFile,
34 Self::HuggingFace(hf) => NameSource::HuggingFace {
35 repo_id: hf.repo_id,
36 },
37 }
38 }
39}
40
41pub struct HfOrigin<'a> {
43 pub repo_id: &'a str,
44 pub commit_sha: &'a str,
45 pub hf_tags: &'a [String],
46 pub quantization_fallback: Quantization,
48 pub file_paths: Option<&'a [PathBuf]>,
50 pub published_sampling: Option<&'a InferenceConfig>,
59}
60
61fn filter_hf_tags(tags: &[String]) -> Vec<String> {
65 tags.iter()
66 .filter(|tag| {
67 let tag_lower = tag.to_lowercase();
68 !tag_lower.starts_with("arxiv:")
69 && !tag_lower.starts_with("region:")
70 && !tag_lower.starts_with("license:")
71 && !tag_lower.starts_with("dataset:")
72 && tag_lower != "gguf"
73 })
74 .cloned()
75 .collect()
76}
77
78fn merge_tags(gguf_tags: Vec<String>, hf_tags: &[String]) -> Vec<String> {
82 use std::collections::HashSet;
83
84 let mut seen = HashSet::new();
85 let mut result = Vec::new();
86
87 for tag in gguf_tags {
88 if seen.insert(tag.clone()) {
89 result.push(tag);
90 }
91 }
92 for tag in filter_hf_tags(hf_tags) {
93 if seen.insert(tag.clone()) {
94 result.push(tag);
95 }
96 }
97
98 result
99}
100
101pub async fn fetch_published_sampling(
132 client: &dyn crate::ports::huggingface::HfClientPort,
133 repo_id: &str,
134 tags: &[String],
135) -> Option<InferenceConfig> {
136 let candidates = crate::domain::generation_config_candidates(repo_id, tags);
137
138 for candidate in candidates.iter().take(MAX_GENERATION_CONFIG_LOOKUPS) {
139 let body = match client.fetch_generation_config(candidate).await {
140 Ok(Some(body)) => body,
141 Ok(None) => {
142 debug!("{candidate} publishes no generation_config.json");
143 continue;
144 }
145 Err(e) => {
146 info!("could not read {candidate}'s generation_config.json: {e}");
150 continue;
151 }
152 };
153
154 let Some(parsed) = crate::domain::parse_generation_config(&body) else {
155 warn!("{candidate}'s generation_config.json is not a JSON object; ignoring");
156 continue;
157 };
158
159 for reason in &parsed.rejected {
160 warn!("{candidate}'s generation_config.json: {reason}; that value is not applied");
161 }
162 if parsed.requests_greedy {
163 info!(
168 "{candidate} publishes do_sample: false (greedy); gglib does not apply greedy \
169 decoding and is using the published sampler values instead"
170 );
171 }
172 if parsed.is_empty() {
173 debug!("{candidate}'s generation_config.json names no sampler values gglib models");
174 continue;
175 }
176
177 info!("using the sampling recipe {candidate} publishes");
178 return Some(parsed.config);
179 }
180
181 None
182}
183
184pub const MAX_GENERATION_CONFIG_LOOKUPS: usize = 3;
192
193#[must_use]
202pub fn build_new_model(
203 file_path: &Path,
204 gguf: Option<&GgufMetadata>,
205 parser: &dyn GgufParserPort,
206 origin: &ModelOrigin<'_>,
207 added_at: DateTime<Utc>,
208) -> NewModel {
209 let name = resolve_model_name(gguf, file_path, origin.name_source());
210
211 let param_count_b = match origin {
212 ModelOrigin::LocalFile {
213 param_count_override,
214 } => param_count_override
215 .or_else(|| gguf.and_then(|g| g.param_count_b))
216 .unwrap_or(0.0),
217 ModelOrigin::HuggingFace(_) => gguf.and_then(|g| g.param_count_b).unwrap_or(0.0),
218 };
219
220 let gguf_caps = gguf.map(|g| parser.detect_capabilities(g));
221 let gguf_tags = gguf_caps
222 .as_ref()
223 .map_or_else(Vec::new, crate::domain::GgufCapabilities::to_tags);
224
225 let mut model = NewModel::new(name, file_path.to_path_buf(), param_count_b, added_at);
226 model.dialect_spec = gguf_caps.and_then(|c| c.dialect);
227 model.architecture = gguf.and_then(|g| g.architecture.clone());
228 model.context_length = gguf.and_then(|g| g.context_length);
229 model.expert_count = gguf.and_then(|g| g.expert_count);
230 model.expert_used_count = gguf.and_then(|g| g.expert_used_count);
231 model.expert_shared_count = gguf.and_then(|g| g.expert_shared_count);
232 if let Some(g) = gguf {
233 model.metadata.clone_from(&g.metadata);
234 }
235
236 match origin {
237 ModelOrigin::LocalFile { .. } => {
238 model.quantization = gguf.and_then(|g| g.quantization.clone());
239 model.tags = gguf_tags;
240 }
241 ModelOrigin::HuggingFace(hf) => {
242 model.quantization = gguf
243 .and_then(|g| g.quantization.clone())
244 .or_else(|| Some(hf.quantization_fallback.to_string()));
245 model.hf_repo_id = Some(hf.repo_id.to_string());
246 model.hf_commit_sha = Some(hf.commit_sha.to_string());
247 model.hf_filename = Some(file_path.file_name().unwrap().to_string_lossy().to_string());
248 model.download_date = Some(Utc::now());
249 model.file_paths = hf.file_paths.map(<[PathBuf]>::to_vec);
250 model.tags = merge_tags(gguf_tags, hf.hf_tags);
251 }
252 }
253
254 if model.inference_defaults.is_none() {
276 let published = match origin {
277 ModelOrigin::HuggingFace(hf) => hf.published_sampling,
278 ModelOrigin::LocalFile { .. } => None,
279 };
280 if let Some(config) = published {
281 model.inference_defaults = Some(config.clone());
282 model.defaults_origin = Some(DefaultsOrigin::Published);
283 } else if crate::domain::capability_tags::is_reasoning(&model.tags) {
284 model.inference_defaults = Some(InferenceConfig::reasoning_profile());
285 model.defaults_origin = Some(DefaultsOrigin::AutoDetected);
286 }
287 }
288
289 let template = model
295 .metadata
296 .get("tokenizer.chat_template")
297 .map(String::as_str);
298 let declared = crate::domain::declared_name(gguf);
299 let from_template = crate::domain::infer_from_chat_template(template, declared);
300 let from_arch = crate::domain::capabilities_from_architecture(model.architecture.as_deref());
301 model.capabilities = from_template | from_arch;
302
303 model
304}
305
306#[cfg(test)]
307#[allow(clippy::float_cmp)] mod tests {
309 use super::*;
310 use crate::ports::NoopGgufParser;
311 use std::collections::HashMap;
312
313 fn gguf_with(pairs: &[(&str, &str)]) -> GgufMetadata {
314 let mut metadata = HashMap::new();
315 for (k, v) in pairs {
316 metadata.insert((*k).to_string(), (*v).to_string());
317 }
318 GgufMetadata {
319 metadata,
320 ..Default::default()
321 }
322 }
323
324 struct SpecParser;
326
327 impl crate::ports::GgufParserPort for SpecParser {
328 fn parse(
329 &self,
330 _file_path: &Path,
331 ) -> std::result::Result<crate::ports::GgufMetadata, crate::ports::GgufParseError> {
332 Ok(crate::ports::GgufMetadata::default())
333 }
334
335 fn detect_capabilities(
336 &self,
337 _metadata: &crate::ports::GgufMetadata,
338 ) -> crate::ports::GgufCapabilities {
339 crate::ports::GgufCapabilities {
340 flags: crate::domain::gguf::CapabilityFlags::TOOL_CALLING,
341 extensions: std::collections::BTreeSet::new(),
342 dialect: Some(crate::domain::DialectSpec::qwen_xml()),
343 }
344 }
345 }
346
347 fn hf_origin<'a>(repo_id: &'a str, hf_tags: &'a [String]) -> ModelOrigin<'a> {
348 hf_origin_with(repo_id, hf_tags, None)
349 }
350
351 fn hf_origin_with<'a>(
352 repo_id: &'a str,
353 hf_tags: &'a [String],
354 published_sampling: Option<&'a InferenceConfig>,
355 ) -> ModelOrigin<'a> {
356 ModelOrigin::HuggingFace(HfOrigin {
357 repo_id,
358 commit_sha: "abc123",
359 hf_tags,
360 quantization_fallback: Quantization::Q4KM,
361 file_paths: None,
362 published_sampling,
363 })
364 }
365
366 #[test]
367 fn detected_dialect_spec_lands_on_the_model() {
368 let gguf = gguf_with(&[]);
369 let origin = ModelOrigin::LocalFile {
370 param_count_override: None,
371 };
372 let model = build_new_model(
373 Path::new("/models/m.gguf"),
374 Some(&gguf),
375 &SpecParser,
376 &origin,
377 Utc::now(),
378 );
379 assert_eq!(
380 model.dialect_spec,
381 Some(crate::domain::DialectSpec::qwen_xml())
382 );
383 }
384
385 #[test]
388 fn missing_gguf_metadata_means_no_spec() {
389 let hf_tags: Vec<String> = vec![];
390 let origin = hf_origin("some/Repo-GGUF", &hf_tags);
391 let model = build_new_model(
392 Path::new("/models/m.gguf"),
393 None,
394 &SpecParser,
395 &origin,
396 Utc::now(),
397 );
398 assert_eq!(model.dialect_spec, None);
399 }
400
401 #[test]
402 fn local_param_override_beats_gguf_metadata() {
403 let gguf = GgufMetadata {
404 param_count_b: Some(7.0),
405 ..Default::default()
406 };
407 let origin = ModelOrigin::LocalFile {
408 param_count_override: Some(13.0),
409 };
410 let model = build_new_model(
411 Path::new("/models/m.gguf"),
412 Some(&gguf),
413 &NoopGgufParser,
414 &origin,
415 Utc::now(),
416 );
417 assert_eq!(model.param_count_b, 13.0);
418 }
419
420 #[test]
421 fn local_param_falls_back_to_gguf_metadata() {
422 let gguf = GgufMetadata {
423 param_count_b: Some(7.0),
424 ..Default::default()
425 };
426 let origin = ModelOrigin::LocalFile {
427 param_count_override: None,
428 };
429 let model = build_new_model(
430 Path::new("/models/m.gguf"),
431 Some(&gguf),
432 &NoopGgufParser,
433 &origin,
434 Utc::now(),
435 );
436 assert_eq!(model.param_count_b, 7.0);
437 }
438
439 #[test]
440 fn hf_quant_fallback_used_only_when_header_has_none() {
441 let hf_tags: Vec<String> = vec![];
442 let origin = hf_origin("unsloth/Qwen3-8B-GGUF", &hf_tags);
443 let model = build_new_model(
444 Path::new("/models/m.gguf"),
445 None,
446 &NoopGgufParser,
447 &origin,
448 Utc::now(),
449 );
450 assert_eq!(model.quantization, Some(Quantization::Q4KM.to_string()));
451
452 let gguf = GgufMetadata {
453 quantization: Some("Q8_0".to_string()),
454 ..Default::default()
455 };
456 let model = build_new_model(
457 Path::new("/models/m.gguf"),
458 Some(&gguf),
459 &NoopGgufParser,
460 &origin,
461 Utc::now(),
462 );
463 assert_eq!(model.quantization, Some("Q8_0".to_string()));
464 }
465
466 #[test]
467 fn hf_tags_are_merged_deduped_and_filtered() {
468 let hf_tags = vec![
469 "chat".to_string(),
470 "arxiv:1234.5678".to_string(),
471 "region:us".to_string(),
472 "license:apache-2.0".to_string(),
473 "dataset:foo".to_string(),
474 "gguf".to_string(),
475 "chat".to_string(),
476 ];
477 let origin = hf_origin("unsloth/Qwen3-8B-GGUF", &hf_tags);
478 let model = build_new_model(
479 Path::new("/models/m.gguf"),
480 None,
481 &NoopGgufParser,
482 &origin,
483 Utc::now(),
484 );
485 assert_eq!(model.tags, vec!["chat".to_string()]);
486 }
487
488 #[test]
492 fn a_published_recipe_replaces_the_reasoning_tag_guess() {
493 let hf_tags = vec!["reasoning".to_string()];
494 let published = InferenceConfig {
495 temperature: Some(0.6),
496 top_p: Some(0.95),
497 top_k: Some(20),
498 ..InferenceConfig::default()
499 };
500 let origin = hf_origin_with("unsloth/Qwen3-8B-GGUF", &hf_tags, Some(&published));
501
502 let model = build_new_model(
503 Path::new("/models/m.gguf"),
504 None,
505 &NoopGgufParser,
506 &origin,
507 Utc::now(),
508 );
509
510 assert_eq!(model.inference_defaults, Some(published));
511 assert_eq!(model.defaults_origin, Some(DefaultsOrigin::Published));
512 }
513
514 #[test]
520 fn a_published_recipe_is_not_merged_with_the_tag_guess() {
521 let hf_tags = vec!["reasoning".to_string()];
522 let published = InferenceConfig {
523 temperature: Some(0.6),
524 ..InferenceConfig::default()
525 };
526 let origin = hf_origin_with("unsloth/Qwen3-8B-GGUF", &hf_tags, Some(&published));
527
528 let model = build_new_model(
529 Path::new("/models/m.gguf"),
530 None,
531 &NoopGgufParser,
532 &origin,
533 Utc::now(),
534 );
535
536 let stored = model.inference_defaults.expect("defaults stored");
537 assert_eq!(stored.temperature, Some(0.6));
538 assert_eq!(
539 stored.presence_penalty, None,
540 "reasoning_profile's 1.5 must not be grafted on"
541 );
542 assert_eq!(stored.top_p, None, "nor anything else it names");
543 }
544
545 #[test]
549 fn a_published_recipe_ranks_below_global_settings() {
550 let hf_tags: Vec<String> = vec![];
551 let published = InferenceConfig {
552 temperature: Some(0.6),
553 ..InferenceConfig::default()
554 };
555 let origin = hf_origin_with("Qwen/Qwen3-4B", &hf_tags, Some(&published));
556 let model = build_new_model(
557 Path::new("/models/m.gguf"),
558 None,
559 &NoopGgufParser,
560 &origin,
561 Utc::now(),
562 );
563
564 let global = InferenceConfig {
565 temperature: Some(0.9),
566 ..InferenceConfig::default()
567 };
568 let (resolved, _) = InferenceConfig::default().resolve_with_profile_explained(
569 None,
570 model.inference_defaults.as_ref(),
571 Some(&global),
572 crate::domain::ModelSamplingContext {
573 is_reasoning: false,
574 defaults_origin: model.defaults_origin,
575 },
576 );
577
578 assert_eq!(
579 resolved.temperature,
580 Some(0.9),
581 "the operator's global setting must win over a fetched recipe"
582 );
583 }
584
585 #[test]
588 fn a_published_recipe_applies_without_a_reasoning_tag() {
589 let hf_tags: Vec<String> = vec![];
590 let published = InferenceConfig {
591 temperature: Some(0.4),
592 ..InferenceConfig::default()
593 };
594 let origin = hf_origin_with("some/Model", &hf_tags, Some(&published));
595
596 let model = build_new_model(
597 Path::new("/models/m.gguf"),
598 None,
599 &NoopGgufParser,
600 &origin,
601 Utc::now(),
602 );
603
604 assert_eq!(model.defaults_origin, Some(DefaultsOrigin::Published));
605 }
606
607 #[test]
611 fn no_published_recipe_falls_back_to_the_tag_guess() {
612 let hf_tags = vec!["reasoning".to_string()];
613 let origin = hf_origin_with("unsloth/Qwen3-8B-GGUF", &hf_tags, None);
614
615 let model = build_new_model(
616 Path::new("/models/m.gguf"),
617 None,
618 &NoopGgufParser,
619 &origin,
620 Utc::now(),
621 );
622
623 assert_eq!(
624 model.inference_defaults,
625 Some(InferenceConfig::reasoning_profile())
626 );
627 assert_eq!(model.defaults_origin, Some(DefaultsOrigin::AutoDetected));
628 }
629
630 #[test]
631 fn reasoning_tag_sets_inference_defaults_on_both_origins() {
632 let hf_tags = vec!["reasoning".to_string()];
633 let hf = hf_origin("unsloth/Qwen3-8B-GGUF", &hf_tags);
634 let hf_model = build_new_model(
635 Path::new("/models/m.gguf"),
636 None,
637 &NoopGgufParser,
638 &hf,
639 Utc::now(),
640 );
641 assert_eq!(
642 hf_model.inference_defaults,
643 Some(InferenceConfig::reasoning_profile())
644 );
645 assert_eq!(
646 hf_model.defaults_origin,
647 Some(DefaultsOrigin::AutoDetected),
648 "gglib's own guess, not a user choice — must rank below global settings"
649 );
650
651 let gguf = gguf_with(&[]);
652 let local = ModelOrigin::LocalFile {
653 param_count_override: None,
654 };
655 let local_model = build_new_model(
661 Path::new("/models/m.gguf"),
662 Some(&gguf),
663 &NoopGgufParser,
664 &local,
665 Utc::now(),
666 );
667 assert_eq!(local_model.inference_defaults, None);
668 assert_eq!(local_model.defaults_origin, None);
669 }
670
671 #[test]
672 fn gguf_none_falls_back_to_repo_rung_and_hf_only_tags() {
673 let hf_tags = vec!["chat".to_string()];
674 let origin = hf_origin("unsloth/Qwen3.6-27B-MTP-GGUF", &hf_tags);
675 let model = build_new_model(
676 Path::new("/models/m.gguf"),
677 None,
678 &NoopGgufParser,
679 &origin,
680 Utc::now(),
681 );
682 assert_eq!(model.name, "Qwen3.6-27B-MTP");
683 assert_eq!(model.tags, vec!["chat".to_string()]);
684 assert_eq!(
685 model.hf_repo_id,
686 Some("unsloth/Qwen3.6-27B-MTP-GGUF".to_string())
687 );
688 }
689}