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        ];
806        Self::new(models)
807    }
808}
809
810impl ModelRegistry {
811    /// Creates a new registry from a list of [`ModelInfo`] entries.
812    ///
813    /// Builds an internal alias map for fast lookup by model id or alias.
814    /// If multiple models share the same id or alias, the first one registered
815    /// takes priority.
816    #[must_use]
817    pub fn new(models: Vec<ModelInfo>) -> Self {
818        let mut alias_map = HashMap::new();
819        for (idx, model) in models.iter().enumerate() {
820            alias_map.entry(normalize(&model.id)).or_insert(idx);
821            for alias in &model.aliases {
822                alias_map.entry(normalize(alias)).or_insert(idx);
823            }
824        }
825        Self { models, alias_map }
826    }
827
828    /// Returns a clone of all models in the registry.
829    #[must_use]
830    pub fn list(&self) -> Vec<ModelInfo> {
831        self.models.clone()
832    }
833
834    /// Resolves a user-requested model name to a concrete [`ModelInfo`].
835    ///
836    /// Resolution follows this priority order:
837    /// 1. If the provider is Ollama, the requested name is used as-is (to
838    ///    support arbitrary local model tags like `qwen2.5-coder:7b`).
839    /// 2. If a `provider_hint` is given, search for a model matching that
840    ///    provider whose id or alias matches the request (case-insensitive).
841    /// 3. Look up the alias map for a case-insensitive match.
842    /// 4. Fall back to the first model belonging to the hinted provider
843    ///    (or DeepSeek if no hint was given).
844    /// 5. As a last resort, fall back to the first model in the registry.
845    #[must_use]
846    pub fn resolve(
847        &self,
848        requested: Option<&str>,
849        provider_hint: Option<ProviderKind>,
850    ) -> ModelResolution {
851        let mut fallback_chain = Vec::new();
852
853        if let Some(name) = requested {
854            fallback_chain.push(format!("requested:{name}"));
855            if provider_hint == Some(ProviderKind::Ollama) {
856                return ModelResolution {
857                    requested: Some(name.to_string()),
858                    resolved: ModelInfo {
859                        id: name.trim().to_string(),
860                        provider: ProviderKind::Ollama,
861                        aliases: Vec::new(),
862                        supports_tools: true,
863                        supports_reasoning: false,
864                    },
865                    used_fallback: false,
866                    fallback_chain,
867                };
868            }
869            if let Some(provider) = provider_hint
870                && let Some(model) = self
871                    .models
872                    .iter()
873                    .find(|m| m.provider == provider && model_matches(m, name))
874                    .cloned()
875            {
876                return ModelResolution {
877                    requested: Some(name.to_string()),
878                    resolved: model,
879                    used_fallback: false,
880                    fallback_chain,
881                };
882            }
883            if provider_hint == Some(ProviderKind::Atlascloud)
884                && let Some(model) = atlascloud_passthrough_model(name)
885            {
886                return ModelResolution {
887                    requested: Some(name.to_string()),
888                    resolved: model,
889                    used_fallback: false,
890                    fallback_chain,
891                };
892            }
893            if provider_hint == Some(ProviderKind::Arcee)
894                && let Some(model) = arcee_passthrough_model(name)
895            {
896                return ModelResolution {
897                    requested: Some(name.to_string()),
898                    resolved: model,
899                    used_fallback: false,
900                    fallback_chain,
901                };
902            }
903            if provider_hint == Some(ProviderKind::XiaomiMimo)
904                && let Some(model) = xiaomi_mimo_passthrough_model(name)
905            {
906                return ModelResolution {
907                    requested: Some(name.to_string()),
908                    resolved: model,
909                    used_fallback: false,
910                    fallback_chain,
911                };
912            }
913            if let Some(idx) = self.alias_map.get(&normalize(name)) {
914                return ModelResolution {
915                    requested: Some(name.to_string()),
916                    resolved: preserve_requested_model_id_case(self.models[*idx].clone(), name),
917                    used_fallback: false,
918                    fallback_chain,
919                };
920            }
921        }
922
923        let provider = provider_hint.unwrap_or(ProviderKind::Deepseek);
924        fallback_chain.push(format!("provider_default:{}", provider.as_str()));
925        if let Some(model) = self.models.iter().find(|m| m.provider == provider).cloned() {
926            return ModelResolution {
927                requested: requested.map(ToOwned::to_owned),
928                resolved: model,
929                used_fallback: true,
930                fallback_chain,
931            };
932        }
933
934        let final_fallback = self.models.first().cloned().unwrap_or(ModelInfo {
935            id: "deepseek-v4-pro".to_string(),
936            provider: ProviderKind::Deepseek,
937            aliases: Vec::new(),
938            supports_tools: true,
939            supports_reasoning: true,
940        });
941        fallback_chain.push("global_default:deepseek-v4-pro".to_string());
942        ModelResolution {
943            requested: requested.map(ToOwned::to_owned),
944            resolved: final_fallback,
945            used_fallback: true,
946            fallback_chain,
947        }
948    }
949}
950
951fn normalize(value: &str) -> String {
952    value.trim().to_ascii_lowercase()
953}
954
955#[must_use]
956/// Classify a model identifier by its underlying model family.
957pub fn model_family(model_id: &str) -> ModelFamily {
958    let normalized = normalize(model_id);
959    if normalized.is_empty() {
960        return ModelFamily::Inferencer;
961    }
962
963    if normalized.contains("deepseek") {
964        return ModelFamily::DeepSeek;
965    }
966    if normalized.contains("claude") || normalized.contains("anthropic") {
967        return ModelFamily::Anthropic;
968    }
969    if normalized.contains("gpt-oss") || normalized.contains("gpt_oss") {
970        return ModelFamily::GptOss;
971    }
972    if normalized.starts_with("gpt-")
973        || normalized.contains("/gpt-")
974        || normalized.contains("openai/")
975    {
976        return ModelFamily::OpenAI;
977    }
978    if normalized.contains("gemini")
979        || normalized.contains("gemma")
980        || normalized.contains("google/")
981    {
982        return ModelFamily::Google;
983    }
984    if normalized.contains("llama") || normalized.contains("meta-") || normalized.contains("meta/")
985    {
986        return ModelFamily::Meta;
987    }
988    if normalized.contains("mistral")
989        || normalized.contains("mixtral")
990        || normalized.contains("codestral")
991    {
992        return ModelFamily::Mistral;
993    }
994    if normalized.contains("qwen") {
995        return ModelFamily::Qwen;
996    }
997    if normalized.contains("grok") {
998        return ModelFamily::Grok;
999    }
1000    if normalized.contains("cohere") || normalized.contains("command-r") {
1001        return ModelFamily::Cohere;
1002    }
1003
1004    ModelFamily::Inferencer
1005}
1006
1007fn model_matches(model: &ModelInfo, requested: &str) -> bool {
1008    let requested = normalize(requested);
1009    normalize(&model.id) == requested
1010        || model
1011            .aliases
1012            .iter()
1013            .any(|alias| normalize(alias) == requested)
1014}
1015
1016fn preserve_requested_model_id_case(mut model: ModelInfo, requested: &str) -> ModelInfo {
1017    let requested = requested.trim();
1018    if model.id.eq_ignore_ascii_case(requested) {
1019        model.id = requested.to_string();
1020    }
1021    model
1022}
1023
1024fn atlascloud_passthrough_model(requested: &str) -> Option<ModelInfo> {
1025    let requested = requested.trim();
1026    if requested.is_empty() || !requested.contains('/') {
1027        return None;
1028    }
1029
1030    Some(ModelInfo {
1031        id: requested.to_string(),
1032        provider: ProviderKind::Atlascloud,
1033        aliases: Vec::new(),
1034        supports_tools: true,
1035        supports_reasoning: true,
1036    })
1037}
1038
1039fn arcee_passthrough_model(requested: &str) -> Option<ModelInfo> {
1040    let requested = requested.trim();
1041    if requested.is_empty() {
1042        return None;
1043    }
1044    let supports_reasoning = requested.to_ascii_lowercase().contains("thinking");
1045
1046    Some(ModelInfo {
1047        id: requested.to_string(),
1048        provider: ProviderKind::Arcee,
1049        aliases: Vec::new(),
1050        supports_tools: true,
1051        supports_reasoning,
1052    })
1053}
1054
1055fn xiaomi_mimo_passthrough_model(requested: &str) -> Option<ModelInfo> {
1056    let requested = requested.trim();
1057    if requested.is_empty() || requested.chars().any(char::is_control) {
1058        return None;
1059    }
1060
1061    Some(ModelInfo {
1062        id: requested.to_string(),
1063        provider: ProviderKind::XiaomiMimo,
1064        aliases: Vec::new(),
1065        supports_tools: true,
1066        supports_reasoning: true,
1067    })
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072    use super::*;
1073
1074    #[test]
1075    fn deepseek_v4_pro_alias_stays_deepseek_by_default() {
1076        let registry = ModelRegistry::default();
1077        let resolved = registry.resolve(Some("deepseek-v4-pro"), None);
1078
1079        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
1080        assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
1081    }
1082
1083    #[test]
1084    fn deepseek_v4_pro_alias_resolves_to_nvidia_nim_when_provider_hinted() {
1085        let registry = ModelRegistry::default();
1086        let resolved = registry.resolve(Some("deepseek-v4-pro"), Some(ProviderKind::NvidiaNim));
1087
1088        assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
1089        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
1090    }
1091
1092    #[test]
1093    fn nvidia_nim_default_uses_catalog_model_id() {
1094        let registry = ModelRegistry::default();
1095        let resolved = registry.resolve(None, Some(ProviderKind::NvidiaNim));
1096
1097        assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
1098        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
1099    }
1100
1101    #[test]
1102    fn deepseek_v4_flash_alias_resolves_to_nvidia_nim_when_provider_hinted() {
1103        let registry = ModelRegistry::default();
1104        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::NvidiaNim));
1105
1106        assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
1107        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
1108    }
1109
1110    #[test]
1111    fn atlascloud_default_uses_namespaced_model_id() {
1112        let registry = ModelRegistry::default();
1113        let resolved = registry.resolve(None, Some(ProviderKind::Atlascloud));
1114
1115        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1116        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
1117        assert!(resolved.resolved.supports_reasoning);
1118    }
1119
1120    #[test]
1121    fn deepseek_v4_flash_alias_resolves_to_atlascloud_when_provider_hinted() {
1122        let registry = ModelRegistry::default();
1123        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Atlascloud));
1124
1125        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1126        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
1127    }
1128
1129    #[test]
1130    fn deepseek_v4_pro_alias_resolves_to_atlascloud_when_provider_hinted() {
1131        let registry = ModelRegistry::default();
1132        let resolved = registry.resolve(Some("deepseek-v4-pro"), Some(ProviderKind::Atlascloud));
1133
1134        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1135        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
1136    }
1137
1138    #[test]
1139    fn atlascloud_provider_hint_passes_through_explicit_model_id() {
1140        let registry = ModelRegistry::default();
1141        let resolved =
1142            registry.resolve(Some("openai/gpt-5.2-chat"), Some(ProviderKind::Atlascloud));
1143
1144        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1145        assert_eq!(resolved.resolved.id, "openai/gpt-5.2-chat");
1146        assert!(resolved.resolved.supports_tools);
1147        assert!(resolved.resolved.supports_reasoning);
1148        assert!(!resolved.used_fallback);
1149    }
1150
1151    #[test]
1152    fn atlascloud_provider_hint_preserves_explicit_model_id_case() {
1153        let registry = ModelRegistry::default();
1154        let resolved = registry.resolve(Some("Qwen/Qwen3-Coder"), Some(ProviderKind::Atlascloud));
1155
1156        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1157        assert_eq!(resolved.resolved.id, "Qwen/Qwen3-Coder");
1158        assert!(!resolved.used_fallback);
1159    }
1160
1161    #[test]
1162    fn atlascloud_plain_unknown_model_still_uses_provider_default() {
1163        let registry = ModelRegistry::default();
1164        let resolved = registry.resolve(Some("not-in-atlas"), Some(ProviderKind::Atlascloud));
1165
1166        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1167        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
1168        assert!(resolved.used_fallback);
1169    }
1170
1171    #[test]
1172    fn openrouter_default_uses_namespaced_model_id() {
1173        let registry = ModelRegistry::default();
1174        let resolved = registry.resolve(None, Some(ProviderKind::Openrouter));
1175
1176        assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
1177        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
1178    }
1179
1180    #[test]
1181    fn xiaomi_mimo_default_uses_canonical_model_id() {
1182        let registry = ModelRegistry::default();
1183        let resolved = registry.resolve(None, Some(ProviderKind::XiaomiMimo));
1184
1185        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1186        assert_eq!(resolved.resolved.id, "mimo-v2.5-pro");
1187        assert!(resolved.resolved.supports_reasoning);
1188    }
1189
1190    #[test]
1191    fn moonshot_default_and_aliases_use_kimi_k27_code() {
1192        let registry = ModelRegistry::default();
1193
1194        for requested in [None, Some("kimi"), Some("kimi-k2.7-code")] {
1195            let resolved = registry.resolve(requested, Some(ProviderKind::Moonshot));
1196
1197            assert_eq!(resolved.resolved.provider, ProviderKind::Moonshot);
1198            assert_eq!(resolved.resolved.id, "kimi-k2.7-code");
1199            assert!(resolved.resolved.supports_tools);
1200            assert!(resolved.resolved.supports_reasoning);
1201        }
1202    }
1203
1204    #[test]
1205    fn moonshot_explicit_kimi_k26_remains_available() {
1206        let registry = ModelRegistry::default();
1207        let resolved = registry.resolve(Some("kimi-k2.6"), Some(ProviderKind::Moonshot));
1208
1209        assert_eq!(resolved.resolved.provider, ProviderKind::Moonshot);
1210        assert_eq!(resolved.resolved.id, "kimi-k2.6");
1211        assert!(resolved.resolved.supports_reasoning);
1212    }
1213
1214    #[test]
1215    fn xiaomi_mimo_tts_aliases_resolve_when_provider_hinted() {
1216        let registry = ModelRegistry::default();
1217        let resolved = registry.resolve(Some("tts"), Some(ProviderKind::XiaomiMimo));
1218        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1219        assert_eq!(resolved.resolved.id, "mimo-v2.5-tts");
1220        assert!(!resolved.resolved.supports_tools);
1221        assert!(!resolved.resolved.supports_reasoning);
1222
1223        let resolved = registry.resolve(Some("voice-design"), Some(ProviderKind::XiaomiMimo));
1224        assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voicedesign");
1225
1226        let resolved = registry.resolve(Some("voiceclone"), Some(ProviderKind::XiaomiMimo));
1227        assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voiceclone");
1228    }
1229
1230    #[test]
1231    fn xiaomi_mimo_chat_aliases_resolve_when_provider_hinted() {
1232        let registry = ModelRegistry::default();
1233
1234        let resolved = registry.resolve(Some("omni"), Some(ProviderKind::XiaomiMimo));
1235        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1236        assert_eq!(resolved.resolved.id, "mimo-v2.5");
1237        assert!(resolved.resolved.supports_tools);
1238    }
1239
1240    #[test]
1241    fn xiaomi_mimo_provider_hint_preserves_custom_model_id() {
1242        let registry = ModelRegistry::default();
1243        let resolved =
1244            registry.resolve(Some("account-custom-mimo"), Some(ProviderKind::XiaomiMimo));
1245
1246        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1247        assert_eq!(resolved.resolved.id, "account-custom-mimo");
1248        assert!(!resolved.used_fallback);
1249    }
1250
1251    #[test]
1252    fn xiaomi_mimo_provider_hint_does_not_reclassify_openrouter_model_id() {
1253        let registry = ModelRegistry::default();
1254        let resolved = registry.resolve(
1255            Some("deepseek/deepseek-v4-pro"),
1256            Some(ProviderKind::XiaomiMimo),
1257        );
1258
1259        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1260        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
1261        assert!(!resolved.used_fallback);
1262    }
1263
1264    #[test]
1265    fn wanjie_ark_default_uses_reasoner_model_id() {
1266        let registry = ModelRegistry::default();
1267        let resolved = registry.resolve(None, Some(ProviderKind::WanjieArk));
1268
1269        assert_eq!(resolved.resolved.provider, ProviderKind::WanjieArk);
1270        assert_eq!(resolved.resolved.id, "deepseek-reasoner");
1271        assert!(resolved.resolved.supports_reasoning);
1272    }
1273
1274    #[test]
1275    fn novita_default_uses_namespaced_model_id() {
1276        let registry = ModelRegistry::default();
1277        let resolved = registry.resolve(None, Some(ProviderKind::Novita));
1278
1279        assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
1280        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
1281    }
1282
1283    #[test]
1284    fn fireworks_default_uses_canonical_model_id() {
1285        let registry = ModelRegistry::default();
1286        let resolved = registry.resolve(None, Some(ProviderKind::Fireworks));
1287
1288        assert_eq!(resolved.resolved.provider, ProviderKind::Fireworks);
1289        assert_eq!(
1290            resolved.resolved.id,
1291            "accounts/fireworks/models/deepseek-v4-pro"
1292        );
1293    }
1294
1295    #[test]
1296    fn siliconflow_default_uses_canonical_pro_model_id() {
1297        let registry = ModelRegistry::default();
1298        let resolved = registry.resolve(None, Some(ProviderKind::Siliconflow));
1299
1300        assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
1301        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1302        assert!(resolved.resolved.supports_reasoning);
1303    }
1304
1305    #[test]
1306    fn arcee_default_uses_direct_trinity_large_thinking_model_id() {
1307        let registry = ModelRegistry::default();
1308        let resolved = registry.resolve(None, Some(ProviderKind::Arcee));
1309
1310        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1311        assert_eq!(resolved.resolved.id, "trinity-large-thinking");
1312        assert!(resolved.resolved.supports_reasoning);
1313    }
1314
1315    #[test]
1316    fn arcee_trinity_alias_resolves_to_direct_large_thinking_not_openrouter() {
1317        let registry = ModelRegistry::default();
1318        let resolved = registry.resolve(Some("trinity"), Some(ProviderKind::Arcee));
1319
1320        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1321        assert_eq!(resolved.resolved.id, "trinity-large-thinking");
1322        assert!(resolved.resolved.supports_reasoning);
1323    }
1324
1325    #[test]
1326    fn arcee_trinity_mini_remains_explicit_compatibility_model() {
1327        let registry = ModelRegistry::default();
1328        let resolved = registry.resolve(Some("trinity-mini"), Some(ProviderKind::Arcee));
1329
1330        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1331        assert_eq!(resolved.resolved.id, "trinity-mini");
1332        assert!(!resolved.resolved.supports_reasoning);
1333    }
1334
1335    #[test]
1336    fn arcee_provider_hint_preserves_explicit_future_model_id() {
1337        let registry = ModelRegistry::default();
1338        let resolved = registry.resolve(Some("trinity-large-next"), Some(ProviderKind::Arcee));
1339
1340        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1341        assert_eq!(resolved.resolved.id, "trinity-large-next");
1342        assert!(!resolved.resolved.supports_reasoning);
1343        assert!(!resolved.used_fallback);
1344    }
1345
1346    #[test]
1347    fn deepseek_reasoner_alias_resolves_to_siliconflow_pro_when_provider_hinted() {
1348        let registry = ModelRegistry::default();
1349        let resolved = registry.resolve(Some("deepseek-reasoner"), Some(ProviderKind::Siliconflow));
1350
1351        assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
1352        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1353    }
1354
1355    #[test]
1356    fn deepseek_v4_flash_alias_resolves_to_siliconflow_flash_when_provider_hinted() {
1357        let registry = ModelRegistry::default();
1358        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Siliconflow));
1359
1360        assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
1361        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
1362    }
1363
1364    #[test]
1365    fn sglang_default_uses_canonical_model_id() {
1366        let registry = ModelRegistry::default();
1367        let resolved = registry.resolve(None, Some(ProviderKind::Sglang));
1368
1369        assert_eq!(resolved.resolved.provider, ProviderKind::Sglang);
1370        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1371    }
1372
1373    #[test]
1374    fn zai_direct_models_resolve_when_provider_hinted() {
1375        let registry = ModelRegistry::default();
1376
1377        let default = registry.resolve(None, Some(ProviderKind::Zai));
1378        assert_eq!(default.resolved.provider, ProviderKind::Zai);
1379        assert_eq!(default.resolved.id, "GLM-5.1");
1380
1381        for (alias, expected) in [
1382            ("GLM-5.1", "GLM-5.1"),
1383            ("glm-5-1", "GLM-5.1"),
1384            ("GLM-5.2", "GLM-5.2"),
1385            ("glm-5.2", "GLM-5.2"),
1386            ("zai-glm-5-2", "GLM-5.2"),
1387        ] {
1388            let resolved = registry.resolve(Some(alias), Some(ProviderKind::Zai));
1389
1390            assert_eq!(resolved.resolved.provider, ProviderKind::Zai);
1391            assert_eq!(resolved.resolved.id, expected);
1392            assert!(!resolved.used_fallback);
1393            assert!(resolved.resolved.supports_tools);
1394            assert!(resolved.resolved.supports_reasoning);
1395        }
1396    }
1397
1398    #[test]
1399    fn first_party_recent_provider_models_are_listed() {
1400        let registry = ModelRegistry::default();
1401        let models = registry.list();
1402
1403        for (provider, id) in [
1404            (ProviderKind::Zai, "GLM-5.2"),
1405            (ProviderKind::Stepfun, "step-3.7-flash"),
1406            (ProviderKind::Minimax, "MiniMax-M2.1"),
1407        ] {
1408            assert!(
1409                models
1410                    .iter()
1411                    .any(|model| model.provider == provider && model.id == id),
1412                "expected {provider:?} model {id} in registry"
1413            );
1414        }
1415    }
1416
1417    #[test]
1418    fn stepfun_and_minimax_direct_models_resolve_when_provider_hinted() {
1419        let registry = ModelRegistry::default();
1420
1421        let stepfun = registry.resolve(None, Some(ProviderKind::Stepfun));
1422        assert_eq!(stepfun.resolved.provider, ProviderKind::Stepfun);
1423        assert_eq!(stepfun.resolved.id, "step-3.7-flash");
1424
1425        for (alias, expected) in [
1426            ("minimax", "MiniMax-M3"),
1427            ("minimax-m3", "MiniMax-M3"),
1428            ("minimax-m2.7", "MiniMax-M2.7"),
1429            ("minimax-m2-7-highspeed", "MiniMax-M2.7-highspeed"),
1430            ("minimax-m2.1", "MiniMax-M2.1"),
1431            ("minimax-m2", "MiniMax-M2"),
1432        ] {
1433            let resolved = registry.resolve(Some(alias), Some(ProviderKind::Minimax));
1434
1435            assert_eq!(resolved.resolved.provider, ProviderKind::Minimax);
1436            assert_eq!(resolved.resolved.id, expected);
1437            assert!(!resolved.used_fallback);
1438            assert!(resolved.resolved.supports_tools);
1439            assert!(resolved.resolved.supports_reasoning);
1440        }
1441    }
1442
1443    #[test]
1444    fn deepseek_v4_flash_alias_resolves_to_openrouter_when_provider_hinted() {
1445        let registry = ModelRegistry::default();
1446        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Openrouter));
1447
1448        assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
1449        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
1450    }
1451
1452    #[test]
1453    fn recent_openrouter_large_model_aliases_resolve_when_provider_hinted() {
1454        let registry = ModelRegistry::default();
1455
1456        for (alias, expected) in [
1457            ("trinity-large-thinking", "arcee-ai/trinity-large-thinking"),
1458            ("qwen3.6-flash", "qwen/qwen3.6-flash"),
1459            ("qwen3.6-35b-a3b", "qwen/qwen3.6-35b-a3b"),
1460            ("qwen3.6-max-preview", "qwen/qwen3.6-max-preview"),
1461            ("qwen3.6-plus", "qwen/qwen3.6-plus"),
1462            ("gemma-4-31b-it", "google/gemma-4-31b-it"),
1463            ("glm-5.1", "z-ai/glm-5.1"),
1464            ("glm-5.2", "z-ai/glm-5.2"),
1465            ("minimax-m3", "minimax/minimax-m3"),
1466            ("minimax-2.7", "minimax/minimax-2.7"),
1467            ("openrouter-mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro"),
1468            ("openrouter-kimi-k2.7-code", "moonshotai/kimi-k2.7-code"),
1469            ("openrouter-kimi-k2.6", "moonshotai/kimi-k2.6"),
1470            ("nemotron-3-ultra", "nvidia/nemotron-3-ultra-550b-a55b"),
1471            (
1472                "nvidia/nemotron-3-ultra",
1473                "nvidia/nemotron-3-ultra-550b-a55b",
1474            ),
1475        ] {
1476            let resolved = registry.resolve(Some(alias), Some(ProviderKind::Openrouter));
1477
1478            assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
1479            assert_eq!(resolved.resolved.id, expected);
1480            assert!(resolved.resolved.supports_tools);
1481            assert!(resolved.resolved.supports_reasoning);
1482        }
1483    }
1484
1485    #[test]
1486    fn deepseek_v4_flash_alias_resolves_to_novita_when_provider_hinted() {
1487        let registry = ModelRegistry::default();
1488        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Novita));
1489
1490        assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
1491        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
1492    }
1493
1494    #[test]
1495    fn deepseek_v4_flash_alias_resolves_to_sglang_when_provider_hinted() {
1496        let registry = ModelRegistry::default();
1497        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Sglang));
1498
1499        assert_eq!(resolved.resolved.provider, ProviderKind::Sglang);
1500        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
1501    }
1502
1503    #[test]
1504    fn vllm_default_uses_canonical_model_id() {
1505        let registry = ModelRegistry::default();
1506        let resolved = registry.resolve(None, Some(ProviderKind::Vllm));
1507
1508        assert_eq!(resolved.resolved.provider, ProviderKind::Vllm);
1509        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1510    }
1511
1512    #[test]
1513    fn ollama_default_uses_small_local_model_id() {
1514        let registry = ModelRegistry::default();
1515        let resolved = registry.resolve(None, Some(ProviderKind::Ollama));
1516
1517        assert_eq!(resolved.resolved.provider, ProviderKind::Ollama);
1518        assert_eq!(resolved.resolved.id, "deepseek-coder:1.3b");
1519        assert!(!resolved.resolved.supports_reasoning);
1520    }
1521
1522    #[test]
1523    fn ollama_requested_model_tag_is_preserved() {
1524        let registry = ModelRegistry::default();
1525        let resolved = registry.resolve(Some("qwen2.5-coder:7b"), Some(ProviderKind::Ollama));
1526
1527        assert_eq!(resolved.resolved.provider, ProviderKind::Ollama);
1528        assert_eq!(resolved.resolved.id, "qwen2.5-coder:7b");
1529        assert!(!resolved.used_fallback);
1530    }
1531
1532    #[test]
1533    fn deepseek_v4_flash_alias_resolves_to_vllm_when_provider_hinted() {
1534        let registry = ModelRegistry::default();
1535        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Vllm));
1536
1537        assert_eq!(resolved.resolved.provider, ProviderKind::Vllm);
1538        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
1539    }
1540
1541    #[test]
1542    fn preserves_requested_model_casing_for_third_party_providers() {
1543        let registry = ModelRegistry::default();
1544        let resolved = registry.resolve(Some("DeepSeek-V4-Pro"), None);
1545
1546        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
1547        assert_eq!(resolved.resolved.id, "DeepSeek-V4-Pro");
1548    }
1549
1550    #[test]
1551    fn registry_casing_takes_priority_over_requested_casing_with_provider_hint() {
1552        let registry = ModelRegistry::default();
1553        let resolved = registry.resolve(Some("DeepSeek-V4-Pro"), Some(ProviderKind::Deepseek));
1554
1555        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
1556        // Registry's canonical id is used even when user provides different casing
1557        assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
1558    }
1559
1560    #[test]
1561    fn preserves_requested_model_casing_without_surrounding_whitespace() {
1562        let registry = ModelRegistry::default();
1563        let resolved = registry.resolve(Some("  DeepSeek-V4-Pro  "), None);
1564
1565        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
1566        assert_eq!(resolved.resolved.id, "DeepSeek-V4-Pro");
1567    }
1568
1569    #[test]
1570    fn alias_match_does_not_override_requested_casing() {
1571        let registry = ModelRegistry::default();
1572        let resolved = registry.resolve(Some("deepseek-reasoner"), None);
1573
1574        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
1575        assert_eq!(resolved.resolved.id, "deepseek-v4-flash");
1576    }
1577
1578    #[test]
1579    fn model_family_classifies_known_model_ids() {
1580        assert_eq!(model_family("deepseek-v4-pro"), ModelFamily::DeepSeek);
1581        assert_eq!(model_family("openai/gpt-5.4"), ModelFamily::OpenAI);
1582        assert_eq!(
1583            model_family("anthropic/claude-opus-4-7"),
1584            ModelFamily::Anthropic
1585        );
1586        assert_eq!(
1587            model_family("meta-llama/llama-3.3-70b-instruct"),
1588            ModelFamily::Meta
1589        );
1590        assert_eq!(model_family("Qwen/Qwen3-Coder"), ModelFamily::Qwen);
1591    }
1592
1593    #[test]
1594    fn model_family_uses_underlying_model_for_router_ids() {
1595        assert_eq!(
1596            model_family("groq/llama-3.3-70b-versatile"),
1597            ModelFamily::Meta
1598        );
1599        assert_eq!(
1600            model_family("openrouter/openai/gpt-5.4"),
1601            ModelFamily::OpenAI
1602        );
1603        assert_eq!(
1604            model_family("fireworks/accounts/fireworks/models/deepseek-v4-pro"),
1605            ModelFamily::DeepSeek
1606        );
1607    }
1608
1609    #[test]
1610    fn model_family_covers_prominent_google_and_mistral_model_names() {
1611        assert_eq!(model_family("google/gemma-3-27b-it"), ModelFamily::Google);
1612        assert_eq!(
1613            model_family("mistralai/mixtral-8x22b"),
1614            ModelFamily::Mistral
1615        );
1616        assert_eq!(model_family("codestral-latest"), ModelFamily::Mistral);
1617    }
1618
1619    #[test]
1620    fn model_family_falls_back_to_inferencer_for_unknown_models() {
1621        assert_eq!(
1622            model_family("custom-gateway/my-private-model"),
1623            ModelFamily::Inferencer
1624        );
1625        assert_eq!(model_family(""), ModelFamily::Inferencer);
1626    }
1627}