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