1use std::fmt;
4use std::fs::{self, File};
5use std::io::{Read, Write};
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicBool, Ordering};
8
9use sha2::{Digest, Sha256};
10
11use crate::Machine;
12
13const MEMORY_LEFT_FREE: u64 = 2_000_000_000;
15const DISK_LEFT_FREE: u64 = 2_000_000_000;
17const CHUNK: usize = 1 << 16;
18const PART: &str = "part";
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub struct ModelFile {
24 pub name: &'static str,
26 pub url: &'static str,
28 pub bytes: u64,
30 pub sha256: &'static str,
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36#[non_exhaustive]
37pub enum ModelFamily {
38 Qwen3,
40 Qwen2,
42 Gemma3,
44 Llama3,
46 Phi3,
48 Mistral,
50 Lfm3,
52 Whisper,
54 Kokoro,
56}
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub struct LocalModel {
61 pub id: &'static str,
63 pub name: &'static str,
65 pub license: &'static str,
67 pub family: ModelFamily,
69 pub memory: u64,
71 pub understood: Option<u8>,
73 pub files: &'static [ModelFile],
75}
76
77impl LocalModel {
78 pub fn bytes(&self) -> u64 {
80 self.files.iter().map(|file| file.bytes).sum()
81 }
82
83 pub fn fits(&self, machine: &Machine, installed: bool) -> bool {
86 let memory = machine.memory >= self.memory.saturating_add(MEMORY_LEFT_FREE);
87 let disk = installed || machine.free_disk >= self.bytes().saturating_add(DISK_LEFT_FREE);
88 memory && disk
89 }
90}
91
92#[derive(Clone, Debug, PartialEq, Eq)]
94#[non_exhaustive]
95pub enum ModelState {
96 Absent,
98 Installed,
100}
101
102#[derive(Clone, Debug, PartialEq, Eq)]
104#[non_exhaustive]
105pub enum ModelError {
106 Network(String),
108 Disk(String),
110 WrongFile,
112 Cancelled,
114}
115
116impl fmt::Display for ModelError {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 match self {
119 Self::Network(why) => write!(f, "the download failed: {why}"),
120 Self::Disk(why) => write!(f, "the model could not be written: {why}"),
121 Self::WrongFile => f.write_str("the file is not the published model"),
122 Self::Cancelled => f.write_str("the download was cancelled"),
123 }
124 }
125}
126
127impl std::error::Error for ModelError {}
128
129fn disk(error: std::io::Error) -> ModelError {
130 ModelError::Disk(error.to_string())
131}
132
133#[derive(Clone, Debug, PartialEq, Eq)]
159pub struct ModelFolder {
160 root: PathBuf,
161}
162
163impl ModelFolder {
164 pub fn new(root: impl Into<PathBuf>) -> Self {
166 Self { root: root.into() }
167 }
168
169 pub fn root(&self) -> &Path {
171 &self.root
172 }
173
174 fn folder_of(&self, model: &LocalModel) -> PathBuf {
175 self.root.join(model.id)
176 }
177
178 pub fn path_of(&self, model: &LocalModel, file: &ModelFile) -> PathBuf {
180 self.folder_of(model).join(file.name)
181 }
182
183 pub fn state(&self, model: &LocalModel) -> ModelState {
186 let whole = model
187 .files
188 .iter()
189 .all(|file| fs::metadata(self.path_of(model, file)).is_ok_and(|found| found.len() == file.bytes));
190 if whole { ModelState::Installed } else { ModelState::Absent }
191 }
192
193 pub fn download(
204 &self,
205 model: &LocalModel,
206 progress: &dyn Fn(u64, u64),
207 cancelled: &AtomicBool,
208 ) -> Result<(), ModelError> {
209 fs::create_dir_all(self.folder_of(model)).map_err(disk)?;
210 let total = model.bytes();
211 let mut done = 0;
212 for file in model.files {
213 let path = self.path_of(model, file);
214 if fs::metadata(&path).is_ok_and(|found| found.len() == file.bytes) {
215 done += file.bytes;
216 progress(done, total);
217 continue;
218 }
219 let part = path.with_extension(PART);
220 let result = fetch(file, &part, done, total, progress, cancelled);
221 if result.is_err() {
222 let _ = fs::remove_file(&part);
224 }
225 result?;
226 fs::rename(&part, &path).map_err(disk)?;
227 done += file.bytes;
228 }
229 Ok(())
230 }
231
232 pub fn remove(&self, model: &LocalModel) -> Result<(), ModelError> {
238 match fs::remove_dir_all(self.folder_of(model)) {
239 Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(disk(error)),
240 _ => Ok(()),
241 }
242 }
243}
244
245fn fetch(
247 file: &ModelFile,
248 part: &Path,
249 before: u64,
250 total: u64,
251 progress: &dyn Fn(u64, u64),
252 cancelled: &AtomicBool,
253) -> Result<(), ModelError> {
254 let response = ureq::get(file.url).call().map_err(|error| ModelError::Network(error.to_string()))?;
255 let mut reader = response.into_body().into_reader();
256 let mut out = File::create(part).map_err(disk)?;
257 let mut hash = Sha256::new();
258 let mut buffer = vec![0_u8; CHUNK];
259 let mut received = 0_u64;
260 loop {
261 if cancelled.load(Ordering::Relaxed) {
262 return Err(ModelError::Cancelled);
263 }
264 let read = reader.read(&mut buffer).map_err(|error| ModelError::Network(error.to_string()))?;
265 if read == 0 {
266 break;
267 }
268 let chunk = buffer.get(..read).unwrap_or_default();
269 hash.update(chunk);
270 out.write_all(chunk).map_err(disk)?;
271 received += read as u64;
272 progress(before + received, total);
273 }
274 out.flush().map_err(disk)?;
275 let found: String = hash.finalize().iter().map(|byte| format!("{byte:02x}")).collect();
276 if found == file.sha256 && received == file.bytes { Ok(()) } else { Err(ModelError::WrongFile) }
277}