1use 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#[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 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 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 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 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 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 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 let path = repo
257 .get(&filename_str)
258 .await
259 .map_err(|e| FerrumError::model(format!("Download failed: {}", e)))?;
260
261 done_clone.store(true, Ordering::SeqCst);
263
264 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 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 self.download_tokenizer_files(&repo).await?;
308
309 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 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 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 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 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
448fn find_downloading_file(cache_dir: &Path, _filename: &str) -> Option<u64> {
450 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 let mut current = cache_dir.to_path_buf();
469 for _ in 0..3 {
470 if let Ok(entries) = std::fs::read_dir(¤t) {
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
490fn 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}