Skip to main content

car_inference/
models.rs

1//! Model registry — tracks available Qwen3 models, handles download-on-first-use.
2
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use tracing::info;
7
8use crate::InferenceError;
9
10/// Role a model is suited for.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum ModelRole {
14    /// Fast classification, embedding, routing
15    Small,
16    /// Code reasoning, skill repair, policy eval
17    Medium,
18    /// Full reasoning, complex generation
19    Large,
20    /// Maximum quality via MoE (3B active / 30B total)
21    Expert,
22    /// Dedicated embedding model (semantic similarity, retrieval)
23    Embedding,
24}
25
26/// Metadata about a model in the registry.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ModelInfo {
29    pub name: String,
30    pub hf_repo: String,
31    pub hf_filename: String,
32    pub tokenizer_repo: String,
33    pub role: ModelRole,
34    pub param_count: &'static str,
35    pub quantized_size_mb: u64,
36    pub downloaded: bool,
37}
38
39/// Registry of available models and their local paths.
40pub struct ModelRegistry {
41    models_dir: PathBuf,
42    catalog: Vec<ModelSpec>,
43}
44
45struct ModelSpec {
46    name: &'static str,
47    hf_repo: &'static str,
48    hf_filename: &'static str,
49    tokenizer_repo: &'static str,
50    role: ModelRole,
51    param_count: &'static str,
52    quantized_size_mb: u64,
53}
54
55impl ModelRegistry {
56    pub fn new(models_dir: PathBuf) -> Self {
57        Self {
58            models_dir,
59            catalog: builtin_catalog(),
60        }
61    }
62
63    /// List all models with their download status.
64    pub fn list_models(&self) -> Vec<ModelInfo> {
65        self.catalog
66            .iter()
67            .map(|spec| {
68                let local_path = self.models_dir.join(spec.name).join("model.gguf");
69                ModelInfo {
70                    name: spec.name.to_string(),
71                    hf_repo: spec.hf_repo.to_string(),
72                    hf_filename: spec.hf_filename.to_string(),
73                    tokenizer_repo: spec.tokenizer_repo.to_string(),
74                    role: spec.role,
75                    param_count: spec.param_count,
76                    quantized_size_mb: spec.quantized_size_mb,
77                    downloaded: local_path.exists(),
78                }
79            })
80            .collect()
81    }
82
83    /// Find a catalog entry by name (case-insensitive).
84    fn find_spec(&self, name: &str) -> Option<&ModelSpec> {
85        self.catalog
86            .iter()
87            .find(|s| s.name.eq_ignore_ascii_case(name))
88    }
89
90    /// Ensure a model is downloaded, returning its local directory path.
91    pub async fn ensure_model(&self, name: &str) -> Result<PathBuf, InferenceError> {
92        let spec = self
93            .find_spec(name)
94            .ok_or_else(|| InferenceError::ModelNotFound(name.to_string()))?;
95
96        let model_dir = self.models_dir.join(spec.name);
97        let model_path = model_dir.join("model.gguf");
98        let tokenizer_path = model_dir.join("tokenizer.json");
99
100        // Presence is not integrity: a zero-length partial write or a dangling
101        // symlink (pruned blob) satisfies `exists()` but is unusable. Gate on
102        // `cache_file_usable` so a broken dest re-downloads instead of being
103        // returned as ready.
104        if crate::download::cache_file_usable(&model_path)
105            && crate::download::cache_file_usable(&tokenizer_path)
106        {
107            return Ok(model_dir);
108        }
109
110        // Serialize concurrent ensure/pull/remove of the same model — without
111        // this, two callers can race the dest relink and truncate a shared HF
112        // blob. Mirrors the registry pull path's locking.
113        let _guard = crate::download::acquire_model_lock(spec.name).await;
114
115        std::fs::create_dir_all(&model_dir)?;
116
117        // Download model weights
118        if !crate::download::cache_file_usable(&model_path) {
119            info!(
120                model = spec.name,
121                repo = spec.hf_repo,
122                "downloading model weights"
123            );
124            download_file(spec.hf_repo, spec.hf_filename, &model_path).await?;
125        }
126
127        // Download tokenizer
128        if !crate::download::cache_file_usable(&tokenizer_path) {
129            info!(
130                model = spec.name,
131                repo = spec.tokenizer_repo,
132                "downloading tokenizer"
133            );
134            download_file(spec.tokenizer_repo, "tokenizer.json", &tokenizer_path).await?;
135        }
136
137        Ok(model_dir)
138    }
139
140    /// Legacy removal is deliberately disabled.
141    ///
142    /// Model removal must flow through [`crate::InferenceEngine::remove_model_from_car`]
143    /// so CAR can verify its ownership receipt, drain live allocations, and
144    /// preserve shared Hugging Face artifacts.
145    #[deprecated(note = "use InferenceEngine::remove_model_from_car")]
146    pub fn remove_model(&self, name: &str) -> Result<(), InferenceError> {
147        Err(InferenceError::InferenceFailed(format!(
148            "legacy removal for {name} is disabled; use receipt-backed models.remove"
149        )))
150    }
151}
152
153/// Download a single file from a HuggingFace repo.
154async fn download_file(repo: &str, filename: &str, dest: &Path) -> Result<(), InferenceError> {
155    crate::registry::refuse_fetch_when_never_download(&format!("{repo}/{filename}"))?;
156    let api = crate::hf_cache::api()
157        .build()
158        .map_err(|e| InferenceError::DownloadFailed(e.to_string()))?;
159
160    let repo = api.model(repo.to_string());
161    let path = repo
162        .get(filename)
163        .await
164        .map_err(|e| InferenceError::DownloadFailed(format!("{filename}: {e}")))?;
165
166    // `get()` returns a cached file on mere presence, with no integrity check.
167    // If that cached pointer is dangling/empty (the shared cache was pruned or
168    // a prior download was interrupted), force a real re-download before we
169    // link it into our models dir.
170    let path = if crate::download::cache_file_usable(&path) {
171        path
172    } else {
173        let fresh = repo
174            .download(filename)
175            .await
176            .map_err(|e| InferenceError::DownloadFailed(format!("{filename}: {e}")))?;
177        // The re-download must yield a usable pointer; if it's still
178        // dangling/empty, fail loudly rather than link a broken target.
179        if !crate::download::cache_file_usable(&fresh) {
180            return Err(InferenceError::DownloadFailed(format!(
181                "{filename}: re-download produced an unusable file at {}",
182                fresh.display()
183            )));
184        }
185        fresh
186    };
187
188    // hf-hub caches to its own dir; symlink or copy to our location. Re-link
189    // when our dest is missing OR stale (dangling symlink / zero-length).
190    if crate::download::cache_file_usable(dest) {
191        return Ok(());
192    }
193    // A stale dest (e.g. dangling symlink to a pruned blob) must be cleared
194    // before we can re-create the link/copy.
195    if dest.exists() || std::fs::symlink_metadata(dest).is_ok() {
196        let _ = std::fs::remove_file(dest);
197    }
198
199    // Try symlink first, fall back to copy.
200    #[cfg(unix)]
201    {
202        if std::os::unix::fs::symlink(&path, dest).is_ok() {
203            return Ok(());
204        }
205    }
206
207    // Copy fallback: write to a temp file in the dest dir, then atomically
208    // rename into place. A plain `copy` onto `dest` would, if `dest` were a
209    // surviving symlink, follow it and truncate the shared HF blob; rename
210    // replaces the path itself and never writes through a link.
211    let tmp = dest.with_extension("download.partial");
212    std::fs::copy(&path, &tmp)
213        .map_err(|e| InferenceError::DownloadFailed(format!("copy to {}: {e}", tmp.display())))?;
214    std::fs::rename(&tmp, dest).map_err(|e| {
215        let _ = std::fs::remove_file(&tmp);
216        InferenceError::DownloadFailed(format!("install to {}: {e}", dest.display()))
217    })?;
218    Ok(())
219}
220
221/// Built-in catalog of Qwen3 models.
222fn builtin_catalog() -> Vec<ModelSpec> {
223    vec![
224        ModelSpec {
225            name: "Qwen3-Embedding-0.6B",
226            hf_repo: "Qwen/Qwen3-Embedding-0.6B-GGUF",
227            hf_filename: "Qwen3-Embedding-0.6B-Q8_0.gguf",
228            tokenizer_repo: "Qwen/Qwen3-Embedding-0.6B",
229            role: ModelRole::Embedding,
230            param_count: "0.6B",
231            quantized_size_mb: 639,
232        },
233        ModelSpec {
234            name: "Qwen3-0.6B",
235            hf_repo: "Qwen/Qwen3-0.6B-GGUF",
236            hf_filename: "Qwen3-0.6B-Q8_0.gguf",
237            tokenizer_repo: "Qwen/Qwen3-0.6B",
238            role: ModelRole::Small,
239            param_count: "0.6B",
240            quantized_size_mb: 650,
241        },
242        ModelSpec {
243            name: "Qwen3-1.7B",
244            hf_repo: "Qwen/Qwen3-1.7B-GGUF",
245            hf_filename: "Qwen3-1.7B-Q8_0.gguf",
246            tokenizer_repo: "Qwen/Qwen3-1.7B",
247            role: ModelRole::Medium,
248            param_count: "1.7B",
249            quantized_size_mb: 1800,
250        },
251        ModelSpec {
252            name: "Qwen3-4B",
253            hf_repo: "Qwen/Qwen3-4B-GGUF",
254            hf_filename: "Qwen3-4B-Q4_K_M.gguf",
255            tokenizer_repo: "Qwen/Qwen3-4B",
256            role: ModelRole::Medium,
257            param_count: "4B",
258            quantized_size_mb: 2500,
259        },
260        ModelSpec {
261            name: "Qwen3-8B",
262            hf_repo: "Qwen/Qwen3-8B-GGUF",
263            hf_filename: "Qwen3-8B-Q4_K_M.gguf",
264            tokenizer_repo: "Qwen/Qwen3-8B",
265            role: ModelRole::Large,
266            param_count: "8B",
267            quantized_size_mb: 4900,
268        },
269        ModelSpec {
270            name: "Qwen3-30B-A3B",
271            hf_repo: "Qwen/Qwen3-30B-A3B-GGUF",
272            hf_filename: "Qwen3-30B-A3B-Q4_K_M.gguf",
273            tokenizer_repo: "Qwen/Qwen3-30B-A3B",
274            role: ModelRole::Expert,
275            param_count: "30B (3B active)",
276            quantized_size_mb: 17000,
277        },
278    ]
279}