1use std::path::{Path, PathBuf};
2
3use anyhow::Context;
4use futures_util::{stream, StreamExt};
5use serde::Deserialize;
6use tokio::io::AsyncWriteExt;
7
8use crate::http::{build_client, HF_BASE};
9
10pub(crate) const SHARD_DOWNLOAD_CONCURRENCY: usize = 4;
14
15pub struct ModelFiles {
17 pub config_json: PathBuf,
18 pub safetensors_shards: Vec<PathBuf>,
20}
21
22impl ModelFiles {
23 pub fn cleanup_weights(&self) {
29 for f in &self.safetensors_shards {
30 if let Err(e) = std::fs::remove_file(f) {
31 eprintln!("warning: could not remove {}: {e}", f.display());
32 }
33 }
34 }
35}
36
37pub fn canonical_gguf_path(model_dir: &Path, repo_id: &str) -> PathBuf {
42 model_dir.join(repo_id.replace('/', "__")).join("model-f32.gguf")
43}
44
45pub async fn download_model(
48 repo_id: &str,
49 hf_token: Option<&str>,
50 model_dir: &Path,
51) -> anyhow::Result<ModelFiles> {
52 download_model_prefixed(repo_id, "", hf_token, model_dir).await
53}
54
55pub async fn download_file(
59 repo_id: &str,
60 relpath: &str,
61 hf_token: Option<&str>,
62 dest_dir: &Path,
63) -> anyhow::Result<PathBuf> {
64 let client = build_client(hf_token)?;
65 std::fs::create_dir_all(dest_dir).context("create dest dir")?;
66 fetch_file(&client, repo_id, relpath, dest_dir, None).await
67}
68
69pub async fn download_model_prefixed(
76 repo_id: &str,
77 prefix: &str,
78 hf_token: Option<&str>,
79 model_dir: &Path,
80) -> anyhow::Result<ModelFiles> {
81 let client = build_client(hf_token)?;
82 let model_dir = model_dir.join(repo_id.replace('/', "__"));
90 let model_dir = model_dir.as_path();
91 std::fs::create_dir_all(model_dir).context("create model dir")?;
92
93 let config_rel = joined(prefix, "config.json");
94 println!("Fetching {config_rel} …");
95 let config_json = fetch_file(&client, repo_id, &config_rel, model_dir, None).await?;
96
97 let index_rel = joined(prefix, "model.safetensors.index.json");
99 let single_rel = joined(prefix, "model.safetensors");
100
101 let shards = match fetch_file(&client, repo_id, &index_rel, model_dir, None).await {
102 Ok(index_path) => {
103 println!("Found sharded model — reading index …");
104 resolve_shards(&client, repo_id, &index_path, model_dir, prefix).await?
105 }
106 Err(_) => {
107 println!("Fetching {single_rel} …");
108 let shard = fetch_file(&client, repo_id, &single_rel, model_dir, None).await?;
109 vec![shard]
110 }
111 };
112
113 Ok(ModelFiles {
114 config_json,
115 safetensors_shards: shards,
116 })
117}
118
119fn joined(prefix: &str, filename: &str) -> String {
120 if prefix.is_empty() {
121 filename.to_string()
122 } else {
123 format!("{prefix}/{filename}")
124 }
125}
126
127pub(crate) async fn fetch_file(
135 client: &reqwest::Client,
136 repo_id: &str,
137 relpath: &str,
138 dest_dir: &Path,
139 mp: Option<&indicatif::MultiProgress>,
140) -> anyhow::Result<PathBuf> {
141 let dest = dest_dir.join(relpath.replace('/', "_"));
142 if dest.exists() {
143 println!(" (cached) {relpath}");
144 return Ok(dest);
145 }
146
147 let dest_tmp = dest.with_extension("tmp");
148 let already = if dest_tmp.exists() {
149 dest_tmp.metadata()?.len()
150 } else {
151 0
152 };
153
154 let url = format!("{HF_BASE}/{repo_id}/resolve/main/{relpath}");
155
156 let mut req = client.get(&url);
157 if already > 0 {
158 req = req.header(reqwest::header::RANGE, format!("bytes={already}-"));
159 println!(" Resuming {relpath} from {} MB …", already / 1_000_000);
160 }
161
162 let response = req
163 .send()
164 .await
165 .with_context(|| format!("GET {url}"))?;
166
167 let status = response.status();
168 if !status.is_success() {
170 anyhow::bail!("HTTP {status} fetching {relpath} from {repo_id}");
171 }
172
173 let (file, resume_offset) = if status == reqwest::StatusCode::PARTIAL_CONTENT {
175 let f = tokio::fs::OpenOptions::new()
176 .append(true)
177 .open(&dest_tmp)
178 .await
179 .with_context(|| format!("open tmp {}", dest_tmp.display()))?;
180 (f, already)
181 } else {
182 let f = tokio::fs::File::create(&dest_tmp)
183 .await
184 .with_context(|| format!("create tmp {}", dest_tmp.display()))?;
185 (f, 0)
186 };
187
188 let total = response
189 .content_length()
190 .map(|n| n + resume_offset)
191 .unwrap_or(0);
192
193 let pb = indicatif::ProgressBar::new(total);
194 let pb = match mp {
195 Some(mp) => mp.add(pb),
196 None => pb,
197 };
198 pb.set_style(
199 indicatif::ProgressStyle::with_template(
200 " {msg} [{bar:40}] {bytes}/{total_bytes} ({bytes_per_sec}, eta {eta})",
201 )
202 .unwrap()
203 .progress_chars("=>-"),
204 );
205 pb.set_message(relpath.to_string());
206 pb.set_position(resume_offset);
207
208 {
209 let mut file = file;
210 let mut stream = response.bytes_stream();
211 while let Some(chunk) = stream.next().await {
212 let chunk = chunk.with_context(|| format!("stream chunk of {relpath}"))?;
213 pb.inc(chunk.len() as u64);
214 file.write_all(&chunk)
215 .await
216 .with_context(|| format!("write chunk to {}", dest_tmp.display()))?;
217 }
218 }
219
220 pb.finish_and_clear();
221 std::fs::rename(&dest_tmp, &dest)
222 .with_context(|| format!("rename tmp → {}", dest.display()))?;
223
224 Ok(dest)
225}
226
227async fn resolve_shards(
229 client: &reqwest::Client,
230 repo_id: &str,
231 index_path: &Path,
232 cache_dir: &Path,
233 prefix: &str,
234) -> anyhow::Result<Vec<PathBuf>> {
235 #[derive(Deserialize)]
236 struct Index {
237 weight_map: std::collections::HashMap<String, String>,
238 }
239
240 let raw = std::fs::read_to_string(index_path).context("read index json")?;
241 let index: Index = serde_json::from_str(&raw).context("parse index json")?;
242
243 let mut shard_names: Vec<String> = index.weight_map.into_values().collect();
244 shard_names.sort();
245 shard_names.dedup();
246
247 let multi = indicatif::MultiProgress::new();
253 let mut results: Vec<(usize, PathBuf)> = stream::iter(shard_names.iter().enumerate())
254 .map(|(i, name)| {
255 let rel = joined(prefix, name);
256 let multi = &multi;
257 async move {
258 println!(" Fetching {rel} …");
259 fetch_file(client, repo_id, &rel, cache_dir, Some(multi))
260 .await
261 .map(|p| (i, p))
262 }
263 })
264 .buffer_unordered(SHARD_DOWNLOAD_CONCURRENCY)
265 .collect::<Vec<anyhow::Result<(usize, PathBuf)>>>()
266 .await
267 .into_iter()
268 .collect::<anyhow::Result<Vec<_>>>()?;
269 results.sort_by_key(|(i, _)| *i);
270 Ok(results.into_iter().map(|(_, p)| p).collect())
271}