1use std::fs;
6use std::io::{Read, Write};
7use std::path::{Path, PathBuf};
8
9const TOKENIZER_FILE: &str = "tokenizer.json";
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ModelChoice {
17 Qwen1_5B,
18 Qwen3B,
19}
20
21impl ModelChoice {
22 pub fn parse(s: &str) -> Self {
24 match s.trim().to_ascii_lowercase().as_str() {
25 "3b" | "3" | "qwen3b" => ModelChoice::Qwen3B,
26 _ => ModelChoice::Qwen1_5B,
27 }
28 }
29 fn sources(self) -> (&'static str, &'static str, &'static str) {
31 match self {
32 ModelChoice::Qwen1_5B => (
33 "Qwen/Qwen2.5-Coder-1.5B-Instruct-GGUF",
34 "qwen2.5-coder-1.5b-instruct-q4_k_m.gguf",
35 "Qwen/Qwen2.5-Coder-1.5B",
36 ),
37 ModelChoice::Qwen3B => (
38 "Qwen/Qwen2.5-Coder-3B-Instruct-GGUF",
39 "qwen2.5-coder-3b-instruct-q4_k_m.gguf",
40 "Qwen/Qwen2.5-Coder-3B",
41 ),
42 }
43 }
44 fn tokenizer_cache_name(self) -> &'static str {
46 match self {
47 ModelChoice::Qwen1_5B => "tokenizer-1.5b.json",
48 ModelChoice::Qwen3B => "tokenizer-3b.json",
49 }
50 }
51}
52
53#[derive(Debug, Clone)]
56pub struct DownloadProgress {
57 pub label: &'static str,
59 pub received: u64,
61 pub total: Option<u64>,
63}
64
65#[derive(Debug, Clone)]
67pub struct ModelPaths {
68 pub gguf: PathBuf,
69 pub tokenizer: PathBuf,
70}
71
72pub fn ensure_model(
76 cache_dir: &Path,
77 choice: ModelChoice,
78 progress: &(dyn Fn(DownloadProgress) + Sync),
79) -> Result<ModelPaths, String> {
80 fs::create_dir_all(cache_dir).map_err(|e| format!("create {}: {e}", cache_dir.display()))?;
81 let (gguf_repo, gguf_file, tok_repo) = choice.sources();
82 let gguf = cache_dir.join(gguf_file);
83 let tokenizer = cache_dir.join(choice.tokenizer_cache_name());
84
85 if !tokenizer.exists() {
86 let url = hf_url(tok_repo, TOKENIZER_FILE);
87 download(&url, &tokenizer, "tokenizer", progress)?;
88 }
89 if !gguf.exists() {
90 let url = hf_url(gguf_repo, gguf_file);
91 download(&url, &gguf, "weights", progress)?;
92 }
93 Ok(ModelPaths { gguf, tokenizer })
94}
95
96pub fn is_model_cached(cache_dir: &Path, choice: ModelChoice) -> bool {
98 let (_, gguf_file, _) = choice.sources();
99 cache_dir.join(gguf_file).exists() && cache_dir.join(choice.tokenizer_cache_name()).exists()
100}
101
102fn hf_url(repo: &str, file: &str) -> String {
103 format!("https://huggingface.co/{repo}/resolve/main/{file}")
104}
105
106fn download(
110 url: &str,
111 dest: &Path,
112 label: &'static str,
113 progress: &(dyn Fn(DownloadProgress) + Sync),
114) -> Result<(), String> {
115 let client = reqwest::blocking::Client::builder()
116 .build()
117 .map_err(|e| format!("http client: {e}"))?;
118 let mut resp = client
119 .get(url)
120 .send()
121 .map_err(|e| format!("GET {url}: {e}"))?;
122 if !resp.status().is_success() {
123 return Err(format!("GET {url}: HTTP {}", resp.status()));
124 }
125 let total = resp.content_length();
126 let part = dest.with_extension("part");
127 let mut file =
128 fs::File::create(&part).map_err(|e| format!("create {}: {e}", part.display()))?;
129 let mut buf = [0u8; 64 * 1024];
130 let mut received: u64 = 0;
131 let mut last_report: u64 = 0;
132 loop {
133 let n = resp
134 .read(&mut buf)
135 .map_err(|e| format!("read {label}: {e}"))?;
136 if n == 0 {
137 break;
138 }
139 file.write_all(&buf[..n])
140 .map_err(|e| format!("write {label}: {e}"))?;
141 received += n as u64;
142 if received - last_report >= 4 * 1024 * 1024 {
144 last_report = received;
145 progress(DownloadProgress {
146 label,
147 received,
148 total,
149 });
150 }
151 }
152 file.flush().map_err(|e| format!("flush {label}: {e}"))?;
153 drop(file);
154 fs::rename(&part, dest).map_err(|e| format!("finalize {}: {e}", dest.display()))?;
155 progress(DownloadProgress {
156 label,
157 received,
158 total,
159 });
160 Ok(())
161}