Skip to main content

nika_engine/core/
models.rs

1//! Curated model definitions for native inference via mistral.rs.
2//!
3//! The pure data catalog (types, statics, lookup functions) lives in
4//! [`nika_core::catalogs::models`]. This module re-exports everything from
5//! there and adds runtime-dependent functions that require `crate::` access:
6//!
7//! - [`detect_available_ram_gb`] -- reads system RAM via `crate::util::system`
8//! - [`resolve_model`] -- resolves a model ID to a local HuggingFace cache path
9//! - [`ResolvedModel`] -- result of model resolution
10
11// Re-export the entire catalog from nika-core
12pub use nika_core::catalogs::models::*;
13
14use std::path::PathBuf;
15
16/// A resolved model with full path information.
17#[derive(Debug, Clone)]
18pub struct ResolvedModel {
19    /// The known model definition
20    pub model: &'static KnownModel,
21    /// Selected quantization
22    pub quantization: Quantization,
23    /// Full path to the GGUF file
24    pub path: PathBuf,
25}
26
27/// Detect available system RAM in gigabytes with headroom.
28///
29/// Returns 80% of total RAM to leave headroom for the OS and other processes.
30/// This is used for auto-selecting quantization levels.
31///
32/// This is a re-export of [`crate::util::system::get_available_ram_gb`]
33/// compatibility. New code should use `crate::util::system` directly.
34#[must_use]
35pub fn detect_available_ram_gb() -> u32 {
36    crate::util::system::get_available_ram_gb()
37}
38
39/// Resolve a model ID to a full path.
40///
41/// Checks the HuggingFace cache directory for downloaded models.
42///
43/// # Arguments
44///
45/// * `id` - Model ID (e.g., "qwen3:8b")
46/// * `quantization` - Optional specific quantization (auto-selects if None)
47///
48/// # Returns
49///
50/// * `Ok(ResolvedModel)` if model is found and downloaded
51/// * `Err(ModelResolveError)` if model not found or not downloaded
52pub fn resolve_model(
53    id: &str,
54    quantization: Option<Quantization>,
55) -> Result<ResolvedModel, ModelResolveError> {
56    let model =
57        find_model(id).ok_or_else(|| ModelResolveError::UnknownModel { id: id.to_string() })?;
58
59    let quant =
60        quantization.unwrap_or_else(|| auto_select_quantization(model, detect_available_ram_gb()));
61
62    // Find the filename for this quantization
63    let filename = model
64        .quantizations
65        .iter()
66        .find(|(q, _)| *q == quant)
67        .map(|(_, f)| *f)
68        .ok_or_else(|| ModelResolveError::QuantizationNotAvailable {
69            quantization: quant,
70            model_id: id.to_string(),
71        })?;
72
73    // Check HuggingFace cache
74    let cache_dir = dirs::home_dir()
75        .ok_or(ModelResolveError::HomeDirectoryNotFound)?
76        .join(".cache/huggingface/hub");
77
78    // HF cache uses repo name with -- separator
79    let repo_dir_name = format!("models--{}", model.hf_repo.replace('/', "--"));
80    let snapshots_dir = cache_dir.join(&repo_dir_name).join("snapshots");
81
82    // Find the latest snapshot
83    if !snapshots_dir.exists() {
84        return Err(ModelResolveError::ModelNotDownloaded {
85            model_id: id.to_string(),
86        });
87    }
88
89    // Get the most recent snapshot directory
90    let snapshot = std::fs::read_dir(&snapshots_dir)
91        .map_err(|e| ModelResolveError::SnapshotsDirReadError {
92            path: snapshots_dir.clone(),
93            message: e.to_string(),
94        })?
95        .filter_map(|e| e.ok())
96        .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
97        .max_by_key(|e| e.metadata().and_then(|m| m.modified()).ok());
98
99    let snapshot_dir = snapshot.ok_or_else(|| ModelResolveError::NoSnapshotsFound {
100        model_id: id.to_string(),
101    })?;
102    let model_path = snapshot_dir.path().join(filename);
103
104    if !model_path.exists() {
105        return Err(ModelResolveError::ModelFileNotFound {
106            path: model_path,
107            model_id: id.to_string(),
108        });
109    }
110
111    Ok(ResolvedModel {
112        model,
113        quantization: quant,
114        path: model_path,
115    })
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    // =========================================================================
123    // Tests for runtime functions only.
124    // Catalog tests (types, statics, find_model, etc.) live in nika-core.
125    // =========================================================================
126
127    #[test]
128    fn test_resolve_model_unknown_id() {
129        let err = resolve_model("nonexistent:model", None).unwrap_err();
130        match err {
131            ModelResolveError::UnknownModel { id } => {
132                assert_eq!(id, "nonexistent:model");
133            }
134            other => panic!("Expected UnknownModel, got: {:?}", other),
135        }
136    }
137
138    #[test]
139    fn test_resolve_model_unavailable_quantization() {
140        // qwen3:1.7b only has Q8_0 -- request F16 should fail
141        let err = resolve_model("qwen3:1.7b", Some(Quantization::F16)).unwrap_err();
142        match err {
143            ModelResolveError::QuantizationNotAvailable {
144                quantization,
145                model_id,
146            } => {
147                assert_eq!(quantization, Quantization::F16);
148                assert_eq!(model_id, "qwen3:1.7b");
149            }
150            other => panic!("Expected QuantizationNotAvailable, got: {:?}", other),
151        }
152    }
153}