Skip to main content

ferrum_models/
registry.rs

1//! Model registry and alias management
2
3use ferrum_types::Result;
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use tracing::{debug, info};
7
8/// Model alias entry
9#[derive(Debug, Clone)]
10pub struct ModelAlias {
11    /// Alias name (short name)
12    pub name: String,
13    /// Target model identifier
14    pub target: String,
15    /// Optional description
16    pub description: Option<String>,
17}
18
19/// Architecture types for models
20#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
21pub enum Architecture {
22    Llama,
23    Qwen2,
24    Qwen3,
25    /// Qwen3-MoE family (Qwen3-30B-A3B and friends). Distinct from Qwen3
26    /// because the FFN per layer is replaced by a router + N experts.
27    Qwen3Moe,
28    /// Gemma 3 text family (1B–27B): LlamaFamilyModel with 5:1
29    /// local/global attention scheduling, dual RoPE tables, GeGLU,
30    /// sandwich norms and Gemma RMSNorm conventions. The vision tower of
31    /// multimodal checkpoints is ignored (text-only support).
32    Gemma3,
33    Mistral,
34    Phi,
35    GPT2,
36    Bert,
37    Clip,
38    Whisper,
39    Qwen3TTS,
40    Unknown,
41}
42
43impl Architecture {
44    pub fn from_str(s: &str) -> Self {
45        match s.to_lowercase().as_str() {
46            "llama" | "llamaforcausallm" => Architecture::Llama,
47            "qwen2" | "qwen2forcausallm" => Architecture::Qwen2,
48            "qwen3" | "qwen3forcausallm" => Architecture::Qwen3,
49            "qwen3_moe" | "qwen3moe" | "qwen3moeforcausallm" => Architecture::Qwen3Moe,
50            "gemma3" | "gemma3_text" | "gemma3forcausallm" | "gemma3forconditionalgeneration" => {
51                Architecture::Gemma3
52            }
53            "mistral" | "mistralforcausallm" => Architecture::Mistral,
54            "phi" | "phiforcausallm" => Architecture::Phi,
55            "gpt2" | "gpt2lmheadmodel" => Architecture::GPT2,
56            "bert" | "bertmodel" | "bertformaskedlm" | "bertforsequenceclassification" => {
57                Architecture::Bert
58            }
59            "clip" | "clipmodel" => Architecture::Clip,
60            "chinese_clip" | "chineseclipmodel" => Architecture::Clip,
61            "siglip" | "siglipmodel" => Architecture::Clip,
62            "whisper" | "whisperforconditionalgeneration" => Architecture::Whisper,
63            "qwen3_tts" | "qwen3ttsforconditionalgeneration" => Architecture::Qwen3TTS,
64            _ => Architecture::Unknown,
65        }
66    }
67}
68
69/// Model format type
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum ModelFormatType {
72    SafeTensors,
73    PyTorch,
74    GGUF,
75    Unknown,
76}
77
78/// Discovered model entry
79#[derive(Debug, Clone)]
80pub struct ModelDiscoveryEntry {
81    /// Model identifier
82    pub id: String,
83    /// Local path to model
84    pub path: PathBuf,
85    /// Model format
86    pub format: ModelFormatType,
87    /// Architecture type (if detected)
88    pub architecture: Option<Architecture>,
89    /// Whether model passes validation
90    pub is_valid: bool,
91}
92
93/// Model registry for managing models and aliases
94#[derive(Debug)]
95pub struct DefaultModelRegistry {
96    /// Model aliases
97    aliases: HashMap<String, String>,
98    /// Discovered models cache
99    discovered_models: Vec<ModelDiscoveryEntry>,
100}
101
102impl DefaultModelRegistry {
103    /// Create new empty registry
104    pub fn new() -> Self {
105        Self {
106            aliases: HashMap::new(),
107            discovered_models: Vec::new(),
108        }
109    }
110
111    /// Create registry with common aliases
112    pub fn with_defaults() -> Self {
113        let mut registry = Self::new();
114
115        // Common model aliases
116        registry.register_alias("tinyllama", "TinyLlama/TinyLlama-1.1B-Chat-v1.0");
117        registry.register_alias("llama2-7b", "meta-llama/Llama-2-7b-hf");
118        registry.register_alias("llama2-7b-chat", "meta-llama/Llama-2-7b-chat-hf");
119        registry.register_alias("llama3-8b", "meta-llama/Meta-Llama-3-8B");
120        registry.register_alias("llama3-8b-instruct", "meta-llama/Meta-Llama-3-8B-Instruct");
121        registry.register_alias("qwen2-7b", "Qwen/Qwen2-7B");
122        registry.register_alias("qwen2-7b-instruct", "Qwen/Qwen2-7B-Instruct");
123        registry.register_alias("qwen3-0.6b", "Qwen/Qwen3-0.6B");
124        registry.register_alias("qwen3-1.7b", "Qwen/Qwen3-1.7B");
125        registry.register_alias("qwen3-4b", "Qwen/Qwen3-4B");
126        registry.register_alias("mistral-7b", "mistralai/Mistral-7B-v0.1");
127        registry.register_alias("mistral-7b-instruct", "mistralai/Mistral-7B-Instruct-v0.2");
128        registry.register_alias("phi3-mini", "microsoft/Phi-3-mini-4k-instruct");
129
130        // Whisper ASR models
131        registry.register_alias("whisper-tiny", "openai/whisper-tiny");
132        registry.register_alias("whisper-base", "openai/whisper-base");
133        registry.register_alias("whisper-small", "openai/whisper-small");
134        registry.register_alias("whisper-medium", "openai/whisper-medium");
135        registry.register_alias("whisper-large-v3", "openai/whisper-large-v3");
136        registry.register_alias("whisper-turbo", "openai/whisper-large-v3-turbo");
137        registry.register_alias("whisper-large-v3-turbo", "openai/whisper-large-v3-turbo");
138
139        registry
140    }
141
142    /// Register a model alias
143    pub fn register_alias(&mut self, alias: impl Into<String>, target: impl Into<String>) {
144        let alias_str = alias.into();
145        let target_str = target.into();
146        debug!("Registering alias: {} -> {}", alias_str, target_str);
147        self.aliases.insert(alias_str, target_str);
148    }
149
150    /// Add alias from struct
151    pub fn add_alias(&mut self, alias: ModelAlias) -> Result<()> {
152        self.register_alias(alias.name, alias.target);
153        Ok(())
154    }
155
156    /// Resolve model ID through aliases
157    pub fn resolve_model_id(&self, name: &str) -> String {
158        self.aliases
159            .get(name)
160            .cloned()
161            .unwrap_or_else(|| name.to_string())
162    }
163
164    /// List all registered aliases
165    pub fn list_aliases(&self) -> Vec<ModelAlias> {
166        self.aliases
167            .iter()
168            .map(|(name, target)| ModelAlias {
169                name: name.clone(),
170                target: target.clone(),
171                description: None,
172            })
173            .collect()
174    }
175
176    /// Discover models in a directory
177    pub async fn discover_models(&mut self, root: &Path) -> Result<Vec<ModelDiscoveryEntry>> {
178        if !root.exists() || !root.is_dir() {
179            return Ok(Vec::new());
180        }
181
182        info!("Discovering models in: {:?}", root);
183
184        let mut discovered = Vec::new();
185
186        // First check if root itself is a model directory
187        if let Some(model_entry) = self.inspect_model_dir(root).await {
188            discovered.push(model_entry);
189        } else {
190            // Otherwise scan subdirectories
191            if let Ok(entries) = std::fs::read_dir(root) {
192                for entry in entries.filter_map(|e| e.ok()) {
193                    let path = entry.path();
194                    if path.is_dir() {
195                        if let Some(model_entry) = self.inspect_model_dir(&path).await {
196                            discovered.push(model_entry);
197                        }
198                    }
199                }
200            }
201        }
202
203        self.discovered_models = discovered.clone();
204        Ok(discovered)
205    }
206
207    /// Inspect a directory to see if it contains a model
208    async fn inspect_model_dir(&self, path: &Path) -> Option<ModelDiscoveryEntry> {
209        // Check for config.json
210        let config_path = path.join("config.json");
211        if !config_path.exists() {
212            debug!("No config.json in: {:?}", path);
213            return None;
214        }
215
216        // Detect format
217        let format = self.detect_model_format(path);
218        if format == ModelFormatType::Unknown {
219            debug!("Unknown format in: {:?}", path);
220            return None;
221        }
222
223        debug!("Found valid model at: {:?}, format: {:?}", path, format);
224
225        // Try to read architecture from config
226        let architecture = self.read_architecture(&config_path);
227
228        // Extract model ID from path - try to get friendly name from parent directory
229        let id = if let Some(parent) = path.parent() {
230            if let Some(grandparent) = parent.parent() {
231                // Extract from models--org--name format
232                if let Some(name) = grandparent.file_name().and_then(|n| n.to_str()) {
233                    if name.starts_with("models--") {
234                        name[8..].replace("--", "/")
235                    } else {
236                        path.file_name()
237                            .and_then(|n| n.to_str())
238                            .unwrap_or("unknown")
239                            .to_string()
240                    }
241                } else {
242                    path.file_name()
243                        .and_then(|n| n.to_str())
244                        .unwrap_or("unknown")
245                        .to_string()
246                }
247            } else {
248                path.file_name()
249                    .and_then(|n| n.to_str())
250                    .unwrap_or("unknown")
251                    .to_string()
252            }
253        } else {
254            path.file_name()
255                .and_then(|n| n.to_str())
256                .unwrap_or("unknown")
257                .to_string()
258        };
259
260        Some(ModelDiscoveryEntry {
261            id,
262            path: path.to_path_buf(),
263            format,
264            architecture,
265            is_valid: true,
266        })
267    }
268
269    /// Detect model format in directory
270    fn detect_model_format(&self, path: &Path) -> ModelFormatType {
271        if path.join("model.safetensors").exists()
272            || path.join("model.safetensors.index.json").exists()
273        {
274            ModelFormatType::SafeTensors
275        } else if path.join("pytorch_model.bin").exists()
276            || path.join("pytorch_model.bin.index.json").exists()
277        {
278            ModelFormatType::PyTorch
279        } else if std::fs::read_dir(path)
280            .ok()
281            .and_then(|entries| {
282                entries
283                    .filter_map(|e| e.ok())
284                    .find(|e| e.path().extension().and_then(|s| s.to_str()) == Some("gguf"))
285            })
286            .is_some()
287        {
288            ModelFormatType::GGUF
289        } else {
290            ModelFormatType::Unknown
291        }
292    }
293
294    /// Read architecture type from config.json
295    fn read_architecture(&self, config_path: &Path) -> Option<Architecture> {
296        let content = std::fs::read_to_string(config_path).ok()?;
297        let config: serde_json::Value = serde_json::from_str(&content).ok()?;
298
299        // Try "model_type" field
300        if let Some(model_type) = config.get("model_type").and_then(|v| v.as_str()) {
301            return Some(Architecture::from_str(model_type));
302        }
303
304        // Try "architectures" array
305        if let Some(architectures) = config.get("architectures").and_then(|v| v.as_array()) {
306            if let Some(arch) = architectures.first().and_then(|v| v.as_str()) {
307                return Some(Architecture::from_str(arch));
308            }
309        }
310
311        None
312    }
313}
314
315impl Default for DefaultModelRegistry {
316    fn default() -> Self {
317        Self::new()
318    }
319}
320
321// ============================================================================
322// 内联单元测试
323// ============================================================================
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn test_architecture_from_str() {
331        assert_eq!(Architecture::from_str("llama"), Architecture::Llama);
332        assert_eq!(
333            Architecture::from_str("LlamaForCausalLM"),
334            Architecture::Llama
335        );
336        assert_eq!(Architecture::from_str("qwen2"), Architecture::Qwen2);
337        assert_eq!(Architecture::from_str("mistral"), Architecture::Mistral);
338        assert_eq!(Architecture::from_str("phi"), Architecture::Phi);
339        assert_eq!(Architecture::from_str("gpt2"), Architecture::GPT2);
340        assert_eq!(
341            Architecture::from_str("unknown_arch"),
342            Architecture::Unknown
343        );
344    }
345
346    #[test]
347    fn test_architecture_copy() {
348        let arch = Architecture::Llama;
349        let arch2 = arch;
350        assert_eq!(arch, arch2);
351    }
352
353    #[test]
354    fn test_model_format_type_eq() {
355        assert_eq!(ModelFormatType::SafeTensors, ModelFormatType::SafeTensors);
356        assert_ne!(ModelFormatType::SafeTensors, ModelFormatType::PyTorch);
357    }
358
359    #[test]
360    fn test_model_alias_creation() {
361        let alias = ModelAlias {
362            name: "test".to_string(),
363            target: "test/model".to_string(),
364            description: Some("Test model".to_string()),
365        };
366
367        assert_eq!(alias.name, "test");
368        assert_eq!(alias.target, "test/model");
369        assert!(alias.description.is_some());
370    }
371
372    #[test]
373    fn test_model_alias_clone() {
374        let alias = ModelAlias {
375            name: "test".to_string(),
376            target: "test/model".to_string(),
377            description: None,
378        };
379
380        let cloned = alias.clone();
381        assert_eq!(alias.name, cloned.name);
382        assert_eq!(alias.target, cloned.target);
383    }
384
385    #[test]
386    fn test_model_discovery_entry() {
387        let entry = ModelDiscoveryEntry {
388            id: "test-model".to_string(),
389            path: PathBuf::from("/path/to/model"),
390            format: ModelFormatType::SafeTensors,
391            architecture: Some(Architecture::Llama),
392            is_valid: true,
393        };
394
395        assert_eq!(entry.id, "test-model");
396        assert_eq!(entry.format, ModelFormatType::SafeTensors);
397        assert!(entry.is_valid);
398    }
399
400    #[test]
401    fn test_registry_creation() {
402        let registry = DefaultModelRegistry::new();
403        assert_eq!(registry.aliases.len(), 0);
404        assert_eq!(registry.discovered_models.len(), 0);
405    }
406
407    #[test]
408    fn test_registry_default() {
409        let registry = DefaultModelRegistry::default();
410        assert_eq!(registry.aliases.len(), 0);
411    }
412
413    #[test]
414    fn test_registry_with_defaults() {
415        let registry = DefaultModelRegistry::with_defaults();
416
417        // 应该有一些默认别名
418        assert!(registry.aliases.len() > 0);
419
420        // 测试一些常见别名
421        assert!(registry.aliases.contains_key("tinyllama"));
422        assert!(registry.aliases.contains_key("llama2-7b"));
423    }
424
425    #[test]
426    fn test_registry_register_alias() {
427        let mut registry = DefaultModelRegistry::new();
428
429        registry.register_alias("test", "test/model");
430
431        assert_eq!(
432            registry.aliases.get("test"),
433            Some(&"test/model".to_string())
434        );
435    }
436
437    #[test]
438    fn test_registry_resolve_model_id() {
439        let mut registry = DefaultModelRegistry::new();
440
441        registry.register_alias("mymodel", "org/actual-model");
442
443        let resolved = registry.resolve_model_id("mymodel");
444        assert_eq!(resolved, "org/actual-model");
445
446        // 未注册的别名应该返回原始值
447        let unresolved = registry.resolve_model_id("unknown");
448        assert_eq!(unresolved, "unknown");
449    }
450
451    #[test]
452    fn test_registry_list_aliases() {
453        let mut registry = DefaultModelRegistry::new();
454
455        registry.register_alias("model1", "org/model1");
456        registry.register_alias("model2", "org/model2");
457
458        let aliases = registry.list_aliases();
459        assert_eq!(aliases.len(), 2);
460    }
461
462    #[test]
463    fn test_architecture_debug() {
464        let arch = Architecture::Llama;
465        let debug_str = format!("{:?}", arch);
466        assert!(debug_str.contains("Llama"));
467    }
468
469    #[test]
470    fn test_model_format_debug() {
471        let format = ModelFormatType::SafeTensors;
472        let debug_str = format!("{:?}", format);
473        assert!(debug_str.contains("SafeTensors"));
474    }
475
476    #[test]
477    fn test_model_discovery_entry_clone() {
478        let entry = ModelDiscoveryEntry {
479            id: "test".to_string(),
480            path: PathBuf::from("/path"),
481            format: ModelFormatType::GGUF,
482            architecture: Some(Architecture::Mistral),
483            is_valid: false,
484        };
485
486        let cloned = entry.clone();
487        assert_eq!(entry.id, cloned.id);
488        assert_eq!(entry.format, cloned.format);
489        assert_eq!(entry.is_valid, cloned.is_valid);
490    }
491
492    #[test]
493    fn test_registry_multiple_aliases_same_target() {
494        let mut registry = DefaultModelRegistry::new();
495
496        registry.register_alias("alias1", "org/model");
497        registry.register_alias("alias2", "org/model");
498
499        assert_eq!(registry.resolve_model_id("alias1"), "org/model");
500        assert_eq!(registry.resolve_model_id("alias2"), "org/model");
501    }
502
503    #[test]
504    fn test_architecture_serialization() {
505        let arch = Architecture::Qwen2;
506        let json = serde_json::to_string(&arch).unwrap();
507        assert!(json.contains("Qwen2"));
508
509        let deserialized: Architecture = serde_json::from_str(&json).unwrap();
510        assert_eq!(deserialized, arch);
511    }
512}