Skip to main content

codewhale_agent/
lib.rs

1use std::collections::HashMap;
2
3use codewhale_config::ProviderKind;
4use serde::{Deserialize, Serialize};
5
6/// High-level model family used for shared identity affordances across clients.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8pub enum ModelFamily {
9    DeepSeek,
10    Anthropic,
11    OpenAI,
12    Google,
13    Meta,
14    Mistral,
15    Qwen,
16    Grok,
17    Cohere,
18    GptOss,
19    Inferencer,
20}
21
22/// Metadata for a single model entry in the registry.
23///
24/// Each model has a canonical `id` used by the provider, a list of `aliases`
25/// that users may reference, and capability flags indicating whether the model
26/// supports tool use and reasoning.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ModelInfo {
29    /// The canonical model identifier used by the provider (e.g. `"deepseek-v4-pro"`).
30    pub id: String,
31    /// The provider that serves this model.
32    pub provider: ProviderKind,
33    /// Alternative names that users can use to reference this model (case-insensitive).
34    pub aliases: Vec<String>,
35    /// Whether this model supports tool/function calling.
36    pub supports_tools: bool,
37    /// Whether this model supports extended reasoning.
38    pub supports_reasoning: bool,
39}
40
41/// The result of resolving a user-requested model name to a concrete model entry.
42///
43/// Contains the resolved [`ModelInfo`], whether a fallback was used, and the
44/// chain of resolution strategies that were attempted.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ModelResolution {
47    /// The original model name requested by the user, if any.
48    pub requested: Option<String>,
49    /// The concrete model that was resolved.
50    pub resolved: ModelInfo,
51    /// Whether a fallback was used because the requested model was not found.
52    pub used_fallback: bool,
53    /// The ordered list of resolution strategies that were attempted.
54    pub fallback_chain: Vec<String>,
55}
56
57/// A registry of supported models and their aliases, used to resolve user-facing
58/// model names to concrete provider-specific model entries.
59///
60/// The default registry is populated with all built-in models across supported
61/// providers (DeepSeek, NVIDIA NIM, OpenAI-compatible, and others).
62#[derive(Debug, Clone)]
63pub struct ModelRegistry {
64    models: Vec<ModelInfo>,
65    alias_map: HashMap<String, usize>,
66}
67
68/// Creates a registry pre-populated with all built-in models and their aliases.
69impl Default for ModelRegistry {
70    fn default() -> Self {
71        let models = vec![
72            ModelInfo {
73                id: "deepseek-v4-pro".to_string(),
74                provider: ProviderKind::Deepseek,
75                aliases: vec![],
76                supports_tools: true,
77                supports_reasoning: true,
78            },
79            ModelInfo {
80                id: "deepseek-v4-flash".to_string(),
81                provider: ProviderKind::Deepseek,
82                aliases: vec![
83                    "deepseek-chat".to_string(),
84                    "deepseek-reasoner".to_string(),
85                    "deepseek-r1".to_string(),
86                    "deepseek-v3".to_string(),
87                    "deepseek-v3.2".to_string(),
88                ],
89                supports_tools: true,
90                supports_reasoning: true,
91            },
92            ModelInfo {
93                id: "deepseek-ai/deepseek-v4-pro".to_string(),
94                provider: ProviderKind::NvidiaNim,
95                aliases: vec![
96                    "deepseek-v4-pro".to_string(),
97                    "nvidia-deepseek-v4-pro".to_string(),
98                    "nim-deepseek-v4-pro".to_string(),
99                ],
100                supports_tools: true,
101                supports_reasoning: true,
102            },
103            ModelInfo {
104                id: "deepseek-ai/deepseek-v4-flash".to_string(),
105                provider: ProviderKind::NvidiaNim,
106                aliases: vec![
107                    "deepseek-v4-flash".to_string(),
108                    "deepseek-chat".to_string(),
109                    "deepseek-reasoner".to_string(),
110                    "nvidia-deepseek-v4-flash".to_string(),
111                    "nim-deepseek-v4-flash".to_string(),
112                ],
113                supports_tools: true,
114                supports_reasoning: true,
115            },
116            ModelInfo {
117                id: "deepseek-v4-pro".to_string(),
118                provider: ProviderKind::Openai,
119                aliases: vec!["openai-compatible-deepseek-v4-pro".to_string()],
120                supports_tools: true,
121                supports_reasoning: true,
122            },
123            ModelInfo {
124                id: "deepseek-v4-flash".to_string(),
125                provider: ProviderKind::Openai,
126                aliases: vec!["openai-compatible-deepseek-v4-flash".to_string()],
127                supports_tools: true,
128                supports_reasoning: true,
129            },
130            ModelInfo {
131                id: "deepseek-ai/deepseek-v4-flash".to_string(),
132                provider: ProviderKind::Atlascloud,
133                aliases: vec![
134                    "deepseek-v4-flash".to_string(),
135                    "atlascloud-deepseek-v4-flash".to_string(),
136                ],
137                supports_tools: true,
138                supports_reasoning: true,
139            },
140            ModelInfo {
141                id: "deepseek-ai/deepseek-v4-pro".to_string(),
142                provider: ProviderKind::Atlascloud,
143                aliases: vec![
144                    "deepseek-v4-pro".to_string(),
145                    "atlascloud-deepseek-v4-pro".to_string(),
146                ],
147                supports_tools: true,
148                supports_reasoning: true,
149            },
150            ModelInfo {
151                id: "deepseek-reasoner".to_string(),
152                provider: ProviderKind::WanjieArk,
153                aliases: vec![
154                    "wanjie-deepseek-reasoner".to_string(),
155                    "ark-wanjie-deepseek-reasoner".to_string(),
156                ],
157                supports_tools: true,
158                supports_reasoning: true,
159            },
160            ModelInfo {
161                id: "DeepSeek-V4-Pro".to_string(),
162                provider: ProviderKind::Volcengine,
163                aliases: vec![
164                    "deepseek-v4-pro".to_string(),
165                    "volcengine-deepseek-v4-pro".to_string(),
166                    "ark-deepseek-v4-pro".to_string(),
167                ],
168                supports_tools: true,
169                supports_reasoning: true,
170            },
171            ModelInfo {
172                id: "DeepSeek-V4-Flash".to_string(),
173                provider: ProviderKind::Volcengine,
174                aliases: vec![
175                    "deepseek-v4-flash".to_string(),
176                    "deepseek-chat".to_string(),
177                    "volcengine-deepseek-v4-flash".to_string(),
178                    "ark-deepseek-v4-flash".to_string(),
179                ],
180                supports_tools: true,
181                supports_reasoning: true,
182            },
183            ModelInfo {
184                id: "trinity-large-thinking".to_string(),
185                provider: ProviderKind::Arcee,
186                aliases: vec![
187                    "trinity".to_string(),
188                    "arcee-trinity".to_string(),
189                    "arcee-trinity-large-thinking".to_string(),
190                ],
191                supports_tools: true,
192                supports_reasoning: true,
193            },
194            ModelInfo {
195                id: "deepseek/deepseek-v4-pro".to_string(),
196                provider: ProviderKind::Openrouter,
197                aliases: vec![
198                    "deepseek-v4-pro".to_string(),
199                    "openrouter-deepseek-v4-pro".to_string(),
200                ],
201                supports_tools: true,
202                supports_reasoning: true,
203            },
204            ModelInfo {
205                id: "deepseek/deepseek-v4-flash".to_string(),
206                provider: ProviderKind::Openrouter,
207                aliases: vec![
208                    "deepseek-v4-flash".to_string(),
209                    "deepseek-chat".to_string(),
210                    "deepseek-reasoner".to_string(),
211                    "openrouter-deepseek-v4-flash".to_string(),
212                ],
213                supports_tools: true,
214                supports_reasoning: true,
215            },
216            ModelInfo {
217                id: "arcee-ai/trinity-large-thinking".to_string(),
218                provider: ProviderKind::Openrouter,
219                aliases: vec![
220                    "trinity".to_string(),
221                    "trinity-large-thinking".to_string(),
222                    "arcee-trinity-large-thinking".to_string(),
223                ],
224                supports_tools: true,
225                supports_reasoning: true,
226            },
227            ModelInfo {
228                id: "xiaomi/mimo-v2.5-pro".to_string(),
229                provider: ProviderKind::Openrouter,
230                aliases: vec![
231                    "openrouter-mimo-v2.5-pro".to_string(),
232                    "openrouter-xiaomi-mimo-v2.5-pro".to_string(),
233                ],
234                supports_tools: true,
235                supports_reasoning: true,
236            },
237            ModelInfo {
238                id: "xiaomi/mimo-v2.5".to_string(),
239                provider: ProviderKind::Openrouter,
240                aliases: vec![
241                    "openrouter-mimo-v2.5".to_string(),
242                    "openrouter-xiaomi-mimo-v2.5".to_string(),
243                ],
244                supports_tools: true,
245                supports_reasoning: true,
246            },
247            ModelInfo {
248                id: "qwen/qwen3.6-flash".to_string(),
249                provider: ProviderKind::Openrouter,
250                aliases: vec!["qwen3.6-flash".to_string(), "qwen-3.6-flash".to_string()],
251                supports_tools: true,
252                supports_reasoning: true,
253            },
254            ModelInfo {
255                id: "qwen/qwen3.6-35b-a3b".to_string(),
256                provider: ProviderKind::Openrouter,
257                aliases: vec![
258                    "qwen3.6-35b-a3b".to_string(),
259                    "qwen-3.6-35b-a3b".to_string(),
260                ],
261                supports_tools: true,
262                supports_reasoning: true,
263            },
264            ModelInfo {
265                id: "qwen/qwen3.6-max-preview".to_string(),
266                provider: ProviderKind::Openrouter,
267                aliases: vec![
268                    "qwen3.6-max-preview".to_string(),
269                    "qwen-3.6-max-preview".to_string(),
270                    "qwen-max-preview".to_string(),
271                ],
272                supports_tools: true,
273                supports_reasoning: true,
274            },
275            ModelInfo {
276                id: "qwen/qwen3.6-27b".to_string(),
277                provider: ProviderKind::Openrouter,
278                aliases: vec!["qwen3.6-27b".to_string(), "qwen-3.6-27b".to_string()],
279                supports_tools: true,
280                supports_reasoning: true,
281            },
282            ModelInfo {
283                id: "qwen/qwen3.6-plus".to_string(),
284                provider: ProviderKind::Openrouter,
285                aliases: vec!["qwen3.6-plus".to_string(), "qwen-3.6-plus".to_string()],
286                supports_tools: true,
287                supports_reasoning: true,
288            },
289            ModelInfo {
290                id: "moonshotai/kimi-k2.6".to_string(),
291                provider: ProviderKind::Openrouter,
292                aliases: vec!["openrouter-kimi-k2.6".to_string()],
293                supports_tools: true,
294                supports_reasoning: true,
295            },
296            ModelInfo {
297                id: "minimax/minimax-m3".to_string(),
298                provider: ProviderKind::Openrouter,
299                aliases: vec![
300                    "minimax-m3".to_string(),
301                    "minimax-m-3".to_string(),
302                    "openrouter-minimax-m3".to_string(),
303                ],
304                supports_tools: true,
305                supports_reasoning: true,
306            },
307            ModelInfo {
308                id: "z-ai/glm-5.1".to_string(),
309                provider: ProviderKind::Openrouter,
310                aliases: vec!["glm-5.1".to_string(), "zai-glm-5.1".to_string()],
311                supports_tools: true,
312                supports_reasoning: true,
313            },
314            ModelInfo {
315                id: "tencent/hy3-preview".to_string(),
316                provider: ProviderKind::Openrouter,
317                aliases: vec!["hy3-preview".to_string(), "tencent-hy3-preview".to_string()],
318                supports_tools: true,
319                supports_reasoning: true,
320            },
321            ModelInfo {
322                id: "google/gemma-4-31b-it".to_string(),
323                provider: ProviderKind::Openrouter,
324                aliases: vec!["gemma-4-31b".to_string(), "gemma-4-31b-it".to_string()],
325                supports_tools: true,
326                supports_reasoning: true,
327            },
328            ModelInfo {
329                id: "google/gemma-4-26b-a4b-it".to_string(),
330                provider: ProviderKind::Openrouter,
331                aliases: vec![
332                    "gemma-4-26b-a4b".to_string(),
333                    "gemma-4-26b-a4b-it".to_string(),
334                ],
335                supports_tools: true,
336                supports_reasoning: true,
337            },
338            ModelInfo {
339                id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free".to_string(),
340                provider: ProviderKind::Openrouter,
341                aliases: vec![
342                    "nemotron-3-nano-omni".to_string(),
343                    "nemotron-3-nano-omni-reasoning".to_string(),
344                ],
345                supports_tools: true,
346                supports_reasoning: true,
347            },
348            ModelInfo {
349                id: "mimo-v2.5-pro".to_string(),
350                provider: ProviderKind::XiaomiMimo,
351                aliases: vec![
352                    "mimo".to_string(),
353                    "pro".to_string(),
354                    "xiaomi-mimo-v2.5-pro".to_string(),
355                    "xiaomi-mimo-v2-5-pro".to_string(),
356                ],
357                supports_tools: true,
358                supports_reasoning: true,
359            },
360            ModelInfo {
361                id: "mimo-v2.5".to_string(),
362                provider: ProviderKind::XiaomiMimo,
363                aliases: vec![
364                    "omni".to_string(),
365                    "mimo-omni".to_string(),
366                    "v2.5-omni".to_string(),
367                    "mimo-v2.5-omni".to_string(),
368                    "xiaomi-mimo-v2.5".to_string(),
369                    "xiaomi-mimo-v2.5-omni".to_string(),
370                ],
371                supports_tools: true,
372                supports_reasoning: true,
373            },
374            ModelInfo {
375                id: "mimo-v2.5-asr".to_string(),
376                provider: ProviderKind::XiaomiMimo,
377                aliases: vec![
378                    "asr".to_string(),
379                    "speech-to-text".to_string(),
380                    "transcribe".to_string(),
381                ],
382                supports_tools: false,
383                supports_reasoning: false,
384            },
385            ModelInfo {
386                id: "mimo-v2.5-tts".to_string(),
387                provider: ProviderKind::XiaomiMimo,
388                aliases: vec![
389                    "tts".to_string(),
390                    "speech".to_string(),
391                    "mimo-tts".to_string(),
392                ],
393                supports_tools: false,
394                supports_reasoning: false,
395            },
396            ModelInfo {
397                id: "mimo-v2.5-tts-voicedesign".to_string(),
398                provider: ProviderKind::XiaomiMimo,
399                aliases: vec![
400                    "voicedesign".to_string(),
401                    "voice-design".to_string(),
402                    "mimo-voice-design".to_string(),
403                ],
404                supports_tools: false,
405                supports_reasoning: false,
406            },
407            ModelInfo {
408                id: "mimo-v2.5-tts-voiceclone".to_string(),
409                provider: ProviderKind::XiaomiMimo,
410                aliases: vec![
411                    "voiceclone".to_string(),
412                    "voice-clone".to_string(),
413                    "mimo-voice-clone".to_string(),
414                ],
415                supports_tools: false,
416                supports_reasoning: false,
417            },
418            ModelInfo {
419                id: "mimo-v2-tts".to_string(),
420                provider: ProviderKind::XiaomiMimo,
421                aliases: vec!["mimo-v2-speech".to_string()],
422                supports_tools: false,
423                supports_reasoning: false,
424            },
425            ModelInfo {
426                id: "deepseek/deepseek-v4-pro".to_string(),
427                provider: ProviderKind::Novita,
428                aliases: vec![
429                    "deepseek-v4-pro".to_string(),
430                    "novita-deepseek-v4-pro".to_string(),
431                ],
432                supports_tools: true,
433                supports_reasoning: true,
434            },
435            ModelInfo {
436                id: "deepseek/deepseek-v4-flash".to_string(),
437                provider: ProviderKind::Novita,
438                aliases: vec![
439                    "deepseek-v4-flash".to_string(),
440                    "deepseek-chat".to_string(),
441                    "deepseek-reasoner".to_string(),
442                    "novita-deepseek-v4-flash".to_string(),
443                ],
444                supports_tools: true,
445                supports_reasoning: true,
446            },
447            ModelInfo {
448                id: "accounts/fireworks/models/deepseek-v4-pro".to_string(),
449                provider: ProviderKind::Fireworks,
450                aliases: vec![
451                    "deepseek-v4-pro".to_string(),
452                    "fireworks-deepseek-v4-pro".to_string(),
453                ],
454                supports_tools: true,
455                supports_reasoning: true,
456            },
457            ModelInfo {
458                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
459                provider: ProviderKind::Siliconflow,
460                aliases: vec![
461                    "deepseek-v4-pro".to_string(),
462                    "deepseek-reasoner".to_string(),
463                    "deepseek-r1".to_string(),
464                    "siliconflow-deepseek-v4-pro".to_string(),
465                ],
466                supports_tools: true,
467                supports_reasoning: true,
468            },
469            ModelInfo {
470                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
471                provider: ProviderKind::Siliconflow,
472                aliases: vec![
473                    "deepseek-v4-flash".to_string(),
474                    "deepseek-chat".to_string(),
475                    "deepseek-v3".to_string(),
476                    "siliconflow-deepseek-v4-flash".to_string(),
477                ],
478                supports_tools: true,
479                supports_reasoning: true,
480            },
481            ModelInfo {
482                id: "trinity-large-preview".to_string(),
483                provider: ProviderKind::Arcee,
484                aliases: vec!["arcee-trinity-large-preview".to_string()],
485                supports_tools: true,
486                supports_reasoning: false,
487            },
488            ModelInfo {
489                id: "kimi-k2.6".to_string(),
490                provider: ProviderKind::Moonshot,
491                aliases: vec![
492                    "kimi".to_string(),
493                    "kimi-k2".to_string(),
494                    "moonshot-kimi-k2.6".to_string(),
495                ],
496                supports_tools: true,
497                supports_reasoning: true,
498            },
499            ModelInfo {
500                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
501                provider: ProviderKind::Sglang,
502                aliases: vec![
503                    "deepseek-v4-pro".to_string(),
504                    "sglang-deepseek-v4-pro".to_string(),
505                ],
506                supports_tools: true,
507                supports_reasoning: true,
508            },
509            ModelInfo {
510                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
511                provider: ProviderKind::Sglang,
512                aliases: vec![
513                    "deepseek-v4-flash".to_string(),
514                    "deepseek-chat".to_string(),
515                    "deepseek-reasoner".to_string(),
516                    "sglang-deepseek-v4-flash".to_string(),
517                ],
518                supports_tools: true,
519                supports_reasoning: true,
520            },
521            ModelInfo {
522                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
523                provider: ProviderKind::Vllm,
524                aliases: vec![
525                    "deepseek-v4-pro".to_string(),
526                    "vllm-deepseek-v4-pro".to_string(),
527                ],
528                supports_tools: true,
529                supports_reasoning: true,
530            },
531            ModelInfo {
532                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
533                provider: ProviderKind::Vllm,
534                aliases: vec![
535                    "deepseek-v4-flash".to_string(),
536                    "deepseek-chat".to_string(),
537                    "deepseek-reasoner".to_string(),
538                    "vllm-deepseek-v4-flash".to_string(),
539                ],
540                supports_tools: true,
541                supports_reasoning: true,
542            },
543            ModelInfo {
544                id: "deepseek-coder:1.3b".to_string(),
545                provider: ProviderKind::Ollama,
546                aliases: vec![],
547                supports_tools: true,
548                supports_reasoning: false,
549            },
550            ModelInfo {
551                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
552                provider: ProviderKind::Huggingface,
553                aliases: vec![
554                    "deepseek-v4-pro".to_string(),
555                    "hf-deepseek-v4-pro".to_string(),
556                ],
557                supports_tools: true,
558                supports_reasoning: true,
559            },
560            ModelInfo {
561                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
562                provider: ProviderKind::Huggingface,
563                aliases: vec![
564                    "deepseek-v4-flash".to_string(),
565                    "deepseek-chat".to_string(),
566                    "deepseek-reasoner".to_string(),
567                    "hf-deepseek-v4-flash".to_string(),
568                ],
569                supports_tools: true,
570                supports_reasoning: true,
571            },
572            // Together AI provider models
573            ModelInfo {
574                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
575                provider: ProviderKind::Together,
576                aliases: vec![
577                    "deepseek-v4-pro".to_string(),
578                    "together-deepseek-v4-pro".to_string(),
579                ],
580                supports_tools: true,
581                supports_reasoning: true,
582            },
583            ModelInfo {
584                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
585                provider: ProviderKind::Together,
586                aliases: vec![
587                    "deepseek-v4-flash".to_string(),
588                    "deepseek-chat".to_string(),
589                    "together-deepseek-v4-flash".to_string(),
590                ],
591                supports_tools: true,
592                supports_reasoning: true,
593            },
594            // Qwen 3.7 Max (OpenRouter)
595            ModelInfo {
596                id: "qwen/qwen3.7-max".to_string(),
597                provider: ProviderKind::Openrouter,
598                aliases: vec!["qwen3.7-max".to_string(), "qwen-3.7-max".to_string()],
599                supports_tools: true,
600                supports_reasoning: true,
601            },
602            // OpenAI Codex (ChatGPT OAuth) models
603            ModelInfo {
604                id: "gpt-5.5".to_string(),
605                provider: ProviderKind::OpenaiCodex,
606                aliases: vec!["codex-gpt-5.5".to_string(), "chatgpt-gpt-5.5".to_string()],
607                supports_tools: true,
608                supports_reasoning: true,
609            },
610            // Anthropic native Messages API models (#3014)
611            ModelInfo {
612                id: "claude-opus-4-8".to_string(),
613                provider: ProviderKind::Anthropic,
614                aliases: vec!["opus".to_string(), "claude-opus".to_string()],
615                supports_tools: true,
616                supports_reasoning: true,
617            },
618            ModelInfo {
619                id: "claude-sonnet-4-6".to_string(),
620                provider: ProviderKind::Anthropic,
621                aliases: vec!["sonnet".to_string(), "claude-sonnet".to_string()],
622                supports_tools: true,
623                supports_reasoning: true,
624            },
625            ModelInfo {
626                id: "claude-haiku-4-5".to_string(),
627                provider: ProviderKind::Anthropic,
628                aliases: vec!["haiku".to_string(), "claude-haiku".to_string()],
629                supports_tools: true,
630                supports_reasoning: false,
631            },
632            // MiniMax 2.7 (OpenRouter)
633            ModelInfo {
634                id: "minimax/minimax-2.7".to_string(),
635                provider: ProviderKind::Openrouter,
636                aliases: vec![
637                    "minimax-2.7".to_string(),
638                    "minimax-2-7".to_string(),
639                    "openrouter-minimax-2.7".to_string(),
640                ],
641                supports_tools: true,
642                supports_reasoning: true,
643            },
644            // NVIDIA Nemotron 3 Ultra (OpenRouter)
645            ModelInfo {
646                id: "nvidia/nemotron-3-ultra".to_string(),
647                provider: ProviderKind::Openrouter,
648                aliases: vec![
649                    "nemotron-3-ultra".to_string(),
650                    "nvidia-nemotron-3-ultra".to_string(),
651                ],
652                supports_tools: true,
653                supports_reasoning: true,
654            },
655        ];
656        Self::new(models)
657    }
658}
659
660impl ModelRegistry {
661    /// Creates a new registry from a list of [`ModelInfo`] entries.
662    ///
663    /// Builds an internal alias map for fast lookup by model id or alias.
664    /// If multiple models share the same id or alias, the first one registered
665    /// takes priority.
666    #[must_use]
667    pub fn new(models: Vec<ModelInfo>) -> Self {
668        let mut alias_map = HashMap::new();
669        for (idx, model) in models.iter().enumerate() {
670            alias_map.entry(normalize(&model.id)).or_insert(idx);
671            for alias in &model.aliases {
672                alias_map.entry(normalize(alias)).or_insert(idx);
673            }
674        }
675        Self { models, alias_map }
676    }
677
678    /// Returns a clone of all models in the registry.
679    #[must_use]
680    pub fn list(&self) -> Vec<ModelInfo> {
681        self.models.clone()
682    }
683
684    /// Resolves a user-requested model name to a concrete [`ModelInfo`].
685    ///
686    /// Resolution follows this priority order:
687    /// 1. If the provider is Ollama, the requested name is used as-is (to
688    ///    support arbitrary local model tags like `qwen2.5-coder:7b`).
689    /// 2. If a `provider_hint` is given, search for a model matching that
690    ///    provider whose id or alias matches the request (case-insensitive).
691    /// 3. Look up the alias map for a case-insensitive match.
692    /// 4. Fall back to the first model belonging to the hinted provider
693    ///    (or DeepSeek if no hint was given).
694    /// 5. As a last resort, fall back to the first model in the registry.
695    #[must_use]
696    pub fn resolve(
697        &self,
698        requested: Option<&str>,
699        provider_hint: Option<ProviderKind>,
700    ) -> ModelResolution {
701        let mut fallback_chain = Vec::new();
702
703        if let Some(name) = requested {
704            fallback_chain.push(format!("requested:{name}"));
705            if provider_hint == Some(ProviderKind::Ollama) {
706                return ModelResolution {
707                    requested: Some(name.to_string()),
708                    resolved: ModelInfo {
709                        id: name.trim().to_string(),
710                        provider: ProviderKind::Ollama,
711                        aliases: Vec::new(),
712                        supports_tools: true,
713                        supports_reasoning: false,
714                    },
715                    used_fallback: false,
716                    fallback_chain,
717                };
718            }
719            if let Some(provider) = provider_hint
720                && let Some(model) = self
721                    .models
722                    .iter()
723                    .find(|m| m.provider == provider && model_matches(m, name))
724                    .cloned()
725            {
726                return ModelResolution {
727                    requested: Some(name.to_string()),
728                    resolved: model,
729                    used_fallback: false,
730                    fallback_chain,
731                };
732            }
733            if provider_hint == Some(ProviderKind::Atlascloud)
734                && let Some(model) = atlascloud_passthrough_model(name)
735            {
736                return ModelResolution {
737                    requested: Some(name.to_string()),
738                    resolved: model,
739                    used_fallback: false,
740                    fallback_chain,
741                };
742            }
743            if provider_hint == Some(ProviderKind::Arcee)
744                && let Some(model) = arcee_passthrough_model(name)
745            {
746                return ModelResolution {
747                    requested: Some(name.to_string()),
748                    resolved: model,
749                    used_fallback: false,
750                    fallback_chain,
751                };
752            }
753            if provider_hint == Some(ProviderKind::XiaomiMimo)
754                && let Some(model) = xiaomi_mimo_passthrough_model(name)
755            {
756                return ModelResolution {
757                    requested: Some(name.to_string()),
758                    resolved: model,
759                    used_fallback: false,
760                    fallback_chain,
761                };
762            }
763            if let Some(idx) = self.alias_map.get(&normalize(name)) {
764                return ModelResolution {
765                    requested: Some(name.to_string()),
766                    resolved: preserve_requested_model_id_case(self.models[*idx].clone(), name),
767                    used_fallback: false,
768                    fallback_chain,
769                };
770            }
771        }
772
773        let provider = provider_hint.unwrap_or(ProviderKind::Deepseek);
774        fallback_chain.push(format!("provider_default:{}", provider.as_str()));
775        if let Some(model) = self.models.iter().find(|m| m.provider == provider).cloned() {
776            return ModelResolution {
777                requested: requested.map(ToOwned::to_owned),
778                resolved: model,
779                used_fallback: true,
780                fallback_chain,
781            };
782        }
783
784        let final_fallback = self.models.first().cloned().unwrap_or(ModelInfo {
785            id: "deepseek-v4-pro".to_string(),
786            provider: ProviderKind::Deepseek,
787            aliases: Vec::new(),
788            supports_tools: true,
789            supports_reasoning: true,
790        });
791        fallback_chain.push("global_default:deepseek-v4-pro".to_string());
792        ModelResolution {
793            requested: requested.map(ToOwned::to_owned),
794            resolved: final_fallback,
795            used_fallback: true,
796            fallback_chain,
797        }
798    }
799}
800
801fn normalize(value: &str) -> String {
802    value.trim().to_ascii_lowercase()
803}
804
805#[must_use]
806/// Classify a model identifier by its underlying model family.
807pub fn model_family(model_id: &str) -> ModelFamily {
808    let normalized = normalize(model_id);
809    if normalized.is_empty() {
810        return ModelFamily::Inferencer;
811    }
812
813    if normalized.contains("deepseek") {
814        return ModelFamily::DeepSeek;
815    }
816    if normalized.contains("claude") || normalized.contains("anthropic") {
817        return ModelFamily::Anthropic;
818    }
819    if normalized.contains("gpt-oss") || normalized.contains("gpt_oss") {
820        return ModelFamily::GptOss;
821    }
822    if normalized.starts_with("gpt-")
823        || normalized.contains("/gpt-")
824        || normalized.contains("openai/")
825    {
826        return ModelFamily::OpenAI;
827    }
828    if normalized.contains("gemini")
829        || normalized.contains("gemma")
830        || normalized.contains("google/")
831    {
832        return ModelFamily::Google;
833    }
834    if normalized.contains("llama") || normalized.contains("meta-") || normalized.contains("meta/")
835    {
836        return ModelFamily::Meta;
837    }
838    if normalized.contains("mistral")
839        || normalized.contains("mixtral")
840        || normalized.contains("codestral")
841    {
842        return ModelFamily::Mistral;
843    }
844    if normalized.contains("qwen") {
845        return ModelFamily::Qwen;
846    }
847    if normalized.contains("grok") {
848        return ModelFamily::Grok;
849    }
850    if normalized.contains("cohere") || normalized.contains("command-r") {
851        return ModelFamily::Cohere;
852    }
853
854    ModelFamily::Inferencer
855}
856
857fn model_matches(model: &ModelInfo, requested: &str) -> bool {
858    let requested = normalize(requested);
859    normalize(&model.id) == requested
860        || model
861            .aliases
862            .iter()
863            .any(|alias| normalize(alias) == requested)
864}
865
866fn preserve_requested_model_id_case(mut model: ModelInfo, requested: &str) -> ModelInfo {
867    let requested = requested.trim();
868    if model.id.eq_ignore_ascii_case(requested) {
869        model.id = requested.to_string();
870    }
871    model
872}
873
874fn atlascloud_passthrough_model(requested: &str) -> Option<ModelInfo> {
875    let requested = requested.trim();
876    if requested.is_empty() || !requested.contains('/') {
877        return None;
878    }
879
880    Some(ModelInfo {
881        id: requested.to_string(),
882        provider: ProviderKind::Atlascloud,
883        aliases: Vec::new(),
884        supports_tools: true,
885        supports_reasoning: true,
886    })
887}
888
889fn arcee_passthrough_model(requested: &str) -> Option<ModelInfo> {
890    let requested = requested.trim();
891    if requested.is_empty() {
892        return None;
893    }
894    let supports_reasoning = requested.to_ascii_lowercase().contains("thinking");
895
896    Some(ModelInfo {
897        id: requested.to_string(),
898        provider: ProviderKind::Arcee,
899        aliases: Vec::new(),
900        supports_tools: true,
901        supports_reasoning,
902    })
903}
904
905fn xiaomi_mimo_passthrough_model(requested: &str) -> Option<ModelInfo> {
906    let requested = requested.trim();
907    if requested.is_empty() || requested.chars().any(char::is_control) {
908        return None;
909    }
910
911    Some(ModelInfo {
912        id: requested.to_string(),
913        provider: ProviderKind::XiaomiMimo,
914        aliases: Vec::new(),
915        supports_tools: true,
916        supports_reasoning: true,
917    })
918}
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923
924    #[test]
925    fn deepseek_v4_pro_alias_stays_deepseek_by_default() {
926        let registry = ModelRegistry::default();
927        let resolved = registry.resolve(Some("deepseek-v4-pro"), None);
928
929        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
930        assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
931    }
932
933    #[test]
934    fn deepseek_v4_pro_alias_resolves_to_nvidia_nim_when_provider_hinted() {
935        let registry = ModelRegistry::default();
936        let resolved = registry.resolve(Some("deepseek-v4-pro"), Some(ProviderKind::NvidiaNim));
937
938        assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
939        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
940    }
941
942    #[test]
943    fn nvidia_nim_default_uses_catalog_model_id() {
944        let registry = ModelRegistry::default();
945        let resolved = registry.resolve(None, Some(ProviderKind::NvidiaNim));
946
947        assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
948        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
949    }
950
951    #[test]
952    fn deepseek_v4_flash_alias_resolves_to_nvidia_nim_when_provider_hinted() {
953        let registry = ModelRegistry::default();
954        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::NvidiaNim));
955
956        assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
957        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
958    }
959
960    #[test]
961    fn atlascloud_default_uses_namespaced_model_id() {
962        let registry = ModelRegistry::default();
963        let resolved = registry.resolve(None, Some(ProviderKind::Atlascloud));
964
965        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
966        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
967        assert!(resolved.resolved.supports_reasoning);
968    }
969
970    #[test]
971    fn deepseek_v4_flash_alias_resolves_to_atlascloud_when_provider_hinted() {
972        let registry = ModelRegistry::default();
973        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Atlascloud));
974
975        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
976        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
977    }
978
979    #[test]
980    fn deepseek_v4_pro_alias_resolves_to_atlascloud_when_provider_hinted() {
981        let registry = ModelRegistry::default();
982        let resolved = registry.resolve(Some("deepseek-v4-pro"), Some(ProviderKind::Atlascloud));
983
984        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
985        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
986    }
987
988    #[test]
989    fn atlascloud_provider_hint_passes_through_explicit_model_id() {
990        let registry = ModelRegistry::default();
991        let resolved =
992            registry.resolve(Some("openai/gpt-5.2-chat"), Some(ProviderKind::Atlascloud));
993
994        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
995        assert_eq!(resolved.resolved.id, "openai/gpt-5.2-chat");
996        assert!(resolved.resolved.supports_tools);
997        assert!(resolved.resolved.supports_reasoning);
998        assert!(!resolved.used_fallback);
999    }
1000
1001    #[test]
1002    fn atlascloud_provider_hint_preserves_explicit_model_id_case() {
1003        let registry = ModelRegistry::default();
1004        let resolved = registry.resolve(Some("Qwen/Qwen3-Coder"), Some(ProviderKind::Atlascloud));
1005
1006        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1007        assert_eq!(resolved.resolved.id, "Qwen/Qwen3-Coder");
1008        assert!(!resolved.used_fallback);
1009    }
1010
1011    #[test]
1012    fn atlascloud_plain_unknown_model_still_uses_provider_default() {
1013        let registry = ModelRegistry::default();
1014        let resolved = registry.resolve(Some("not-in-atlas"), Some(ProviderKind::Atlascloud));
1015
1016        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1017        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
1018        assert!(resolved.used_fallback);
1019    }
1020
1021    #[test]
1022    fn openrouter_default_uses_namespaced_model_id() {
1023        let registry = ModelRegistry::default();
1024        let resolved = registry.resolve(None, Some(ProviderKind::Openrouter));
1025
1026        assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
1027        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
1028    }
1029
1030    #[test]
1031    fn xiaomi_mimo_default_uses_canonical_model_id() {
1032        let registry = ModelRegistry::default();
1033        let resolved = registry.resolve(None, Some(ProviderKind::XiaomiMimo));
1034
1035        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1036        assert_eq!(resolved.resolved.id, "mimo-v2.5-pro");
1037        assert!(resolved.resolved.supports_reasoning);
1038    }
1039
1040    #[test]
1041    fn xiaomi_mimo_tts_aliases_resolve_when_provider_hinted() {
1042        let registry = ModelRegistry::default();
1043        let resolved = registry.resolve(Some("tts"), Some(ProviderKind::XiaomiMimo));
1044        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1045        assert_eq!(resolved.resolved.id, "mimo-v2.5-tts");
1046        assert!(!resolved.resolved.supports_tools);
1047        assert!(!resolved.resolved.supports_reasoning);
1048
1049        let resolved = registry.resolve(Some("voice-design"), Some(ProviderKind::XiaomiMimo));
1050        assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voicedesign");
1051
1052        let resolved = registry.resolve(Some("voiceclone"), Some(ProviderKind::XiaomiMimo));
1053        assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voiceclone");
1054    }
1055
1056    #[test]
1057    fn xiaomi_mimo_chat_aliases_resolve_when_provider_hinted() {
1058        let registry = ModelRegistry::default();
1059
1060        let resolved = registry.resolve(Some("omni"), Some(ProviderKind::XiaomiMimo));
1061        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1062        assert_eq!(resolved.resolved.id, "mimo-v2.5");
1063        assert!(resolved.resolved.supports_tools);
1064    }
1065
1066    #[test]
1067    fn xiaomi_mimo_provider_hint_preserves_custom_model_id() {
1068        let registry = ModelRegistry::default();
1069        let resolved =
1070            registry.resolve(Some("account-custom-mimo"), Some(ProviderKind::XiaomiMimo));
1071
1072        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1073        assert_eq!(resolved.resolved.id, "account-custom-mimo");
1074        assert!(!resolved.used_fallback);
1075    }
1076
1077    #[test]
1078    fn xiaomi_mimo_provider_hint_does_not_reclassify_openrouter_model_id() {
1079        let registry = ModelRegistry::default();
1080        let resolved = registry.resolve(
1081            Some("deepseek/deepseek-v4-pro"),
1082            Some(ProviderKind::XiaomiMimo),
1083        );
1084
1085        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1086        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
1087        assert!(!resolved.used_fallback);
1088    }
1089
1090    #[test]
1091    fn wanjie_ark_default_uses_reasoner_model_id() {
1092        let registry = ModelRegistry::default();
1093        let resolved = registry.resolve(None, Some(ProviderKind::WanjieArk));
1094
1095        assert_eq!(resolved.resolved.provider, ProviderKind::WanjieArk);
1096        assert_eq!(resolved.resolved.id, "deepseek-reasoner");
1097        assert!(resolved.resolved.supports_reasoning);
1098    }
1099
1100    #[test]
1101    fn novita_default_uses_namespaced_model_id() {
1102        let registry = ModelRegistry::default();
1103        let resolved = registry.resolve(None, Some(ProviderKind::Novita));
1104
1105        assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
1106        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
1107    }
1108
1109    #[test]
1110    fn fireworks_default_uses_canonical_model_id() {
1111        let registry = ModelRegistry::default();
1112        let resolved = registry.resolve(None, Some(ProviderKind::Fireworks));
1113
1114        assert_eq!(resolved.resolved.provider, ProviderKind::Fireworks);
1115        assert_eq!(
1116            resolved.resolved.id,
1117            "accounts/fireworks/models/deepseek-v4-pro"
1118        );
1119    }
1120
1121    #[test]
1122    fn siliconflow_default_uses_canonical_pro_model_id() {
1123        let registry = ModelRegistry::default();
1124        let resolved = registry.resolve(None, Some(ProviderKind::Siliconflow));
1125
1126        assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
1127        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1128        assert!(resolved.resolved.supports_reasoning);
1129    }
1130
1131    #[test]
1132    fn arcee_default_uses_direct_trinity_large_thinking_model_id() {
1133        let registry = ModelRegistry::default();
1134        let resolved = registry.resolve(None, Some(ProviderKind::Arcee));
1135
1136        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1137        assert_eq!(resolved.resolved.id, "trinity-large-thinking");
1138        assert!(resolved.resolved.supports_reasoning);
1139    }
1140
1141    #[test]
1142    fn arcee_trinity_alias_resolves_to_direct_large_thinking_not_openrouter() {
1143        let registry = ModelRegistry::default();
1144        let resolved = registry.resolve(Some("trinity"), Some(ProviderKind::Arcee));
1145
1146        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1147        assert_eq!(resolved.resolved.id, "trinity-large-thinking");
1148        assert!(resolved.resolved.supports_reasoning);
1149    }
1150
1151    #[test]
1152    fn arcee_trinity_mini_remains_explicit_compatibility_model() {
1153        let registry = ModelRegistry::default();
1154        let resolved = registry.resolve(Some("trinity-mini"), Some(ProviderKind::Arcee));
1155
1156        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1157        assert_eq!(resolved.resolved.id, "trinity-mini");
1158        assert!(!resolved.resolved.supports_reasoning);
1159    }
1160
1161    #[test]
1162    fn arcee_provider_hint_preserves_explicit_future_model_id() {
1163        let registry = ModelRegistry::default();
1164        let resolved = registry.resolve(Some("trinity-large-next"), Some(ProviderKind::Arcee));
1165
1166        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1167        assert_eq!(resolved.resolved.id, "trinity-large-next");
1168        assert!(!resolved.resolved.supports_reasoning);
1169        assert!(!resolved.used_fallback);
1170    }
1171
1172    #[test]
1173    fn deepseek_reasoner_alias_resolves_to_siliconflow_pro_when_provider_hinted() {
1174        let registry = ModelRegistry::default();
1175        let resolved = registry.resolve(Some("deepseek-reasoner"), Some(ProviderKind::Siliconflow));
1176
1177        assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
1178        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1179    }
1180
1181    #[test]
1182    fn deepseek_v4_flash_alias_resolves_to_siliconflow_flash_when_provider_hinted() {
1183        let registry = ModelRegistry::default();
1184        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Siliconflow));
1185
1186        assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
1187        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
1188    }
1189
1190    #[test]
1191    fn sglang_default_uses_canonical_model_id() {
1192        let registry = ModelRegistry::default();
1193        let resolved = registry.resolve(None, Some(ProviderKind::Sglang));
1194
1195        assert_eq!(resolved.resolved.provider, ProviderKind::Sglang);
1196        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1197    }
1198
1199    #[test]
1200    fn deepseek_v4_flash_alias_resolves_to_openrouter_when_provider_hinted() {
1201        let registry = ModelRegistry::default();
1202        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Openrouter));
1203
1204        assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
1205        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
1206    }
1207
1208    #[test]
1209    fn recent_openrouter_large_model_aliases_resolve_when_provider_hinted() {
1210        let registry = ModelRegistry::default();
1211
1212        for (alias, expected) in [
1213            ("trinity-large-thinking", "arcee-ai/trinity-large-thinking"),
1214            ("qwen3.6-flash", "qwen/qwen3.6-flash"),
1215            ("qwen3.6-35b-a3b", "qwen/qwen3.6-35b-a3b"),
1216            ("qwen3.6-max-preview", "qwen/qwen3.6-max-preview"),
1217            ("qwen3.6-plus", "qwen/qwen3.6-plus"),
1218            ("gemma-4-31b-it", "google/gemma-4-31b-it"),
1219            ("glm-5.1", "z-ai/glm-5.1"),
1220            ("minimax-m3", "minimax/minimax-m3"),
1221            ("openrouter-mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro"),
1222            ("openrouter-kimi-k2.6", "moonshotai/kimi-k2.6"),
1223        ] {
1224            let resolved = registry.resolve(Some(alias), Some(ProviderKind::Openrouter));
1225
1226            assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
1227            assert_eq!(resolved.resolved.id, expected);
1228            assert!(resolved.resolved.supports_tools);
1229            assert!(resolved.resolved.supports_reasoning);
1230        }
1231    }
1232
1233    #[test]
1234    fn deepseek_v4_flash_alias_resolves_to_novita_when_provider_hinted() {
1235        let registry = ModelRegistry::default();
1236        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Novita));
1237
1238        assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
1239        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
1240    }
1241
1242    #[test]
1243    fn deepseek_v4_flash_alias_resolves_to_sglang_when_provider_hinted() {
1244        let registry = ModelRegistry::default();
1245        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Sglang));
1246
1247        assert_eq!(resolved.resolved.provider, ProviderKind::Sglang);
1248        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
1249    }
1250
1251    #[test]
1252    fn vllm_default_uses_canonical_model_id() {
1253        let registry = ModelRegistry::default();
1254        let resolved = registry.resolve(None, Some(ProviderKind::Vllm));
1255
1256        assert_eq!(resolved.resolved.provider, ProviderKind::Vllm);
1257        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1258    }
1259
1260    #[test]
1261    fn ollama_default_uses_small_local_model_id() {
1262        let registry = ModelRegistry::default();
1263        let resolved = registry.resolve(None, Some(ProviderKind::Ollama));
1264
1265        assert_eq!(resolved.resolved.provider, ProviderKind::Ollama);
1266        assert_eq!(resolved.resolved.id, "deepseek-coder:1.3b");
1267        assert!(!resolved.resolved.supports_reasoning);
1268    }
1269
1270    #[test]
1271    fn ollama_requested_model_tag_is_preserved() {
1272        let registry = ModelRegistry::default();
1273        let resolved = registry.resolve(Some("qwen2.5-coder:7b"), Some(ProviderKind::Ollama));
1274
1275        assert_eq!(resolved.resolved.provider, ProviderKind::Ollama);
1276        assert_eq!(resolved.resolved.id, "qwen2.5-coder:7b");
1277        assert!(!resolved.used_fallback);
1278    }
1279
1280    #[test]
1281    fn deepseek_v4_flash_alias_resolves_to_vllm_when_provider_hinted() {
1282        let registry = ModelRegistry::default();
1283        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Vllm));
1284
1285        assert_eq!(resolved.resolved.provider, ProviderKind::Vllm);
1286        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
1287    }
1288
1289    #[test]
1290    fn preserves_requested_model_casing_for_third_party_providers() {
1291        let registry = ModelRegistry::default();
1292        let resolved = registry.resolve(Some("DeepSeek-V4-Pro"), None);
1293
1294        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
1295        assert_eq!(resolved.resolved.id, "DeepSeek-V4-Pro");
1296    }
1297
1298    #[test]
1299    fn registry_casing_takes_priority_over_requested_casing_with_provider_hint() {
1300        let registry = ModelRegistry::default();
1301        let resolved = registry.resolve(Some("DeepSeek-V4-Pro"), Some(ProviderKind::Deepseek));
1302
1303        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
1304        // Registry's canonical id is used even when user provides different casing
1305        assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
1306    }
1307
1308    #[test]
1309    fn preserves_requested_model_casing_without_surrounding_whitespace() {
1310        let registry = ModelRegistry::default();
1311        let resolved = registry.resolve(Some("  DeepSeek-V4-Pro  "), None);
1312
1313        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
1314        assert_eq!(resolved.resolved.id, "DeepSeek-V4-Pro");
1315    }
1316
1317    #[test]
1318    fn alias_match_does_not_override_requested_casing() {
1319        let registry = ModelRegistry::default();
1320        let resolved = registry.resolve(Some("deepseek-reasoner"), None);
1321
1322        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
1323        assert_eq!(resolved.resolved.id, "deepseek-v4-flash");
1324    }
1325
1326    #[test]
1327    fn model_family_classifies_known_model_ids() {
1328        assert_eq!(model_family("deepseek-v4-pro"), ModelFamily::DeepSeek);
1329        assert_eq!(model_family("openai/gpt-5.4"), ModelFamily::OpenAI);
1330        assert_eq!(
1331            model_family("anthropic/claude-opus-4-7"),
1332            ModelFamily::Anthropic
1333        );
1334        assert_eq!(
1335            model_family("meta-llama/llama-3.3-70b-instruct"),
1336            ModelFamily::Meta
1337        );
1338        assert_eq!(model_family("Qwen/Qwen3-Coder"), ModelFamily::Qwen);
1339    }
1340
1341    #[test]
1342    fn model_family_uses_underlying_model_for_router_ids() {
1343        assert_eq!(
1344            model_family("groq/llama-3.3-70b-versatile"),
1345            ModelFamily::Meta
1346        );
1347        assert_eq!(
1348            model_family("openrouter/openai/gpt-5.4"),
1349            ModelFamily::OpenAI
1350        );
1351        assert_eq!(
1352            model_family("fireworks/accounts/fireworks/models/deepseek-v4-pro"),
1353            ModelFamily::DeepSeek
1354        );
1355    }
1356
1357    #[test]
1358    fn model_family_covers_prominent_google_and_mistral_model_names() {
1359        assert_eq!(model_family("google/gemma-3-27b-it"), ModelFamily::Google);
1360        assert_eq!(
1361            model_family("mistralai/mixtral-8x22b"),
1362            ModelFamily::Mistral
1363        );
1364        assert_eq!(model_family("codestral-latest"), ModelFamily::Mistral);
1365    }
1366
1367    #[test]
1368    fn model_family_falls_back_to_inferencer_for_unknown_models() {
1369        assert_eq!(
1370            model_family("custom-gateway/my-private-model"),
1371            ModelFamily::Inferencer
1372        );
1373        assert_eq!(model_family(""), ModelFamily::Inferencer);
1374    }
1375}