Skip to main content

ferrum_models/
source.rs

1//! Model source resolution and downloading with progress tracking
2
3use ferrum_types::{FerrumError, ModelSource, Result};
4use hf_hub::api::tokio::{Api, ApiBuilder, ApiRepo};
5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, OnceLock};
8use std::time::{Duration, Instant};
9use tracing::{debug, info, warn};
10
11pub(crate) mod cached_weights;
12pub use cached_weights::{inspect_cached_weights, CachedWeights};
13pub mod gguf_selection;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16struct ModelSourceRuntimeEnv {
17    hf_home: Option<String>,
18    hf_token: Option<String>,
19}
20
21impl ModelSourceRuntimeEnv {
22    fn from_env() -> Self {
23        Self::from_env_vars(std::env::vars())
24    }
25
26    fn from_env_vars<I, K, V>(vars: I) -> Self
27    where
28        I: IntoIterator<Item = (K, V)>,
29        K: AsRef<str>,
30        V: Into<String>,
31    {
32        let mut hf_home = None;
33        let mut hf_token = None;
34        let mut hf_hub_token = None;
35
36        for (key, value) in vars {
37            let value = value.into();
38            match key.as_ref() {
39                "HF_HOME" => hf_home = Some(value),
40                "HF_TOKEN" => hf_token = Some(value),
41                "HUGGING_FACE_HUB_TOKEN" => hf_hub_token = Some(value),
42                _ => {}
43            }
44        }
45
46        Self {
47            hf_home,
48            hf_token: hf_token.or(hf_hub_token),
49        }
50    }
51}
52
53fn model_source_runtime_env() -> &'static ModelSourceRuntimeEnv {
54    static CONFIG: OnceLock<ModelSourceRuntimeEnv> = OnceLock::new();
55    CONFIG.get_or_init(ModelSourceRuntimeEnv::from_env)
56}
57
58/// Configuration for model source resolution
59#[derive(Debug, Clone)]
60pub struct ModelSourceConfig {
61    pub cache_dir: Option<PathBuf>,
62    pub hf_token: Option<String>,
63    pub offline_mode: bool,
64    pub max_retries: usize,
65    pub download_timeout: u64,
66    pub use_file_lock: bool,
67}
68
69impl Default for ModelSourceConfig {
70    fn default() -> Self {
71        // Use HuggingFace standard cache directory
72        let default_cache = model_source_runtime_env()
73            .hf_home
74            .clone()
75            .or_else(|| {
76                dirs::home_dir()
77                    .map(|h| h.join(".cache/huggingface"))
78                    .and_then(|p| p.to_str().map(String::from))
79            })
80            .map(PathBuf::from);
81
82        Self {
83            cache_dir: default_cache,
84            hf_token: Self::get_hf_token(),
85            offline_mode: false,
86            max_retries: 3,
87            download_timeout: 300,
88            use_file_lock: true,
89        }
90    }
91}
92
93impl ModelSourceConfig {
94    pub fn get_hf_token() -> Option<String> {
95        model_source_runtime_env().hf_token.clone()
96    }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum ModelFormat {
101    SafeTensors,
102    PyTorchBin,
103    GGUF,
104    Unknown,
105}
106
107#[derive(Debug, Clone)]
108pub struct ResolvedModelSource {
109    pub original: String,
110    pub local_path: PathBuf,
111    pub format: ModelFormat,
112    pub from_cache: bool,
113}
114
115impl From<ResolvedModelSource> for ModelSource {
116    fn from(value: ResolvedModelSource) -> Self {
117        ModelSource::Local(value.local_path.display().to_string())
118    }
119}
120
121#[async_trait::async_trait]
122pub trait ModelSourceResolver: Send + Sync {
123    async fn resolve(&self, id: &str, revision: Option<&str>) -> Result<ResolvedModelSource>;
124}
125
126pub struct DefaultModelSourceResolver {
127    _config: ModelSourceConfig,
128    api: Api,
129}
130
131impl DefaultModelSourceResolver {
132    pub fn new(config: ModelSourceConfig) -> Self {
133        let mut builder = ApiBuilder::new();
134
135        if let Some(cache_dir) = &config.cache_dir {
136            builder = builder.with_cache_dir(cache_dir.clone());
137        }
138
139        if let Some(token) = &config.hf_token {
140            builder = builder.with_token(Some(token.clone()));
141        }
142
143        let api = builder.build().unwrap_or_else(|e| {
144            warn!("Failed to build HF API: {}, using default", e);
145            Api::new().expect("Failed to create default HF API")
146        });
147
148        Self {
149            _config: config,
150            api,
151        }
152    }
153
154    fn is_local_path(id: &str) -> bool {
155        Path::new(id).exists()
156    }
157
158    fn detect_format(path: &Path) -> ModelFormat {
159        if path.join("model.safetensors").exists()
160            || path.join("model.safetensors.index.json").exists()
161        {
162            ModelFormat::SafeTensors
163        } else if path.join("pytorch_model.bin").exists() {
164            ModelFormat::PyTorchBin
165        } else {
166            ModelFormat::Unknown
167        }
168    }
169
170    async fn resolve_local(&self, path: &str) -> Result<ResolvedModelSource> {
171        let path_buf = PathBuf::from(path);
172
173        if !path_buf.exists() {
174            return Err(FerrumError::model(format!("Path does not exist: {}", path)));
175        }
176
177        let format = Self::detect_format(&path_buf);
178
179        Ok(ResolvedModelSource {
180            original: path.to_string(),
181            local_path: path_buf,
182            format,
183            from_cache: true,
184        })
185    }
186
187    /// Download file with progress monitoring
188    async fn download_with_monitor(
189        &self,
190        repo: &ApiRepo,
191        filename: &str,
192        expected_cache_dir: &Path,
193    ) -> Result<PathBuf> {
194        info!("📥 下载中: {}...", filename);
195
196        let done = Arc::new(AtomicBool::new(false));
197        let done_clone = done.clone();
198        let filename_str = filename.to_string();
199
200        // Start monitor task
201        let monitor_task = tokio::spawn({
202            let done = done.clone();
203            let filename = filename_str.clone();
204            let cache_dir = expected_cache_dir.to_path_buf();
205
206            async move {
207                tokio::time::sleep(Duration::from_millis(1000)).await;
208
209                let start_time = Instant::now();
210                let mut last_size = 0u64;
211                let mut last_time = Instant::now();
212                let mut last_print = Instant::now();
213
214                while !done.load(Ordering::SeqCst) {
215                    // Try to find downloading file
216                    if let Some(current_size) = find_downloading_file(&cache_dir, &filename) {
217                        let elapsed_since_last = last_time.elapsed().as_secs_f64();
218
219                        if elapsed_since_last > 0.5 && current_size > last_size {
220                            let delta = current_size - last_size;
221                            let speed_mbps = delta as f64 / elapsed_since_last / 1024.0 / 1024.0;
222                            let current_mb = current_size as f64 / 1024.0 / 1024.0;
223
224                            // Only print every 2 seconds to avoid spam
225                            if last_print.elapsed().as_secs() >= 2 {
226                                info!(
227                                    "  📊 已下载: {:.2} MB (速度: {:.1} MB/s)",
228                                    current_mb, speed_mbps
229                                );
230                                last_print = Instant::now();
231                            }
232
233                            last_size = current_size;
234                            last_time = Instant::now();
235                        }
236                    }
237
238                    tokio::time::sleep(Duration::from_millis(500)).await;
239                }
240
241                // Final statistics
242                let total_time = start_time.elapsed().as_secs_f64();
243                if last_size > 0 && total_time > 0.0 {
244                    let avg_speed = last_size as f64 / total_time / 1024.0 / 1024.0;
245                    info!(
246                        "  ✅ 下载完成: {:.2} MB (平均速度: {:.1} MB/s, 耗时: {:.1}s)",
247                        last_size as f64 / 1024.0 / 1024.0,
248                        avg_speed,
249                        total_time
250                    );
251                }
252            }
253        });
254
255        // Do the actual download (blocking, but monitored)
256        let path = repo
257            .get(&filename_str)
258            .await
259            .map_err(|e| FerrumError::model(format!("Download failed: {}", e)))?;
260
261        // Signal completion
262        done_clone.store(true, Ordering::SeqCst);
263
264        // Wait for monitor to finish
265        let _ = monitor_task.await;
266
267        Ok(path)
268    }
269
270    async fn resolve_huggingface(
271        &self,
272        repo_id: &str,
273        revision: Option<&str>,
274    ) -> Result<ResolvedModelSource> {
275        info!("🔍 正在解析模型: {}", repo_id);
276
277        let repo = if let Some(rev) = revision {
278            self.api.repo(hf_hub::Repo::with_revision(
279                repo_id.to_string(),
280                hf_hub::RepoType::Model,
281                rev.to_string(),
282            ))
283        } else {
284            self.api.repo(hf_hub::Repo::new(
285                repo_id.to_string(),
286                hf_hub::RepoType::Model,
287            ))
288        };
289
290        // Download config first (small file, no need for progress)
291        info!("📥 下载中: config.json...");
292        let config_path = repo
293            .get("config.json")
294            .await
295            .map_err(|e| FerrumError::model(format!("Failed to download config: {}", e)))?;
296
297        info!("✅ config.json 下载完成");
298
299        let model_dir = config_path
300            .parent()
301            .ok_or_else(|| FerrumError::model("Invalid cache path"))?
302            .to_path_buf();
303
304        info!("📁 缓存目录: {:?}", model_dir);
305
306        // Download tokenizer files (critical for inference)
307        self.download_tokenizer_files(&repo).await?;
308
309        // Download weights
310        let format = self.download_weights(&repo, &model_dir).await?;
311
312        Ok(ResolvedModelSource {
313            original: repo_id.to_string(),
314            local_path: model_dir,
315            format,
316            from_cache: false,
317        })
318    }
319
320    async fn download_tokenizer_files(&self, repo: &ApiRepo) -> Result<()> {
321        info!("📥 下载 tokenizer 文件...");
322
323        // List of common tokenizer files
324        let tokenizer_files = vec![
325            "tokenizer.json",
326            "tokenizer_config.json",
327            "vocab.json",
328            "merges.txt",
329            "special_tokens_map.json",
330        ];
331
332        let mut downloaded_count = 0;
333        for filename in &tokenizer_files {
334            match repo.get(filename).await {
335                Ok(_path) => {
336                    info!("  ✅ {}", filename);
337                    downloaded_count += 1;
338                }
339                Err(e) => {
340                    debug!("  ⏭️  {} (optional): {}", filename, e);
341                }
342            }
343        }
344
345        if downloaded_count > 0 {
346            info!("✅ Tokenizer 文件下载完成 ({} 个文件)", downloaded_count);
347        } else {
348            warn!("⚠️  未找到 tokenizer 文件,可能影响推理");
349        }
350
351        Ok(())
352    }
353
354    async fn download_weights(&self, repo: &ApiRepo, model_dir: &Path) -> Result<ModelFormat> {
355        // Try SafeTensors single file
356        info!("🔍 检查 model.safetensors...");
357        match self
358            .download_with_monitor(repo, "model.safetensors", model_dir)
359            .await
360        {
361            Ok(path) => {
362                if let Ok(metadata) = std::fs::metadata(&path) {
363                    info!(
364                        "✅ model.safetensors 完成 ({:.2} GB)",
365                        metadata.len() as f64 / 1e9
366                    );
367                }
368                return Ok(ModelFormat::SafeTensors);
369            }
370            Err(e) => debug!("model.safetensors not found: {}", e),
371        }
372
373        // Try sharded SafeTensors
374        info!("🔍 检查分片模型...");
375        match repo.get("model.safetensors.index.json").await {
376            Ok(index_path) => {
377                info!("✅ 发现分片 SafeTensors 模型");
378
379                let content = std::fs::read_to_string(&index_path)
380                    .map_err(|e| FerrumError::io(format!("Failed to read index: {}", e)))?;
381
382                let index: serde_json::Value = serde_json::from_str(&content)
383                    .map_err(|e| FerrumError::model(format!("Failed to parse index: {}", e)))?;
384
385                if let Some(weight_map) = index.get("weight_map").and_then(|w| w.as_object()) {
386                    let shards: std::collections::HashSet<_> =
387                        weight_map.values().filter_map(|v| v.as_str()).collect();
388
389                    let total = shards.len();
390                    info!("📦 需要下载 {} 个分片", total);
391
392                    let mut total_bytes = 0u64;
393                    for (i, shard) in shards.iter().enumerate() {
394                        info!("📥 [{}/{}] {}", i + 1, total, shard);
395
396                        let shard_path = self.download_with_monitor(repo, shard, model_dir).await?;
397
398                        if let Ok(meta) = std::fs::metadata(&shard_path) {
399                            let size = meta.len();
400                            total_bytes += size;
401                            info!(
402                                "📊 进度: [{}/{}] 分片, 累计 {:.2} GB",
403                                i + 1,
404                                total,
405                                total_bytes as f64 / 1e9
406                            );
407                        }
408                    }
409
410                    info!(
411                        "🎉 全部下载完成! 总大小: {:.2} GB",
412                        total_bytes as f64 / 1e9
413                    );
414                }
415
416                return Ok(ModelFormat::SafeTensors);
417            }
418            Err(e) => debug!("Sharded model not found: {}", e),
419        }
420
421        // Try PyTorch
422        info!("🔍 检查 pytorch_model.bin...");
423        match self
424            .download_with_monitor(repo, "pytorch_model.bin", model_dir)
425            .await
426        {
427            Ok(path) => {
428                warn!("⚠️  使用 PyTorch 格式 (推荐使用 SafeTensors)");
429                if let Ok(meta) = std::fs::metadata(&path) {
430                    info!(
431                        "✅ pytorch_model.bin 完成 ({:.2} GB)",
432                        meta.len() as f64 / 1e9
433                    );
434                }
435                return Ok(ModelFormat::PyTorchBin);
436            }
437            Err(e) => debug!("pytorch_model.bin not found: {}", e),
438        }
439
440        if Self::detect_format(model_dir) == ModelFormat::GGUF {
441            return Ok(ModelFormat::GGUF);
442        }
443
444        Err(FerrumError::model("未找到支持的模型格式"))
445    }
446}
447
448/// Find downloading file in cache directory
449fn find_downloading_file(cache_dir: &Path, _filename: &str) -> Option<u64> {
450    // Just search for ANY .part file in the cache directory tree
451    // This is more reliable than trying to match filenames
452
453    // Check blobs directory
454    if let Ok(entries) = std::fs::read_dir(cache_dir.join("blobs")) {
455        for entry in entries.filter_map(|e| e.ok()) {
456            let path = entry.path();
457            let path_str = path.to_string_lossy();
458
459            if path_str.ends_with(".part") || path_str.contains(".sync.part") {
460                if let Ok(metadata) = std::fs::metadata(&path) {
461                    return Some(metadata.len());
462                }
463            }
464        }
465    }
466
467    // Also try to find in parent directories
468    let mut current = cache_dir.to_path_buf();
469    for _ in 0..3 {
470        if let Ok(entries) = std::fs::read_dir(&current) {
471            for entry in entries.filter_map(|e| e.ok()) {
472                if entry.path().is_dir() {
473                    if let Some(size) = scan_dir_for_part_files(&entry.path()) {
474                        return Some(size);
475                    }
476                }
477            }
478        }
479
480        if let Some(parent) = current.parent() {
481            current = parent.to_path_buf();
482        } else {
483            break;
484        }
485    }
486
487    None
488}
489
490/// Recursively scan directory for .part files
491fn scan_dir_for_part_files(dir: &Path) -> Option<u64> {
492    if let Ok(entries) = std::fs::read_dir(dir) {
493        for entry in entries.filter_map(|e| e.ok()) {
494            let path = entry.path();
495            let path_str = path.to_string_lossy();
496
497            if path_str.ends_with(".part") || path_str.contains(".sync.part") {
498                if let Ok(metadata) = std::fs::metadata(&path) {
499                    return Some(metadata.len());
500                }
501            }
502
503            if path.is_dir() {
504                if let Some(size) = scan_dir_for_part_files(&path) {
505                    return Some(size);
506                }
507            }
508        }
509    }
510    None
511}
512
513#[async_trait::async_trait]
514impl ModelSourceResolver for DefaultModelSourceResolver {
515    async fn resolve(&self, id: &str, revision: Option<&str>) -> Result<ResolvedModelSource> {
516        if Self::is_local_path(id) {
517            return self.resolve_local(id).await;
518        }
519
520        self.resolve_huggingface(id, revision).await
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    #[test]
529    fn model_source_runtime_env_parses_hf_cache_and_token() {
530        let env = ModelSourceRuntimeEnv::from_env_vars([
531            ("HF_HOME", "/tmp/hf"),
532            ("HF_TOKEN", "primary"),
533            ("HUGGING_FACE_HUB_TOKEN", "fallback"),
534        ]);
535
536        assert_eq!(env.hf_home.as_deref(), Some("/tmp/hf"));
537        assert_eq!(env.hf_token.as_deref(), Some("primary"));
538    }
539
540    #[test]
541    fn model_source_runtime_env_uses_hub_token_fallback() {
542        let env = ModelSourceRuntimeEnv::from_env_vars([("HUGGING_FACE_HUB_TOKEN", "fallback")]);
543
544        assert_eq!(env.hf_home, None);
545        assert_eq!(env.hf_token.as_deref(), Some("fallback"));
546    }
547}