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.7-code".to_string(),
291                provider: ProviderKind::Openrouter,
292                aliases: vec![
293                    "kimi-k2.7-code".to_string(),
294                    "openrouter-kimi-k2.7-code".to_string(),
295                ],
296                supports_tools: true,
297                supports_reasoning: true,
298            },
299            ModelInfo {
300                id: "moonshotai/kimi-k2.6".to_string(),
301                provider: ProviderKind::Openrouter,
302                aliases: vec!["openrouter-kimi-k2.6".to_string()],
303                supports_tools: true,
304                supports_reasoning: true,
305            },
306            ModelInfo {
307                id: "minimax/minimax-m3".to_string(),
308                provider: ProviderKind::Openrouter,
309                aliases: vec![
310                    "minimax-m3".to_string(),
311                    "minimax-m-3".to_string(),
312                    "openrouter-minimax-m3".to_string(),
313                ],
314                supports_tools: true,
315                supports_reasoning: true,
316            },
317            ModelInfo {
318                id: "z-ai/glm-5.1".to_string(),
319                provider: ProviderKind::Openrouter,
320                aliases: vec!["glm-5.1".to_string(), "zai-glm-5.1".to_string()],
321                supports_tools: true,
322                supports_reasoning: true,
323            },
324            ModelInfo {
325                id: "tencent/hy3-preview".to_string(),
326                provider: ProviderKind::Openrouter,
327                aliases: vec!["hy3-preview".to_string(), "tencent-hy3-preview".to_string()],
328                supports_tools: true,
329                supports_reasoning: true,
330            },
331            ModelInfo {
332                id: "google/gemma-4-31b-it".to_string(),
333                provider: ProviderKind::Openrouter,
334                aliases: vec!["gemma-4-31b".to_string(), "gemma-4-31b-it".to_string()],
335                supports_tools: true,
336                supports_reasoning: true,
337            },
338            ModelInfo {
339                id: "google/gemma-4-26b-a4b-it".to_string(),
340                provider: ProviderKind::Openrouter,
341                aliases: vec![
342                    "gemma-4-26b-a4b".to_string(),
343                    "gemma-4-26b-a4b-it".to_string(),
344                ],
345                supports_tools: true,
346                supports_reasoning: true,
347            },
348            ModelInfo {
349                id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free".to_string(),
350                provider: ProviderKind::Openrouter,
351                aliases: vec![
352                    "nemotron-3-nano-omni".to_string(),
353                    "nemotron-3-nano-omni-reasoning".to_string(),
354                ],
355                supports_tools: true,
356                supports_reasoning: true,
357            },
358            ModelInfo {
359                id: "mimo-v2.5-pro".to_string(),
360                provider: ProviderKind::XiaomiMimo,
361                aliases: vec![
362                    "mimo".to_string(),
363                    "pro".to_string(),
364                    "xiaomi-mimo-v2.5-pro".to_string(),
365                    "xiaomi-mimo-v2-5-pro".to_string(),
366                ],
367                supports_tools: true,
368                supports_reasoning: true,
369            },
370            ModelInfo {
371                id: "mimo-v2.5".to_string(),
372                provider: ProviderKind::XiaomiMimo,
373                aliases: vec![
374                    "omni".to_string(),
375                    "mimo-omni".to_string(),
376                    "v2.5-omni".to_string(),
377                    "mimo-v2.5-omni".to_string(),
378                    "xiaomi-mimo-v2.5".to_string(),
379                    "xiaomi-mimo-v2.5-omni".to_string(),
380                ],
381                supports_tools: true,
382                supports_reasoning: true,
383            },
384            ModelInfo {
385                id: "mimo-v2.5-asr".to_string(),
386                provider: ProviderKind::XiaomiMimo,
387                aliases: vec![
388                    "asr".to_string(),
389                    "speech-to-text".to_string(),
390                    "transcribe".to_string(),
391                ],
392                supports_tools: false,
393                supports_reasoning: false,
394            },
395            ModelInfo {
396                id: "mimo-v2.5-tts".to_string(),
397                provider: ProviderKind::XiaomiMimo,
398                aliases: vec![
399                    "tts".to_string(),
400                    "speech".to_string(),
401                    "mimo-tts".to_string(),
402                ],
403                supports_tools: false,
404                supports_reasoning: false,
405            },
406            ModelInfo {
407                id: "mimo-v2.5-tts-voicedesign".to_string(),
408                provider: ProviderKind::XiaomiMimo,
409                aliases: vec![
410                    "voicedesign".to_string(),
411                    "voice-design".to_string(),
412                    "mimo-voice-design".to_string(),
413                ],
414                supports_tools: false,
415                supports_reasoning: false,
416            },
417            ModelInfo {
418                id: "mimo-v2.5-tts-voiceclone".to_string(),
419                provider: ProviderKind::XiaomiMimo,
420                aliases: vec![
421                    "voiceclone".to_string(),
422                    "voice-clone".to_string(),
423                    "mimo-voice-clone".to_string(),
424                ],
425                supports_tools: false,
426                supports_reasoning: false,
427            },
428            ModelInfo {
429                id: "mimo-v2-tts".to_string(),
430                provider: ProviderKind::XiaomiMimo,
431                aliases: vec!["mimo-v2-speech".to_string()],
432                supports_tools: false,
433                supports_reasoning: false,
434            },
435            ModelInfo {
436                id: "deepseek/deepseek-v4-pro".to_string(),
437                provider: ProviderKind::Novita,
438                aliases: vec![
439                    "deepseek-v4-pro".to_string(),
440                    "novita-deepseek-v4-pro".to_string(),
441                ],
442                supports_tools: true,
443                supports_reasoning: true,
444            },
445            ModelInfo {
446                id: "deepseek/deepseek-v4-flash".to_string(),
447                provider: ProviderKind::Novita,
448                aliases: vec![
449                    "deepseek-v4-flash".to_string(),
450                    "deepseek-chat".to_string(),
451                    "deepseek-reasoner".to_string(),
452                    "novita-deepseek-v4-flash".to_string(),
453                ],
454                supports_tools: true,
455                supports_reasoning: true,
456            },
457            ModelInfo {
458                id: "accounts/fireworks/models/deepseek-v4-pro".to_string(),
459                provider: ProviderKind::Fireworks,
460                aliases: vec![
461                    "deepseek-v4-pro".to_string(),
462                    "fireworks-deepseek-v4-pro".to_string(),
463                ],
464                supports_tools: true,
465                supports_reasoning: true,
466            },
467            ModelInfo {
468                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
469                provider: ProviderKind::Siliconflow,
470                aliases: vec![
471                    "deepseek-v4-pro".to_string(),
472                    "deepseek-reasoner".to_string(),
473                    "deepseek-r1".to_string(),
474                    "siliconflow-deepseek-v4-pro".to_string(),
475                ],
476                supports_tools: true,
477                supports_reasoning: true,
478            },
479            ModelInfo {
480                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
481                provider: ProviderKind::Siliconflow,
482                aliases: vec![
483                    "deepseek-v4-flash".to_string(),
484                    "deepseek-chat".to_string(),
485                    "deepseek-v3".to_string(),
486                    "siliconflow-deepseek-v4-flash".to_string(),
487                ],
488                supports_tools: true,
489                supports_reasoning: true,
490            },
491            ModelInfo {
492                id: "trinity-large-preview".to_string(),
493                provider: ProviderKind::Arcee,
494                aliases: vec!["arcee-trinity-large-preview".to_string()],
495                supports_tools: true,
496                supports_reasoning: false,
497            },
498            ModelInfo {
499                id: "kimi-k2.7-code".to_string(),
500                provider: ProviderKind::Moonshot,
501                aliases: vec![
502                    "kimi".to_string(),
503                    "kimi-k2".to_string(),
504                    "kimi-k2.7".to_string(),
505                    "kimi-code".to_string(),
506                    "moonshot-kimi-k2.7-code".to_string(),
507                ],
508                supports_tools: true,
509                supports_reasoning: true,
510            },
511            ModelInfo {
512                id: "kimi-k2.6".to_string(),
513                provider: ProviderKind::Moonshot,
514                aliases: vec!["kimi-k2.6".to_string(), "moonshot-kimi-k2.6".to_string()],
515                supports_tools: true,
516                supports_reasoning: true,
517            },
518            ModelInfo {
519                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
520                provider: ProviderKind::Sglang,
521                aliases: vec![
522                    "deepseek-v4-pro".to_string(),
523                    "sglang-deepseek-v4-pro".to_string(),
524                ],
525                supports_tools: true,
526                supports_reasoning: true,
527            },
528            ModelInfo {
529                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
530                provider: ProviderKind::Sglang,
531                aliases: vec![
532                    "deepseek-v4-flash".to_string(),
533                    "deepseek-chat".to_string(),
534                    "deepseek-reasoner".to_string(),
535                    "sglang-deepseek-v4-flash".to_string(),
536                ],
537                supports_tools: true,
538                supports_reasoning: true,
539            },
540            ModelInfo {
541                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
542                provider: ProviderKind::Vllm,
543                aliases: vec![
544                    "deepseek-v4-pro".to_string(),
545                    "vllm-deepseek-v4-pro".to_string(),
546                ],
547                supports_tools: true,
548                supports_reasoning: true,
549            },
550            ModelInfo {
551                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
552                provider: ProviderKind::Vllm,
553                aliases: vec![
554                    "deepseek-v4-flash".to_string(),
555                    "deepseek-chat".to_string(),
556                    "deepseek-reasoner".to_string(),
557                    "vllm-deepseek-v4-flash".to_string(),
558                ],
559                supports_tools: true,
560                supports_reasoning: true,
561            },
562            ModelInfo {
563                id: "deepseek-coder:1.3b".to_string(),
564                provider: ProviderKind::Ollama,
565                aliases: vec![],
566                supports_tools: true,
567                supports_reasoning: false,
568            },
569            ModelInfo {
570                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
571                provider: ProviderKind::Huggingface,
572                aliases: vec![
573                    "deepseek-v4-pro".to_string(),
574                    "hf-deepseek-v4-pro".to_string(),
575                ],
576                supports_tools: true,
577                supports_reasoning: true,
578            },
579            ModelInfo {
580                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
581                provider: ProviderKind::Huggingface,
582                aliases: vec![
583                    "deepseek-v4-flash".to_string(),
584                    "deepseek-chat".to_string(),
585                    "deepseek-reasoner".to_string(),
586                    "hf-deepseek-v4-flash".to_string(),
587                ],
588                supports_tools: true,
589                supports_reasoning: true,
590            },
591            // Together AI provider models
592            ModelInfo {
593                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
594                provider: ProviderKind::Together,
595                aliases: vec![
596                    "deepseek-v4-pro".to_string(),
597                    "together-deepseek-v4-pro".to_string(),
598                ],
599                supports_tools: true,
600                supports_reasoning: true,
601            },
602            ModelInfo {
603                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
604                provider: ProviderKind::Together,
605                aliases: vec![
606                    "deepseek-v4-flash".to_string(),
607                    "deepseek-chat".to_string(),
608                    "together-deepseek-v4-flash".to_string(),
609                ],
610                supports_tools: true,
611                supports_reasoning: true,
612            },
613            // Qwen 3.7 Max (OpenRouter)
614            ModelInfo {
615                id: "qwen/qwen3.7-max".to_string(),
616                provider: ProviderKind::Openrouter,
617                aliases: vec!["qwen3.7-max".to_string(), "qwen-3.7-max".to_string()],
618                supports_tools: true,
619                supports_reasoning: true,
620            },
621            // OpenAI Codex (ChatGPT OAuth) models
622            ModelInfo {
623                id: "gpt-5.5".to_string(),
624                provider: ProviderKind::OpenaiCodex,
625                aliases: vec!["codex-gpt-5.5".to_string(), "chatgpt-gpt-5.5".to_string()],
626                supports_tools: true,
627                supports_reasoning: true,
628            },
629            // Anthropic native Messages API models (#3014)
630            ModelInfo {
631                id: "claude-opus-4-8".to_string(),
632                provider: ProviderKind::Anthropic,
633                aliases: vec!["opus".to_string(), "claude-opus".to_string()],
634                supports_tools: true,
635                supports_reasoning: true,
636            },
637            ModelInfo {
638                id: "claude-sonnet-4-6".to_string(),
639                provider: ProviderKind::Anthropic,
640                aliases: vec!["sonnet".to_string(), "claude-sonnet".to_string()],
641                supports_tools: true,
642                supports_reasoning: true,
643            },
644            ModelInfo {
645                id: "claude-haiku-4-5".to_string(),
646                provider: ProviderKind::Anthropic,
647                aliases: vec!["haiku".to_string(), "claude-haiku".to_string()],
648                supports_tools: true,
649                supports_reasoning: false,
650            },
651            // MiniMax 2.7 (OpenRouter)
652            ModelInfo {
653                id: "minimax/minimax-2.7".to_string(),
654                provider: ProviderKind::Openrouter,
655                aliases: vec![
656                    "minimax-2.7".to_string(),
657                    "minimax-2-7".to_string(),
658                    "openrouter-minimax-2.7".to_string(),
659                ],
660                supports_tools: true,
661                supports_reasoning: true,
662            },
663            // NVIDIA Nemotron 3 Ultra (OpenRouter)
664            ModelInfo {
665                id: "nvidia/nemotron-3-ultra-550b-a55b".to_string(),
666                provider: ProviderKind::Openrouter,
667                aliases: vec![
668                    "nvidia/nemotron-3-ultra".to_string(),
669                    "nemotron-3-ultra".to_string(),
670                    "nemotron-3-ultra-550b-a55b".to_string(),
671                    "nvidia-nemotron-3-ultra".to_string(),
672                    "nvidia-nemotron-3-ultra-550b-a55b".to_string(),
673                ],
674                supports_tools: true,
675                supports_reasoning: true,
676            },
677        ];
678        Self::new(models)
679    }
680}
681
682impl ModelRegistry {
683    /// Creates a new registry from a list of [`ModelInfo`] entries.
684    ///
685    /// Builds an internal alias map for fast lookup by model id or alias.
686    /// If multiple models share the same id or alias, the first one registered
687    /// takes priority.
688    #[must_use]
689    pub fn new(models: Vec<ModelInfo>) -> Self {
690        let mut alias_map = HashMap::new();
691        for (idx, model) in models.iter().enumerate() {
692            alias_map.entry(normalize(&model.id)).or_insert(idx);
693            for alias in &model.aliases {
694                alias_map.entry(normalize(alias)).or_insert(idx);
695            }
696        }
697        Self { models, alias_map }
698    }
699
700    /// Returns a clone of all models in the registry.
701    #[must_use]
702    pub fn list(&self) -> Vec<ModelInfo> {
703        self.models.clone()
704    }
705
706    /// Resolves a user-requested model name to a concrete [`ModelInfo`].
707    ///
708    /// Resolution follows this priority order:
709    /// 1. If the provider is Ollama, the requested name is used as-is (to
710    ///    support arbitrary local model tags like `qwen2.5-coder:7b`).
711    /// 2. If a `provider_hint` is given, search for a model matching that
712    ///    provider whose id or alias matches the request (case-insensitive).
713    /// 3. Look up the alias map for a case-insensitive match.
714    /// 4. Fall back to the first model belonging to the hinted provider
715    ///    (or DeepSeek if no hint was given).
716    /// 5. As a last resort, fall back to the first model in the registry.
717    #[must_use]
718    pub fn resolve(
719        &self,
720        requested: Option<&str>,
721        provider_hint: Option<ProviderKind>,
722    ) -> ModelResolution {
723        let mut fallback_chain = Vec::new();
724
725        if let Some(name) = requested {
726            fallback_chain.push(format!("requested:{name}"));
727            if provider_hint == Some(ProviderKind::Ollama) {
728                return ModelResolution {
729                    requested: Some(name.to_string()),
730                    resolved: ModelInfo {
731                        id: name.trim().to_string(),
732                        provider: ProviderKind::Ollama,
733                        aliases: Vec::new(),
734                        supports_tools: true,
735                        supports_reasoning: false,
736                    },
737                    used_fallback: false,
738                    fallback_chain,
739                };
740            }
741            if let Some(provider) = provider_hint
742                && let Some(model) = self
743                    .models
744                    .iter()
745                    .find(|m| m.provider == provider && model_matches(m, name))
746                    .cloned()
747            {
748                return ModelResolution {
749                    requested: Some(name.to_string()),
750                    resolved: model,
751                    used_fallback: false,
752                    fallback_chain,
753                };
754            }
755            if provider_hint == Some(ProviderKind::Atlascloud)
756                && let Some(model) = atlascloud_passthrough_model(name)
757            {
758                return ModelResolution {
759                    requested: Some(name.to_string()),
760                    resolved: model,
761                    used_fallback: false,
762                    fallback_chain,
763                };
764            }
765            if provider_hint == Some(ProviderKind::Arcee)
766                && let Some(model) = arcee_passthrough_model(name)
767            {
768                return ModelResolution {
769                    requested: Some(name.to_string()),
770                    resolved: model,
771                    used_fallback: false,
772                    fallback_chain,
773                };
774            }
775            if provider_hint == Some(ProviderKind::XiaomiMimo)
776                && let Some(model) = xiaomi_mimo_passthrough_model(name)
777            {
778                return ModelResolution {
779                    requested: Some(name.to_string()),
780                    resolved: model,
781                    used_fallback: false,
782                    fallback_chain,
783                };
784            }
785            if let Some(idx) = self.alias_map.get(&normalize(name)) {
786                return ModelResolution {
787                    requested: Some(name.to_string()),
788                    resolved: preserve_requested_model_id_case(self.models[*idx].clone(), name),
789                    used_fallback: false,
790                    fallback_chain,
791                };
792            }
793        }
794
795        let provider = provider_hint.unwrap_or(ProviderKind::Deepseek);
796        fallback_chain.push(format!("provider_default:{}", provider.as_str()));
797        if let Some(model) = self.models.iter().find(|m| m.provider == provider).cloned() {
798            return ModelResolution {
799                requested: requested.map(ToOwned::to_owned),
800                resolved: model,
801                used_fallback: true,
802                fallback_chain,
803            };
804        }
805
806        let final_fallback = self.models.first().cloned().unwrap_or(ModelInfo {
807            id: "deepseek-v4-pro".to_string(),
808            provider: ProviderKind::Deepseek,
809            aliases: Vec::new(),
810            supports_tools: true,
811            supports_reasoning: true,
812        });
813        fallback_chain.push("global_default:deepseek-v4-pro".to_string());
814        ModelResolution {
815            requested: requested.map(ToOwned::to_owned),
816            resolved: final_fallback,
817            used_fallback: true,
818            fallback_chain,
819        }
820    }
821}
822
823fn normalize(value: &str) -> String {
824    value.trim().to_ascii_lowercase()
825}
826
827#[must_use]
828/// Classify a model identifier by its underlying model family.
829pub fn model_family(model_id: &str) -> ModelFamily {
830    let normalized = normalize(model_id);
831    if normalized.is_empty() {
832        return ModelFamily::Inferencer;
833    }
834
835    if normalized.contains("deepseek") {
836        return ModelFamily::DeepSeek;
837    }
838    if normalized.contains("claude") || normalized.contains("anthropic") {
839        return ModelFamily::Anthropic;
840    }
841    if normalized.contains("gpt-oss") || normalized.contains("gpt_oss") {
842        return ModelFamily::GptOss;
843    }
844    if normalized.starts_with("gpt-")
845        || normalized.contains("/gpt-")
846        || normalized.contains("openai/")
847    {
848        return ModelFamily::OpenAI;
849    }
850    if normalized.contains("gemini")
851        || normalized.contains("gemma")
852        || normalized.contains("google/")
853    {
854        return ModelFamily::Google;
855    }
856    if normalized.contains("llama") || normalized.contains("meta-") || normalized.contains("meta/")
857    {
858        return ModelFamily::Meta;
859    }
860    if normalized.contains("mistral")
861        || normalized.contains("mixtral")
862        || normalized.contains("codestral")
863    {
864        return ModelFamily::Mistral;
865    }
866    if normalized.contains("qwen") {
867        return ModelFamily::Qwen;
868    }
869    if normalized.contains("grok") {
870        return ModelFamily::Grok;
871    }
872    if normalized.contains("cohere") || normalized.contains("command-r") {
873        return ModelFamily::Cohere;
874    }
875
876    ModelFamily::Inferencer
877}
878
879fn model_matches(model: &ModelInfo, requested: &str) -> bool {
880    let requested = normalize(requested);
881    normalize(&model.id) == requested
882        || model
883            .aliases
884            .iter()
885            .any(|alias| normalize(alias) == requested)
886}
887
888fn preserve_requested_model_id_case(mut model: ModelInfo, requested: &str) -> ModelInfo {
889    let requested = requested.trim();
890    if model.id.eq_ignore_ascii_case(requested) {
891        model.id = requested.to_string();
892    }
893    model
894}
895
896fn atlascloud_passthrough_model(requested: &str) -> Option<ModelInfo> {
897    let requested = requested.trim();
898    if requested.is_empty() || !requested.contains('/') {
899        return None;
900    }
901
902    Some(ModelInfo {
903        id: requested.to_string(),
904        provider: ProviderKind::Atlascloud,
905        aliases: Vec::new(),
906        supports_tools: true,
907        supports_reasoning: true,
908    })
909}
910
911fn arcee_passthrough_model(requested: &str) -> Option<ModelInfo> {
912    let requested = requested.trim();
913    if requested.is_empty() {
914        return None;
915    }
916    let supports_reasoning = requested.to_ascii_lowercase().contains("thinking");
917
918    Some(ModelInfo {
919        id: requested.to_string(),
920        provider: ProviderKind::Arcee,
921        aliases: Vec::new(),
922        supports_tools: true,
923        supports_reasoning,
924    })
925}
926
927fn xiaomi_mimo_passthrough_model(requested: &str) -> Option<ModelInfo> {
928    let requested = requested.trim();
929    if requested.is_empty() || requested.chars().any(char::is_control) {
930        return None;
931    }
932
933    Some(ModelInfo {
934        id: requested.to_string(),
935        provider: ProviderKind::XiaomiMimo,
936        aliases: Vec::new(),
937        supports_tools: true,
938        supports_reasoning: true,
939    })
940}
941
942#[cfg(test)]
943mod tests {
944    use super::*;
945
946    #[test]
947    fn deepseek_v4_pro_alias_stays_deepseek_by_default() {
948        let registry = ModelRegistry::default();
949        let resolved = registry.resolve(Some("deepseek-v4-pro"), None);
950
951        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
952        assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
953    }
954
955    #[test]
956    fn deepseek_v4_pro_alias_resolves_to_nvidia_nim_when_provider_hinted() {
957        let registry = ModelRegistry::default();
958        let resolved = registry.resolve(Some("deepseek-v4-pro"), Some(ProviderKind::NvidiaNim));
959
960        assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
961        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
962    }
963
964    #[test]
965    fn nvidia_nim_default_uses_catalog_model_id() {
966        let registry = ModelRegistry::default();
967        let resolved = registry.resolve(None, Some(ProviderKind::NvidiaNim));
968
969        assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
970        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
971    }
972
973    #[test]
974    fn deepseek_v4_flash_alias_resolves_to_nvidia_nim_when_provider_hinted() {
975        let registry = ModelRegistry::default();
976        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::NvidiaNim));
977
978        assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
979        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
980    }
981
982    #[test]
983    fn atlascloud_default_uses_namespaced_model_id() {
984        let registry = ModelRegistry::default();
985        let resolved = registry.resolve(None, Some(ProviderKind::Atlascloud));
986
987        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
988        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
989        assert!(resolved.resolved.supports_reasoning);
990    }
991
992    #[test]
993    fn deepseek_v4_flash_alias_resolves_to_atlascloud_when_provider_hinted() {
994        let registry = ModelRegistry::default();
995        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Atlascloud));
996
997        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
998        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
999    }
1000
1001    #[test]
1002    fn deepseek_v4_pro_alias_resolves_to_atlascloud_when_provider_hinted() {
1003        let registry = ModelRegistry::default();
1004        let resolved = registry.resolve(Some("deepseek-v4-pro"), Some(ProviderKind::Atlascloud));
1005
1006        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1007        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
1008    }
1009
1010    #[test]
1011    fn atlascloud_provider_hint_passes_through_explicit_model_id() {
1012        let registry = ModelRegistry::default();
1013        let resolved =
1014            registry.resolve(Some("openai/gpt-5.2-chat"), Some(ProviderKind::Atlascloud));
1015
1016        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1017        assert_eq!(resolved.resolved.id, "openai/gpt-5.2-chat");
1018        assert!(resolved.resolved.supports_tools);
1019        assert!(resolved.resolved.supports_reasoning);
1020        assert!(!resolved.used_fallback);
1021    }
1022
1023    #[test]
1024    fn atlascloud_provider_hint_preserves_explicit_model_id_case() {
1025        let registry = ModelRegistry::default();
1026        let resolved = registry.resolve(Some("Qwen/Qwen3-Coder"), Some(ProviderKind::Atlascloud));
1027
1028        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1029        assert_eq!(resolved.resolved.id, "Qwen/Qwen3-Coder");
1030        assert!(!resolved.used_fallback);
1031    }
1032
1033    #[test]
1034    fn atlascloud_plain_unknown_model_still_uses_provider_default() {
1035        let registry = ModelRegistry::default();
1036        let resolved = registry.resolve(Some("not-in-atlas"), Some(ProviderKind::Atlascloud));
1037
1038        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1039        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
1040        assert!(resolved.used_fallback);
1041    }
1042
1043    #[test]
1044    fn openrouter_default_uses_namespaced_model_id() {
1045        let registry = ModelRegistry::default();
1046        let resolved = registry.resolve(None, Some(ProviderKind::Openrouter));
1047
1048        assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
1049        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
1050    }
1051
1052    #[test]
1053    fn xiaomi_mimo_default_uses_canonical_model_id() {
1054        let registry = ModelRegistry::default();
1055        let resolved = registry.resolve(None, Some(ProviderKind::XiaomiMimo));
1056
1057        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1058        assert_eq!(resolved.resolved.id, "mimo-v2.5-pro");
1059        assert!(resolved.resolved.supports_reasoning);
1060    }
1061
1062    #[test]
1063    fn moonshot_default_and_aliases_use_kimi_k27_code() {
1064        let registry = ModelRegistry::default();
1065
1066        for requested in [None, Some("kimi"), Some("kimi-k2.7-code")] {
1067            let resolved = registry.resolve(requested, Some(ProviderKind::Moonshot));
1068
1069            assert_eq!(resolved.resolved.provider, ProviderKind::Moonshot);
1070            assert_eq!(resolved.resolved.id, "kimi-k2.7-code");
1071            assert!(resolved.resolved.supports_tools);
1072            assert!(resolved.resolved.supports_reasoning);
1073        }
1074    }
1075
1076    #[test]
1077    fn moonshot_explicit_kimi_k26_remains_available() {
1078        let registry = ModelRegistry::default();
1079        let resolved = registry.resolve(Some("kimi-k2.6"), Some(ProviderKind::Moonshot));
1080
1081        assert_eq!(resolved.resolved.provider, ProviderKind::Moonshot);
1082        assert_eq!(resolved.resolved.id, "kimi-k2.6");
1083        assert!(resolved.resolved.supports_reasoning);
1084    }
1085
1086    #[test]
1087    fn xiaomi_mimo_tts_aliases_resolve_when_provider_hinted() {
1088        let registry = ModelRegistry::default();
1089        let resolved = registry.resolve(Some("tts"), Some(ProviderKind::XiaomiMimo));
1090        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1091        assert_eq!(resolved.resolved.id, "mimo-v2.5-tts");
1092        assert!(!resolved.resolved.supports_tools);
1093        assert!(!resolved.resolved.supports_reasoning);
1094
1095        let resolved = registry.resolve(Some("voice-design"), Some(ProviderKind::XiaomiMimo));
1096        assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voicedesign");
1097
1098        let resolved = registry.resolve(Some("voiceclone"), Some(ProviderKind::XiaomiMimo));
1099        assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voiceclone");
1100    }
1101
1102    #[test]
1103    fn xiaomi_mimo_chat_aliases_resolve_when_provider_hinted() {
1104        let registry = ModelRegistry::default();
1105
1106        let resolved = registry.resolve(Some("omni"), Some(ProviderKind::XiaomiMimo));
1107        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1108        assert_eq!(resolved.resolved.id, "mimo-v2.5");
1109        assert!(resolved.resolved.supports_tools);
1110    }
1111
1112    #[test]
1113    fn xiaomi_mimo_provider_hint_preserves_custom_model_id() {
1114        let registry = ModelRegistry::default();
1115        let resolved =
1116            registry.resolve(Some("account-custom-mimo"), Some(ProviderKind::XiaomiMimo));
1117
1118        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1119        assert_eq!(resolved.resolved.id, "account-custom-mimo");
1120        assert!(!resolved.used_fallback);
1121    }
1122
1123    #[test]
1124    fn xiaomi_mimo_provider_hint_does_not_reclassify_openrouter_model_id() {
1125        let registry = ModelRegistry::default();
1126        let resolved = registry.resolve(
1127            Some("deepseek/deepseek-v4-pro"),
1128            Some(ProviderKind::XiaomiMimo),
1129        );
1130
1131        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1132        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
1133        assert!(!resolved.used_fallback);
1134    }
1135
1136    #[test]
1137    fn wanjie_ark_default_uses_reasoner_model_id() {
1138        let registry = ModelRegistry::default();
1139        let resolved = registry.resolve(None, Some(ProviderKind::WanjieArk));
1140
1141        assert_eq!(resolved.resolved.provider, ProviderKind::WanjieArk);
1142        assert_eq!(resolved.resolved.id, "deepseek-reasoner");
1143        assert!(resolved.resolved.supports_reasoning);
1144    }
1145
1146    #[test]
1147    fn novita_default_uses_namespaced_model_id() {
1148        let registry = ModelRegistry::default();
1149        let resolved = registry.resolve(None, Some(ProviderKind::Novita));
1150
1151        assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
1152        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
1153    }
1154
1155    #[test]
1156    fn fireworks_default_uses_canonical_model_id() {
1157        let registry = ModelRegistry::default();
1158        let resolved = registry.resolve(None, Some(ProviderKind::Fireworks));
1159
1160        assert_eq!(resolved.resolved.provider, ProviderKind::Fireworks);
1161        assert_eq!(
1162            resolved.resolved.id,
1163            "accounts/fireworks/models/deepseek-v4-pro"
1164        );
1165    }
1166
1167    #[test]
1168    fn siliconflow_default_uses_canonical_pro_model_id() {
1169        let registry = ModelRegistry::default();
1170        let resolved = registry.resolve(None, Some(ProviderKind::Siliconflow));
1171
1172        assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
1173        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1174        assert!(resolved.resolved.supports_reasoning);
1175    }
1176
1177    #[test]
1178    fn arcee_default_uses_direct_trinity_large_thinking_model_id() {
1179        let registry = ModelRegistry::default();
1180        let resolved = registry.resolve(None, Some(ProviderKind::Arcee));
1181
1182        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1183        assert_eq!(resolved.resolved.id, "trinity-large-thinking");
1184        assert!(resolved.resolved.supports_reasoning);
1185    }
1186
1187    #[test]
1188    fn arcee_trinity_alias_resolves_to_direct_large_thinking_not_openrouter() {
1189        let registry = ModelRegistry::default();
1190        let resolved = registry.resolve(Some("trinity"), Some(ProviderKind::Arcee));
1191
1192        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1193        assert_eq!(resolved.resolved.id, "trinity-large-thinking");
1194        assert!(resolved.resolved.supports_reasoning);
1195    }
1196
1197    #[test]
1198    fn arcee_trinity_mini_remains_explicit_compatibility_model() {
1199        let registry = ModelRegistry::default();
1200        let resolved = registry.resolve(Some("trinity-mini"), Some(ProviderKind::Arcee));
1201
1202        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1203        assert_eq!(resolved.resolved.id, "trinity-mini");
1204        assert!(!resolved.resolved.supports_reasoning);
1205    }
1206
1207    #[test]
1208    fn arcee_provider_hint_preserves_explicit_future_model_id() {
1209        let registry = ModelRegistry::default();
1210        let resolved = registry.resolve(Some("trinity-large-next"), Some(ProviderKind::Arcee));
1211
1212        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1213        assert_eq!(resolved.resolved.id, "trinity-large-next");
1214        assert!(!resolved.resolved.supports_reasoning);
1215        assert!(!resolved.used_fallback);
1216    }
1217
1218    #[test]
1219    fn deepseek_reasoner_alias_resolves_to_siliconflow_pro_when_provider_hinted() {
1220        let registry = ModelRegistry::default();
1221        let resolved = registry.resolve(Some("deepseek-reasoner"), Some(ProviderKind::Siliconflow));
1222
1223        assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
1224        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1225    }
1226
1227    #[test]
1228    fn deepseek_v4_flash_alias_resolves_to_siliconflow_flash_when_provider_hinted() {
1229        let registry = ModelRegistry::default();
1230        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Siliconflow));
1231
1232        assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
1233        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
1234    }
1235
1236    #[test]
1237    fn sglang_default_uses_canonical_model_id() {
1238        let registry = ModelRegistry::default();
1239        let resolved = registry.resolve(None, Some(ProviderKind::Sglang));
1240
1241        assert_eq!(resolved.resolved.provider, ProviderKind::Sglang);
1242        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1243    }
1244
1245    #[test]
1246    fn deepseek_v4_flash_alias_resolves_to_openrouter_when_provider_hinted() {
1247        let registry = ModelRegistry::default();
1248        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Openrouter));
1249
1250        assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
1251        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
1252    }
1253
1254    #[test]
1255    fn recent_openrouter_large_model_aliases_resolve_when_provider_hinted() {
1256        let registry = ModelRegistry::default();
1257
1258        for (alias, expected) in [
1259            ("trinity-large-thinking", "arcee-ai/trinity-large-thinking"),
1260            ("qwen3.6-flash", "qwen/qwen3.6-flash"),
1261            ("qwen3.6-35b-a3b", "qwen/qwen3.6-35b-a3b"),
1262            ("qwen3.6-max-preview", "qwen/qwen3.6-max-preview"),
1263            ("qwen3.6-plus", "qwen/qwen3.6-plus"),
1264            ("gemma-4-31b-it", "google/gemma-4-31b-it"),
1265            ("glm-5.1", "z-ai/glm-5.1"),
1266            ("minimax-m3", "minimax/minimax-m3"),
1267            ("openrouter-mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro"),
1268            ("openrouter-kimi-k2.7-code", "moonshotai/kimi-k2.7-code"),
1269            ("openrouter-kimi-k2.6", "moonshotai/kimi-k2.6"),
1270            ("nemotron-3-ultra", "nvidia/nemotron-3-ultra-550b-a55b"),
1271            (
1272                "nvidia/nemotron-3-ultra",
1273                "nvidia/nemotron-3-ultra-550b-a55b",
1274            ),
1275        ] {
1276            let resolved = registry.resolve(Some(alias), Some(ProviderKind::Openrouter));
1277
1278            assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
1279            assert_eq!(resolved.resolved.id, expected);
1280            assert!(resolved.resolved.supports_tools);
1281            assert!(resolved.resolved.supports_reasoning);
1282        }
1283    }
1284
1285    #[test]
1286    fn deepseek_v4_flash_alias_resolves_to_novita_when_provider_hinted() {
1287        let registry = ModelRegistry::default();
1288        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Novita));
1289
1290        assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
1291        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
1292    }
1293
1294    #[test]
1295    fn deepseek_v4_flash_alias_resolves_to_sglang_when_provider_hinted() {
1296        let registry = ModelRegistry::default();
1297        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Sglang));
1298
1299        assert_eq!(resolved.resolved.provider, ProviderKind::Sglang);
1300        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
1301    }
1302
1303    #[test]
1304    fn vllm_default_uses_canonical_model_id() {
1305        let registry = ModelRegistry::default();
1306        let resolved = registry.resolve(None, Some(ProviderKind::Vllm));
1307
1308        assert_eq!(resolved.resolved.provider, ProviderKind::Vllm);
1309        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1310    }
1311
1312    #[test]
1313    fn ollama_default_uses_small_local_model_id() {
1314        let registry = ModelRegistry::default();
1315        let resolved = registry.resolve(None, Some(ProviderKind::Ollama));
1316
1317        assert_eq!(resolved.resolved.provider, ProviderKind::Ollama);
1318        assert_eq!(resolved.resolved.id, "deepseek-coder:1.3b");
1319        assert!(!resolved.resolved.supports_reasoning);
1320    }
1321
1322    #[test]
1323    fn ollama_requested_model_tag_is_preserved() {
1324        let registry = ModelRegistry::default();
1325        let resolved = registry.resolve(Some("qwen2.5-coder:7b"), Some(ProviderKind::Ollama));
1326
1327        assert_eq!(resolved.resolved.provider, ProviderKind::Ollama);
1328        assert_eq!(resolved.resolved.id, "qwen2.5-coder:7b");
1329        assert!(!resolved.used_fallback);
1330    }
1331
1332    #[test]
1333    fn deepseek_v4_flash_alias_resolves_to_vllm_when_provider_hinted() {
1334        let registry = ModelRegistry::default();
1335        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Vllm));
1336
1337        assert_eq!(resolved.resolved.provider, ProviderKind::Vllm);
1338        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
1339    }
1340
1341    #[test]
1342    fn preserves_requested_model_casing_for_third_party_providers() {
1343        let registry = ModelRegistry::default();
1344        let resolved = registry.resolve(Some("DeepSeek-V4-Pro"), None);
1345
1346        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
1347        assert_eq!(resolved.resolved.id, "DeepSeek-V4-Pro");
1348    }
1349
1350    #[test]
1351    fn registry_casing_takes_priority_over_requested_casing_with_provider_hint() {
1352        let registry = ModelRegistry::default();
1353        let resolved = registry.resolve(Some("DeepSeek-V4-Pro"), Some(ProviderKind::Deepseek));
1354
1355        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
1356        // Registry's canonical id is used even when user provides different casing
1357        assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
1358    }
1359
1360    #[test]
1361    fn preserves_requested_model_casing_without_surrounding_whitespace() {
1362        let registry = ModelRegistry::default();
1363        let resolved = registry.resolve(Some("  DeepSeek-V4-Pro  "), None);
1364
1365        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
1366        assert_eq!(resolved.resolved.id, "DeepSeek-V4-Pro");
1367    }
1368
1369    #[test]
1370    fn alias_match_does_not_override_requested_casing() {
1371        let registry = ModelRegistry::default();
1372        let resolved = registry.resolve(Some("deepseek-reasoner"), None);
1373
1374        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
1375        assert_eq!(resolved.resolved.id, "deepseek-v4-flash");
1376    }
1377
1378    #[test]
1379    fn model_family_classifies_known_model_ids() {
1380        assert_eq!(model_family("deepseek-v4-pro"), ModelFamily::DeepSeek);
1381        assert_eq!(model_family("openai/gpt-5.4"), ModelFamily::OpenAI);
1382        assert_eq!(
1383            model_family("anthropic/claude-opus-4-7"),
1384            ModelFamily::Anthropic
1385        );
1386        assert_eq!(
1387            model_family("meta-llama/llama-3.3-70b-instruct"),
1388            ModelFamily::Meta
1389        );
1390        assert_eq!(model_family("Qwen/Qwen3-Coder"), ModelFamily::Qwen);
1391    }
1392
1393    #[test]
1394    fn model_family_uses_underlying_model_for_router_ids() {
1395        assert_eq!(
1396            model_family("groq/llama-3.3-70b-versatile"),
1397            ModelFamily::Meta
1398        );
1399        assert_eq!(
1400            model_family("openrouter/openai/gpt-5.4"),
1401            ModelFamily::OpenAI
1402        );
1403        assert_eq!(
1404            model_family("fireworks/accounts/fireworks/models/deepseek-v4-pro"),
1405            ModelFamily::DeepSeek
1406        );
1407    }
1408
1409    #[test]
1410    fn model_family_covers_prominent_google_and_mistral_model_names() {
1411        assert_eq!(model_family("google/gemma-3-27b-it"), ModelFamily::Google);
1412        assert_eq!(
1413            model_family("mistralai/mixtral-8x22b"),
1414            ModelFamily::Mistral
1415        );
1416        assert_eq!(model_family("codestral-latest"), ModelFamily::Mistral);
1417    }
1418
1419    #[test]
1420    fn model_family_falls_back_to_inferencer_for_unknown_models() {
1421        assert_eq!(
1422            model_family("custom-gateway/my-private-model"),
1423            ModelFamily::Inferencer
1424        );
1425        assert_eq!(model_family(""), ModelFamily::Inferencer);
1426    }
1427}