use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
const TOKENIZER_FILE: &str = "tokenizer.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelChoice {
Qwen1_5B,
Qwen3B,
}
impl ModelChoice {
pub fn parse(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"3b" | "3" | "qwen3b" => ModelChoice::Qwen3B,
_ => ModelChoice::Qwen1_5B,
}
}
fn sources(self) -> (&'static str, &'static str, &'static str) {
match self {
ModelChoice::Qwen1_5B => (
"Qwen/Qwen2.5-Coder-1.5B-Instruct-GGUF",
"qwen2.5-coder-1.5b-instruct-q4_k_m.gguf",
"Qwen/Qwen2.5-Coder-1.5B",
),
ModelChoice::Qwen3B => (
"Qwen/Qwen2.5-Coder-3B-Instruct-GGUF",
"qwen2.5-coder-3b-instruct-q4_k_m.gguf",
"Qwen/Qwen2.5-Coder-3B",
),
}
}
fn tokenizer_cache_name(self) -> &'static str {
match self {
ModelChoice::Qwen1_5B => "tokenizer-1.5b.json",
ModelChoice::Qwen3B => "tokenizer-3b.json",
}
}
}
#[derive(Debug, Clone)]
pub struct DownloadProgress {
pub label: &'static str,
pub received: u64,
pub total: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct ModelPaths {
pub gguf: PathBuf,
pub tokenizer: PathBuf,
}
pub fn ensure_model(
cache_dir: &Path,
choice: ModelChoice,
progress: &(dyn Fn(DownloadProgress) + Sync),
) -> Result<ModelPaths, String> {
fs::create_dir_all(cache_dir).map_err(|e| format!("create {}: {e}", cache_dir.display()))?;
let (gguf_repo, gguf_file, tok_repo) = choice.sources();
let gguf = cache_dir.join(gguf_file);
let tokenizer = cache_dir.join(choice.tokenizer_cache_name());
if !tokenizer.exists() {
let url = hf_url(tok_repo, TOKENIZER_FILE);
download(&url, &tokenizer, "tokenizer", progress)?;
}
if !gguf.exists() {
let url = hf_url(gguf_repo, gguf_file);
download(&url, &gguf, "weights", progress)?;
}
Ok(ModelPaths { gguf, tokenizer })
}
pub fn is_model_cached(cache_dir: &Path, choice: ModelChoice) -> bool {
let (_, gguf_file, _) = choice.sources();
cache_dir.join(gguf_file).exists() && cache_dir.join(choice.tokenizer_cache_name()).exists()
}
fn hf_url(repo: &str, file: &str) -> String {
format!("https://huggingface.co/{repo}/resolve/main/{file}")
}
fn download(
url: &str,
dest: &Path,
label: &'static str,
progress: &(dyn Fn(DownloadProgress) + Sync),
) -> Result<(), String> {
let client = reqwest::blocking::Client::builder()
.build()
.map_err(|e| format!("http client: {e}"))?;
let mut resp = client
.get(url)
.send()
.map_err(|e| format!("GET {url}: {e}"))?;
if !resp.status().is_success() {
return Err(format!("GET {url}: HTTP {}", resp.status()));
}
let total = resp.content_length();
let part = dest.with_extension("part");
let mut file =
fs::File::create(&part).map_err(|e| format!("create {}: {e}", part.display()))?;
let mut buf = [0u8; 64 * 1024];
let mut received: u64 = 0;
let mut last_report: u64 = 0;
loop {
let n = resp
.read(&mut buf)
.map_err(|e| format!("read {label}: {e}"))?;
if n == 0 {
break;
}
file.write_all(&buf[..n])
.map_err(|e| format!("write {label}: {e}"))?;
received += n as u64;
if received - last_report >= 4 * 1024 * 1024 {
last_report = received;
progress(DownloadProgress {
label,
received,
total,
});
}
}
file.flush().map_err(|e| format!("flush {label}: {e}"))?;
drop(file);
fs::rename(&part, dest).map_err(|e| format!("finalize {}: {e}", dest.display()))?;
progress(DownloadProgress {
label,
received,
total,
});
Ok(())
}