1use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use tracing::info;
7
8use crate::InferenceError;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum ModelRole {
14 Small,
16 Medium,
18 Large,
20 Expert,
22 Embedding,
24}
25
26#[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
39pub 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 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 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 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 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 let _guard = crate::download::acquire_model_lock(spec.name).await;
114
115 std::fs::create_dir_all(&model_dir)?;
116
117 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 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 #[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
153async 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 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 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 if crate::download::cache_file_usable(dest) {
191 return Ok(());
192 }
193 if dest.exists() || std::fs::symlink_metadata(dest).is_ok() {
196 let _ = std::fs::remove_file(dest);
197 }
198
199 #[cfg(unix)]
201 {
202 if std::os::unix::fs::symlink(&path, dest).is_ok() {
203 return Ok(());
204 }
205 }
206
207 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
221fn 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}