1use std::io::{Read, Write};
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10use thiserror::Error;
11
12#[derive(Debug, Error)]
13pub enum DownloadError {
14 #[error("invalid model name: {0}")]
15 InvalidModelName(String),
16 #[error("{0}")]
17 Request(String),
18 #[error("download stream failed: {0}")]
19 Stream(String),
20 #[error("invalid bundle after download: {0}")]
21 InvalidBundle(String),
22 #[error("io error: {0}")]
23 Io(#[from] std::io::Error),
24}
25
26const DEFAULT_SITE: &str = "https://ariacompute.com";
27const DEFAULT_SDK: &str = "v1.0";
28const HUB_REQUIRED: &[&str] = &["config.json", "weight.bin"];
29const HUB_OPTIONAL: &[&str] = &[
30 "tokenizer.json",
31 "tokenizer.model",
32 "tokenizer_config.json",
33 "special_tokens_map.json",
34 "vocab.json",
35 "merges.txt",
36];
37
38fn aria_home() -> Result<PathBuf, DownloadError> {
39 if let Ok(override_home) = std::env::var("ARIA_COMPUTE_HOME") {
40 if !override_home.is_empty() {
41 return Ok(PathBuf::from(override_home));
42 }
43 }
44 let home = if cfg!(windows) {
45 std::env::var("USERPROFILE").map_err(|_| {
46 DownloadError::Io(std::io::Error::new(
47 std::io::ErrorKind::NotFound,
48 "could not resolve home directory",
49 ))
50 })?
51 } else {
52 std::env::var("HOME").map_err(|_| {
53 DownloadError::Io(std::io::Error::new(
54 std::io::ErrorKind::NotFound,
55 "could not resolve home directory",
56 ))
57 })?
58 };
59 Ok(PathBuf::from(home).join(".ariacompute"))
60}
61
62fn models_dir() -> Result<PathBuf, DownloadError> {
63 Ok(aria_home()?.join("models"))
64}
65
66fn parse_bundle_name(model: &str) -> Result<(String, String), DownloadError> {
70 if model.is_empty() || model.contains('/') || model.contains('\\') {
71 return Err(DownloadError::InvalidModelName(model.to_string()));
72 }
73 let (slug, quant) = if let Some(idx) = model.rfind("_q") {
74 let (slug, suffix) = model.split_at(idx);
75 let mut suffix = &suffix[2..];
76 if let Some(core) = suffix.strip_suffix("_channel") {
77 suffix = core;
78 } else if let Some(core) = suffix.strip_suffix("_group") {
79 suffix = core;
80 }
81 let quant = match suffix {
82 "4" => "int4",
83 "8" => "int8",
84 "326" | "3.26" => "int326",
85 other => {
86 return Err(DownloadError::InvalidModelName(format!(
87 "unknown quant suffix _q{other}"
88 )))
89 }
90 };
91 (slug.to_string(), quant.to_string())
92 } else {
93 (model.to_string(), "int4".to_string())
94 };
95 if slug.is_empty() {
96 return Err(DownloadError::InvalidModelName(model.to_string()));
97 }
98 Ok((slug, quant))
99}
100
101fn preferred_public_hub(site: Option<&str>) -> &'static str {
102 if site
103 .unwrap_or("")
104 .to_ascii_lowercase()
105 .contains("ariacompute.cn")
106 {
107 "modelscope"
108 } else {
109 "huggingface"
110 }
111}
112
113fn hub_bearer(token: &str) -> Option<&str> {
114 let t = token.trim();
115 if t.is_empty() {
116 return None;
117 }
118 let low = t.to_ascii_lowercase();
119 if low.starts_with("sk-") || low.starts_with("bfvk-") {
120 None
121 } else {
122 Some(t)
123 }
124}
125
126fn unquote_yaml(v: &str) -> String {
127 let t = v.trim();
128 let b = t.as_bytes();
129 if b.len() >= 2
130 && ((b[0] == b'"' && *b.last().unwrap() == b'"')
131 || (b[0] == b'\'' && *b.last().unwrap() == b'\''))
132 {
133 t[1..t.len() - 1].to_string()
134 } else {
135 t.to_string()
136 }
137}
138
139fn config_yml_scalar(key: &str) -> Option<String> {
140 let path = aria_home().ok()?.join("config.yml");
141 let raw = std::fs::read_to_string(path).ok()?;
142 for line in raw.lines() {
143 if line.starts_with(' ') || line.starts_with('\t') {
144 continue;
145 }
146 let s = line.trim();
147 if s.is_empty() || s.starts_with('#') {
148 continue;
149 }
150 let Some((k, v)) = s.split_once(':') else {
151 continue;
152 };
153 if k.trim() != key {
154 continue;
155 }
156 let val = unquote_yaml(v);
157 if val.is_empty() {
158 return None;
159 }
160 return Some(val);
161 }
162 None
163}
164
165fn hub_token_field(source: &str) -> &'static str {
166 if source == "modelscope" {
167 "modelscope_api_token"
168 } else {
169 "hf_token"
170 }
171}
172
173fn resolve_hub_token(
174 source: &str,
175 token: &str,
176 hf_token: Option<&str>,
177 modelscope_api_token: Option<&str>,
178) -> Option<String> {
179 let named = if source == "modelscope" {
180 modelscope_api_token.unwrap_or("")
181 } else {
182 hf_token.unwrap_or("")
183 };
184 let from_cfg = config_yml_scalar(hub_token_field(source)).unwrap_or_default();
185 for cand in [named, token, from_cfg.as_str()] {
186 if let Some(b) = hub_bearer(cand) {
187 return Some(b.to_string());
188 }
189 }
190 None
191}
192
193fn hub_path_names(model: &str) -> Vec<String> {
194 let mut names = vec![model.to_string()];
195 let mut lower = model.to_ascii_lowercase();
196 let mut core = model.to_string();
197 for suf in ["_channel", "_group"] {
198 if lower.ends_with(suf) {
199 core = model[..model.len() - suf.len()].to_string();
200 lower = core.to_ascii_lowercase();
201 break;
202 }
203 }
204 let mut stems = vec![core.clone()];
205 if lower.ends_with("_q326") {
206 stems.push(format!("{}q3.26", &core[..core.len() - 5]));
207 } else if lower.ends_with("_q3.26") {
208 stems.push(format!("{}q326", &core[..core.len() - 6]));
209 }
210 for stem in stems {
211 for share in ["", "_channel", "_group"] {
212 let cand = format!("{stem}{share}");
213 if !names.iter().any(|n| n == &cand) {
214 names.push(cand);
215 }
216 }
217 }
218 names
219}
220
221fn hub_file_urls(source: &str, model: &str, file: &str) -> Vec<String> {
222 let mut urls = Vec::new();
223 for name in hub_path_names(model) {
224 if source == "modelscope" {
225 for repo in [format!("AriaCompute/{name}"), "AriaCompute/model".into()] {
226 urls.push(format!(
227 "https://www.modelscope.cn/models/{repo}/resolve/master/{DEFAULT_SDK}/{name}/{file}"
228 ));
229 urls.push(format!(
230 "https://modelscope.cn/models/{repo}/resolve/master/{DEFAULT_SDK}/{name}/{file}"
231 ));
232 }
233 } else {
234 for repo in [format!("ariacompute/{name}"), "ariacompute/model".into()] {
235 urls.push(format!(
236 "https://huggingface.co/{repo}/resolve/main/{DEFAULT_SDK}/{name}/{file}"
237 ));
238 }
239 }
240 }
241 urls
242}
243
244fn is_valid_bundle(dir: &Path) -> bool {
245 let weight = dir.join("weight.bin");
246 let config = dir.join("config.json");
247 if !weight.is_file() || !config.is_file() {
248 return false;
249 }
250 let Ok(raw) = std::fs::read_to_string(&config) else {
251 return false;
252 };
253 let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
254 return false;
255 };
256 v.get("format")
257 .and_then(|x| x.as_str())
258 .map(|f| f == "aria-quant-bundle")
259 .unwrap_or(false)
260}
261
262fn atomic_replace(src: &Path, dst: &Path) -> std::io::Result<()> {
263 if dst.exists() {
264 std::fs::remove_dir_all(dst)?;
265 }
266 std::fs::rename(src, dst)
267}
268
269fn auth_error(source: &str, code: u16) -> DownloadError {
270 let field = if source == "modelscope" {
271 "modelscope_api_token"
272 } else {
273 "hf_token"
274 };
275 DownloadError::Request(format!(
276 "auth failed HTTP {code}; set {field} via aria-engine auth (do not pass a Dashboard sk-/bfvk- key as the hub token)"
277 ))
278}
279
280fn fetch_url_to_file(url: &str, dest: &Path, token: Option<&str>) -> Result<(), DownloadError> {
281 let agent = ureq::AgentBuilder::new()
282 .timeout(Duration::from_secs(600))
283 .build();
284 let mut req = agent.get(url);
285 if let Some(t) = token {
286 req = req.set("Authorization", &format!("Bearer {t}"));
287 }
288 let resp = match req.call() {
289 Ok(r) => r,
290 Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => {
291 return Err(DownloadError::Request(format!("HTTP {code}")));
292 }
293 Err(ureq::Error::Status(code, _)) => {
294 return Err(DownloadError::Request(format!("HTTP {code}")));
295 }
296 Err(e) => return Err(DownloadError::Request(e.to_string())),
297 };
298 if let Some(parent) = dest.parent() {
299 std::fs::create_dir_all(parent)?;
300 }
301 let mut reader = resp.into_reader();
302 let mut out = std::fs::File::create(dest)?;
303 let mut buf = [0u8; 1024 * 1024];
304 loop {
305 let n = reader
306 .read(&mut buf)
307 .map_err(|e| DownloadError::Stream(e.to_string()))?;
308 if n == 0 {
309 break;
310 }
311 out.write_all(&buf[..n])?;
312 }
313 Ok(())
314}
315
316fn fetch_hub_file(
317 source: &str,
318 model: &str,
319 file: &str,
320 dest: &Path,
321 token: Option<&str>,
322 required: bool,
323) -> Result<bool, DownloadError> {
324 let mut last: Option<DownloadError> = None;
325 for url in hub_file_urls(source, model, file) {
326 match fetch_url_to_file(&url, dest, token) {
327 Ok(()) => return Ok(true),
328 Err(DownloadError::Request(msg))
329 if msg.contains("HTTP 401") || msg.contains("HTTP 403") =>
330 {
331 let code = if msg.contains("401") { 401 } else { 403 };
332 return Err(auth_error(source, code));
333 }
334 Err(e) => last = Some(e),
335 }
336 }
337 if required {
338 Err(DownloadError::Request(format!(
339 "{source}: missing {file}{}",
340 last.map(|e| format!(": {e}")).unwrap_or_default()
341 )))
342 } else {
343 Ok(false)
344 }
345}
346
347pub fn download_model(
355 model: &str,
356 token: &str,
357 site: Option<&str>,
358) -> Result<PathBuf, DownloadError> {
359 download_model_auth(model, token, site, None, None)
360}
361
362pub fn download_model_auth(
364 model: &str,
365 token: &str,
366 site: Option<&str>,
367 hf_token: Option<&str>,
368 modelscope_api_token: Option<&str>,
369) -> Result<PathBuf, DownloadError> {
370 parse_bundle_name(model)?;
371 let site = site.unwrap_or(DEFAULT_SITE);
372 let source = preferred_public_hub(Some(site));
373 let hub_owned = resolve_hub_token(source, token, hf_token, modelscope_api_token);
374 let hub_token = hub_owned.as_deref();
375 let cache = models_dir()?.join(model);
376
377 if cache.exists() && is_valid_bundle(&cache) {
378 return Ok(cache);
379 }
380
381 let staging = models_dir()?.join(format!(".{}.partial", model));
382 if staging.exists() {
383 std::fs::remove_dir_all(&staging)?;
384 }
385 std::fs::create_dir_all(&staging)?;
386 let result = (|| {
387 for file in HUB_REQUIRED {
388 fetch_hub_file(source, model, file, &staging.join(file), hub_token, true)?;
389 }
390 for extra in HUB_OPTIONAL {
391 let _ = fetch_hub_file(source, model, extra, &staging.join(extra), hub_token, false);
392 }
393 if !is_valid_bundle(&staging) {
394 return Err(DownloadError::InvalidBundle(
395 "need weight.bin + aria-quant-bundle config.json".into(),
396 ));
397 }
398 atomic_replace(&staging, &cache)?;
399 Ok(cache.clone())
400 })();
401 if result.is_err() && staging.exists() {
402 let _ = std::fs::remove_dir_all(&staging);
403 }
404 result
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use std::sync::Mutex;
411
412 static ENV_LOCK: Mutex<()> = Mutex::new(());
413
414 #[test]
415 fn preferred_hub_follows_site_tld() {
416 assert_eq!(
417 preferred_public_hub(Some("https://ariacompute.com")),
418 "huggingface"
419 );
420 assert_eq!(
421 preferred_public_hub(Some("https://ariacompute.cn")),
422 "modelscope"
423 );
424 assert_eq!(preferred_public_hub(None), "huggingface");
425 }
426
427 #[test]
428 fn dashboard_token_not_sent_to_hub() {
429 assert!(hub_bearer("sk-bf-95076ed1-8c1a-4efa-b33c-f52c1d7f9f24").is_none());
430 assert!(hub_bearer("bfvk-test").is_none());
431 assert_eq!(hub_bearer("hf_abc"), Some("hf_abc"));
432 }
433
434 #[test]
435 fn hub_urls_follow_upload_layout() {
436 let hf = hub_file_urls("huggingface", "gemma-4-e2b-it_q4", "config.json");
437 assert!(hf.iter().any(|u| u.contains(
438 "/ariacompute/gemma-4-e2b-it_q4/resolve/main/v1.0/gemma-4-e2b-it_q4/config.json"
439 )));
440 let ms = hub_file_urls("modelscope", "gemma-4-e2b-it_q4", "weight.bin");
441 assert!(ms
442 .iter()
443 .any(|u| u.contains("/v1.0/gemma-4-e2b-it_q4/weight.bin")));
444 assert!(hf.iter().chain(ms.iter()).all(|u| !u.contains("/api/dashboard/")));
445 }
446
447 #[test]
448 fn parse_channel_suffix() {
449 let (slug, quant) = parse_bundle_name("foo_q326_channel").unwrap();
450 assert_eq!(slug, "foo");
451 assert_eq!(quant, "int326");
452 }
453
454 #[test]
455 fn cached_bundle_skips_download() {
456 let _guard = ENV_LOCK.lock().unwrap();
457 let tmp = tempfile::tempdir().unwrap();
458 std::env::set_var("ARIA_COMPUTE_HOME", tmp.path());
459 let cache = tmp.path().join("models").join("foo_q4");
460 std::fs::create_dir_all(&cache).unwrap();
461 std::fs::write(cache.join("weight.bin"), b"x").unwrap();
462 std::fs::write(
463 cache.join("config.json"),
464 br#"{"format":"aria-quant-bundle"}"#,
465 )
466 .unwrap();
467 let got = download_model("foo_q4", "", None).unwrap();
468 assert_eq!(got, cache);
469 std::env::remove_var("ARIA_COMPUTE_HOME");
470 }
471
472 #[test]
473 fn resolve_named_and_config_yml() {
474 let _guard = ENV_LOCK.lock().unwrap();
475 let tmp = tempfile::tempdir().unwrap();
476 std::env::set_var("ARIA_COMPUTE_HOME", tmp.path());
477 std::fs::write(
478 tmp.path().join("config.yml"),
479 "hf_token: hf_from_yml\nmodelscope_api_token: \"ms_from_yml\"\n",
480 )
481 .unwrap();
482 assert_eq!(
483 resolve_hub_token("huggingface", "hf_generic", Some("hf_named"), None).as_deref(),
484 Some("hf_named")
485 );
486 assert_eq!(
487 resolve_hub_token("modelscope", "", None, Some("ms_named")).as_deref(),
488 Some("ms_named")
489 );
490 assert_eq!(
491 resolve_hub_token("huggingface", "", None, None).as_deref(),
492 Some("hf_from_yml")
493 );
494 assert_eq!(
495 resolve_hub_token("modelscope", "", None, None).as_deref(),
496 Some("ms_from_yml")
497 );
498 assert_eq!(
499 resolve_hub_token("huggingface", "sk-bf-not-hub", None, None).as_deref(),
500 Some("hf_from_yml")
501 );
502 std::env::remove_var("ARIA_COMPUTE_HOME");
503 }
504}