Skip to main content

codewhale_agent/
lib.rs

1use std::collections::HashMap;
2
3use codewhale_config::ProviderKind;
4use serde::{Deserialize, Serialize};
5
6/// Metadata for a single model entry in the registry.
7///
8/// Each model has a canonical `id` used by the provider, a list of `aliases`
9/// that users may reference, and capability flags indicating whether the model
10/// supports tool use and reasoning.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ModelInfo {
13    /// The canonical model identifier used by the provider (e.g. `"deepseek-v4-pro"`).
14    pub id: String,
15    /// The provider that serves this model.
16    pub provider: ProviderKind,
17    /// Alternative names that users can use to reference this model (case-insensitive).
18    pub aliases: Vec<String>,
19    /// Whether this model supports tool/function calling.
20    pub supports_tools: bool,
21    /// Whether this model supports extended reasoning.
22    pub supports_reasoning: bool,
23}
24
25/// The result of resolving a user-requested model name to a concrete model entry.
26///
27/// Contains the resolved [`ModelInfo`], whether a fallback was used, and the
28/// chain of resolution strategies that were attempted.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ModelResolution {
31    /// The original model name requested by the user, if any.
32    pub requested: Option<String>,
33    /// The concrete model that was resolved.
34    pub resolved: ModelInfo,
35    /// Whether a fallback was used because the requested model was not found.
36    pub used_fallback: bool,
37    /// The ordered list of resolution strategies that were attempted.
38    pub fallback_chain: Vec<String>,
39}
40
41/// A registry of supported models and their aliases, used to resolve user-facing
42/// model names to concrete provider-specific model entries.
43///
44/// The default registry is populated with all built-in models across supported
45/// providers (DeepSeek, NVIDIA NIM, OpenAI-compatible, and others).
46#[derive(Debug, Clone)]
47pub struct ModelRegistry {
48    models: Vec<ModelInfo>,
49    alias_map: HashMap<String, usize>,
50}
51
52/// Creates a registry pre-populated with all built-in models and their aliases.
53impl Default for ModelRegistry {
54    fn default() -> Self {
55        let models = vec![
56            ModelInfo {
57                id: "deepseek-v4-pro".to_string(),
58                provider: ProviderKind::Deepseek,
59                aliases: vec![],
60                supports_tools: true,
61                supports_reasoning: true,
62            },
63            ModelInfo {
64                id: "deepseek-v4-flash".to_string(),
65                provider: ProviderKind::Deepseek,
66                aliases: vec![
67                    "deepseek-chat".to_string(),
68                    "deepseek-reasoner".to_string(),
69                    "deepseek-r1".to_string(),
70                    "deepseek-v3".to_string(),
71                    "deepseek-v3.2".to_string(),
72                ],
73                supports_tools: true,
74                supports_reasoning: true,
75            },
76            ModelInfo {
77                id: "deepseek-ai/deepseek-v4-pro".to_string(),
78                provider: ProviderKind::NvidiaNim,
79                aliases: vec![
80                    "deepseek-v4-pro".to_string(),
81                    "nvidia-deepseek-v4-pro".to_string(),
82                    "nim-deepseek-v4-pro".to_string(),
83                ],
84                supports_tools: true,
85                supports_reasoning: true,
86            },
87            ModelInfo {
88                id: "deepseek-ai/deepseek-v4-flash".to_string(),
89                provider: ProviderKind::NvidiaNim,
90                aliases: vec![
91                    "deepseek-v4-flash".to_string(),
92                    "deepseek-chat".to_string(),
93                    "deepseek-reasoner".to_string(),
94                    "nvidia-deepseek-v4-flash".to_string(),
95                    "nim-deepseek-v4-flash".to_string(),
96                ],
97                supports_tools: true,
98                supports_reasoning: true,
99            },
100            ModelInfo {
101                id: "deepseek-v4-pro".to_string(),
102                provider: ProviderKind::Openai,
103                aliases: vec!["openai-compatible-deepseek-v4-pro".to_string()],
104                supports_tools: true,
105                supports_reasoning: true,
106            },
107            ModelInfo {
108                id: "deepseek-v4-flash".to_string(),
109                provider: ProviderKind::Openai,
110                aliases: vec!["openai-compatible-deepseek-v4-flash".to_string()],
111                supports_tools: true,
112                supports_reasoning: true,
113            },
114            ModelInfo {
115                id: "deepseek-ai/deepseek-v4-flash".to_string(),
116                provider: ProviderKind::Atlascloud,
117                aliases: vec![
118                    "deepseek-v4-flash".to_string(),
119                    "atlascloud-deepseek-v4-flash".to_string(),
120                ],
121                supports_tools: true,
122                supports_reasoning: true,
123            },
124            ModelInfo {
125                id: "deepseek-ai/deepseek-v4-pro".to_string(),
126                provider: ProviderKind::Atlascloud,
127                aliases: vec![
128                    "deepseek-v4-pro".to_string(),
129                    "atlascloud-deepseek-v4-pro".to_string(),
130                ],
131                supports_tools: true,
132                supports_reasoning: true,
133            },
134            ModelInfo {
135                id: "deepseek-reasoner".to_string(),
136                provider: ProviderKind::WanjieArk,
137                aliases: vec![
138                    "wanjie-deepseek-reasoner".to_string(),
139                    "ark-wanjie-deepseek-reasoner".to_string(),
140                ],
141                supports_tools: true,
142                supports_reasoning: true,
143            },
144            ModelInfo {
145                id: "DeepSeek-V4-Pro".to_string(),
146                provider: ProviderKind::Volcengine,
147                aliases: vec![
148                    "deepseek-v4-pro".to_string(),
149                    "volcengine-deepseek-v4-pro".to_string(),
150                    "ark-deepseek-v4-pro".to_string(),
151                ],
152                supports_tools: true,
153                supports_reasoning: true,
154            },
155            ModelInfo {
156                id: "DeepSeek-V4-Flash".to_string(),
157                provider: ProviderKind::Volcengine,
158                aliases: vec![
159                    "deepseek-v4-flash".to_string(),
160                    "deepseek-chat".to_string(),
161                    "volcengine-deepseek-v4-flash".to_string(),
162                    "ark-deepseek-v4-flash".to_string(),
163                ],
164                supports_tools: true,
165                supports_reasoning: true,
166            },
167            ModelInfo {
168                id: "deepseek/deepseek-v4-pro".to_string(),
169                provider: ProviderKind::Openrouter,
170                aliases: vec![
171                    "deepseek-v4-pro".to_string(),
172                    "openrouter-deepseek-v4-pro".to_string(),
173                ],
174                supports_tools: true,
175                supports_reasoning: true,
176            },
177            ModelInfo {
178                id: "deepseek/deepseek-v4-flash".to_string(),
179                provider: ProviderKind::Openrouter,
180                aliases: vec![
181                    "deepseek-v4-flash".to_string(),
182                    "deepseek-chat".to_string(),
183                    "deepseek-reasoner".to_string(),
184                    "openrouter-deepseek-v4-flash".to_string(),
185                ],
186                supports_tools: true,
187                supports_reasoning: true,
188            },
189            ModelInfo {
190                id: "arcee-ai/trinity-large-thinking".to_string(),
191                provider: ProviderKind::Openrouter,
192                aliases: vec![
193                    "trinity".to_string(),
194                    "trinity-large-thinking".to_string(),
195                    "arcee-trinity-large-thinking".to_string(),
196                ],
197                supports_tools: true,
198                supports_reasoning: true,
199            },
200            ModelInfo {
201                id: "xiaomi/mimo-v2.5-pro".to_string(),
202                provider: ProviderKind::Openrouter,
203                aliases: vec![
204                    "openrouter-mimo-v2.5-pro".to_string(),
205                    "openrouter-xiaomi-mimo-v2.5-pro".to_string(),
206                ],
207                supports_tools: true,
208                supports_reasoning: true,
209            },
210            ModelInfo {
211                id: "xiaomi/mimo-v2.5".to_string(),
212                provider: ProviderKind::Openrouter,
213                aliases: vec![
214                    "openrouter-mimo-v2.5".to_string(),
215                    "openrouter-xiaomi-mimo-v2.5".to_string(),
216                ],
217                supports_tools: true,
218                supports_reasoning: true,
219            },
220            ModelInfo {
221                id: "qwen/qwen3.6-35b-a3b".to_string(),
222                provider: ProviderKind::Openrouter,
223                aliases: vec![
224                    "qwen3.6-35b-a3b".to_string(),
225                    "qwen-3.6-35b-a3b".to_string(),
226                ],
227                supports_tools: true,
228                supports_reasoning: true,
229            },
230            ModelInfo {
231                id: "qwen/qwen3.6-27b".to_string(),
232                provider: ProviderKind::Openrouter,
233                aliases: vec!["qwen3.6-27b".to_string(), "qwen-3.6-27b".to_string()],
234                supports_tools: true,
235                supports_reasoning: true,
236            },
237            ModelInfo {
238                id: "moonshotai/kimi-k2.6".to_string(),
239                provider: ProviderKind::Openrouter,
240                aliases: vec!["openrouter-kimi-k2.6".to_string()],
241                supports_tools: true,
242                supports_reasoning: true,
243            },
244            ModelInfo {
245                id: "minimax/minimax-m3".to_string(),
246                provider: ProviderKind::Openrouter,
247                aliases: vec![
248                    "minimax-m3".to_string(),
249                    "minimax-m-3".to_string(),
250                    "openrouter-minimax-m3".to_string(),
251                ],
252                supports_tools: true,
253                supports_reasoning: true,
254            },
255            ModelInfo {
256                id: "z-ai/glm-5.1".to_string(),
257                provider: ProviderKind::Openrouter,
258                aliases: vec!["glm-5.1".to_string(), "zai-glm-5.1".to_string()],
259                supports_tools: true,
260                supports_reasoning: true,
261            },
262            ModelInfo {
263                id: "tencent/hy3-preview".to_string(),
264                provider: ProviderKind::Openrouter,
265                aliases: vec!["hy3-preview".to_string(), "tencent-hy3-preview".to_string()],
266                supports_tools: true,
267                supports_reasoning: true,
268            },
269            ModelInfo {
270                id: "google/gemma-4-31b-it".to_string(),
271                provider: ProviderKind::Openrouter,
272                aliases: vec!["gemma-4-31b".to_string(), "gemma-4-31b-it".to_string()],
273                supports_tools: true,
274                supports_reasoning: true,
275            },
276            ModelInfo {
277                id: "google/gemma-4-26b-a4b-it".to_string(),
278                provider: ProviderKind::Openrouter,
279                aliases: vec![
280                    "gemma-4-26b-a4b".to_string(),
281                    "gemma-4-26b-a4b-it".to_string(),
282                ],
283                supports_tools: true,
284                supports_reasoning: true,
285            },
286            ModelInfo {
287                id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free".to_string(),
288                provider: ProviderKind::Openrouter,
289                aliases: vec![
290                    "nemotron-3-nano-omni".to_string(),
291                    "nemotron-3-nano-omni-reasoning".to_string(),
292                ],
293                supports_tools: true,
294                supports_reasoning: true,
295            },
296            ModelInfo {
297                id: "mimo-v2.5-pro".to_string(),
298                provider: ProviderKind::XiaomiMimo,
299                aliases: vec!["mimo".to_string()],
300                supports_tools: true,
301                supports_reasoning: true,
302            },
303            ModelInfo {
304                id: "mimo-v2.5".to_string(),
305                provider: ProviderKind::XiaomiMimo,
306                aliases: vec!["xiaomi-mimo-v2.5".to_string()],
307                supports_tools: true,
308                supports_reasoning: true,
309            },
310            ModelInfo {
311                id: "mimo-v2.5-tts".to_string(),
312                provider: ProviderKind::XiaomiMimo,
313                aliases: vec![
314                    "tts".to_string(),
315                    "speech".to_string(),
316                    "mimo-tts".to_string(),
317                ],
318                supports_tools: false,
319                supports_reasoning: false,
320            },
321            ModelInfo {
322                id: "mimo-v2.5-tts-voicedesign".to_string(),
323                provider: ProviderKind::XiaomiMimo,
324                aliases: vec![
325                    "voicedesign".to_string(),
326                    "voice-design".to_string(),
327                    "mimo-voice-design".to_string(),
328                ],
329                supports_tools: false,
330                supports_reasoning: false,
331            },
332            ModelInfo {
333                id: "mimo-v2.5-tts-voiceclone".to_string(),
334                provider: ProviderKind::XiaomiMimo,
335                aliases: vec![
336                    "voiceclone".to_string(),
337                    "voice-clone".to_string(),
338                    "mimo-voice-clone".to_string(),
339                ],
340                supports_tools: false,
341                supports_reasoning: false,
342            },
343            ModelInfo {
344                id: "mimo-v2-tts".to_string(),
345                provider: ProviderKind::XiaomiMimo,
346                aliases: vec!["mimo-v2-speech".to_string()],
347                supports_tools: false,
348                supports_reasoning: false,
349            },
350            ModelInfo {
351                id: "deepseek/deepseek-v4-pro".to_string(),
352                provider: ProviderKind::Novita,
353                aliases: vec![
354                    "deepseek-v4-pro".to_string(),
355                    "novita-deepseek-v4-pro".to_string(),
356                ],
357                supports_tools: true,
358                supports_reasoning: true,
359            },
360            ModelInfo {
361                id: "deepseek/deepseek-v4-flash".to_string(),
362                provider: ProviderKind::Novita,
363                aliases: vec![
364                    "deepseek-v4-flash".to_string(),
365                    "deepseek-chat".to_string(),
366                    "deepseek-reasoner".to_string(),
367                    "novita-deepseek-v4-flash".to_string(),
368                ],
369                supports_tools: true,
370                supports_reasoning: true,
371            },
372            ModelInfo {
373                id: "accounts/fireworks/models/deepseek-v4-pro".to_string(),
374                provider: ProviderKind::Fireworks,
375                aliases: vec![
376                    "deepseek-v4-pro".to_string(),
377                    "fireworks-deepseek-v4-pro".to_string(),
378                ],
379                supports_tools: true,
380                supports_reasoning: true,
381            },
382            ModelInfo {
383                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
384                provider: ProviderKind::Siliconflow,
385                aliases: vec![
386                    "deepseek-v4-pro".to_string(),
387                    "deepseek-reasoner".to_string(),
388                    "deepseek-r1".to_string(),
389                    "siliconflow-deepseek-v4-pro".to_string(),
390                ],
391                supports_tools: true,
392                supports_reasoning: true,
393            },
394            ModelInfo {
395                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
396                provider: ProviderKind::Siliconflow,
397                aliases: vec![
398                    "deepseek-v4-flash".to_string(),
399                    "deepseek-chat".to_string(),
400                    "deepseek-v3".to_string(),
401                    "siliconflow-deepseek-v4-flash".to_string(),
402                ],
403                supports_tools: true,
404                supports_reasoning: true,
405            },
406            ModelInfo {
407                id: "kimi-k2.6".to_string(),
408                provider: ProviderKind::Moonshot,
409                aliases: vec![
410                    "kimi".to_string(),
411                    "kimi-k2".to_string(),
412                    "moonshot-kimi-k2.6".to_string(),
413                ],
414                supports_tools: true,
415                supports_reasoning: true,
416            },
417            ModelInfo {
418                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
419                provider: ProviderKind::Sglang,
420                aliases: vec![
421                    "deepseek-v4-pro".to_string(),
422                    "sglang-deepseek-v4-pro".to_string(),
423                ],
424                supports_tools: true,
425                supports_reasoning: true,
426            },
427            ModelInfo {
428                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
429                provider: ProviderKind::Sglang,
430                aliases: vec![
431                    "deepseek-v4-flash".to_string(),
432                    "deepseek-chat".to_string(),
433                    "deepseek-reasoner".to_string(),
434                    "sglang-deepseek-v4-flash".to_string(),
435                ],
436                supports_tools: true,
437                supports_reasoning: true,
438            },
439            ModelInfo {
440                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
441                provider: ProviderKind::Vllm,
442                aliases: vec![
443                    "deepseek-v4-pro".to_string(),
444                    "vllm-deepseek-v4-pro".to_string(),
445                ],
446                supports_tools: true,
447                supports_reasoning: true,
448            },
449            ModelInfo {
450                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
451                provider: ProviderKind::Vllm,
452                aliases: vec![
453                    "deepseek-v4-flash".to_string(),
454                    "deepseek-chat".to_string(),
455                    "deepseek-reasoner".to_string(),
456                    "vllm-deepseek-v4-flash".to_string(),
457                ],
458                supports_tools: true,
459                supports_reasoning: true,
460            },
461            ModelInfo {
462                id: "deepseek-coder:1.3b".to_string(),
463                provider: ProviderKind::Ollama,
464                aliases: vec![],
465                supports_tools: true,
466                supports_reasoning: false,
467            },
468        ];
469        Self::new(models)
470    }
471}
472
473impl ModelRegistry {
474    /// Creates a new registry from a list of [`ModelInfo`] entries.
475    ///
476    /// Builds an internal alias map for fast lookup by model id or alias.
477    /// If multiple models share the same id or alias, the first one registered
478    /// takes priority.
479    #[must_use]
480    pub fn new(models: Vec<ModelInfo>) -> Self {
481        let mut alias_map = HashMap::new();
482        for (idx, model) in models.iter().enumerate() {
483            alias_map.entry(normalize(&model.id)).or_insert(idx);
484            for alias in &model.aliases {
485                alias_map.entry(normalize(alias)).or_insert(idx);
486            }
487        }
488        Self { models, alias_map }
489    }
490
491    /// Returns a clone of all models in the registry.
492    #[must_use]
493    pub fn list(&self) -> Vec<ModelInfo> {
494        self.models.clone()
495    }
496
497    /// Resolves a user-requested model name to a concrete [`ModelInfo`].
498    ///
499    /// Resolution follows this priority order:
500    /// 1. If the provider is Ollama, the requested name is used as-is (to
501    ///    support arbitrary local model tags like `qwen2.5-coder:7b`).
502    /// 2. If a `provider_hint` is given, search for a model matching that
503    ///    provider whose id or alias matches the request (case-insensitive).
504    /// 3. Look up the alias map for a case-insensitive match.
505    /// 4. Fall back to the first model belonging to the hinted provider
506    ///    (or DeepSeek if no hint was given).
507    /// 5. As a last resort, fall back to the first model in the registry.
508    #[must_use]
509    pub fn resolve(
510        &self,
511        requested: Option<&str>,
512        provider_hint: Option<ProviderKind>,
513    ) -> ModelResolution {
514        let mut fallback_chain = Vec::new();
515
516        if let Some(name) = requested {
517            fallback_chain.push(format!("requested:{name}"));
518            if provider_hint == Some(ProviderKind::Ollama) {
519                return ModelResolution {
520                    requested: Some(name.to_string()),
521                    resolved: ModelInfo {
522                        id: name.trim().to_string(),
523                        provider: ProviderKind::Ollama,
524                        aliases: Vec::new(),
525                        supports_tools: true,
526                        supports_reasoning: false,
527                    },
528                    used_fallback: false,
529                    fallback_chain,
530                };
531            }
532            if let Some(provider) = provider_hint
533                && let Some(model) = self
534                    .models
535                    .iter()
536                    .find(|m| m.provider == provider && model_matches(m, name))
537                    .cloned()
538            {
539                return ModelResolution {
540                    requested: Some(name.to_string()),
541                    resolved: model,
542                    used_fallback: false,
543                    fallback_chain,
544                };
545            }
546            if provider_hint == Some(ProviderKind::Atlascloud)
547                && let Some(model) = atlascloud_passthrough_model(name)
548            {
549                return ModelResolution {
550                    requested: Some(name.to_string()),
551                    resolved: model,
552                    used_fallback: false,
553                    fallback_chain,
554                };
555            }
556            if let Some(idx) = self.alias_map.get(&normalize(name)) {
557                return ModelResolution {
558                    requested: Some(name.to_string()),
559                    resolved: preserve_requested_model_id_case(self.models[*idx].clone(), name),
560                    used_fallback: false,
561                    fallback_chain,
562                };
563            }
564        }
565
566        let provider = provider_hint.unwrap_or(ProviderKind::Deepseek);
567        fallback_chain.push(format!("provider_default:{}", provider.as_str()));
568        if let Some(model) = self.models.iter().find(|m| m.provider == provider).cloned() {
569            return ModelResolution {
570                requested: requested.map(ToOwned::to_owned),
571                resolved: model,
572                used_fallback: true,
573                fallback_chain,
574            };
575        }
576
577        let final_fallback = self.models.first().cloned().unwrap_or(ModelInfo {
578            id: "deepseek-v4-pro".to_string(),
579            provider: ProviderKind::Deepseek,
580            aliases: Vec::new(),
581            supports_tools: true,
582            supports_reasoning: true,
583        });
584        fallback_chain.push("global_default:deepseek-v4-pro".to_string());
585        ModelResolution {
586            requested: requested.map(ToOwned::to_owned),
587            resolved: final_fallback,
588            used_fallback: true,
589            fallback_chain,
590        }
591    }
592}
593
594fn normalize(value: &str) -> String {
595    value.trim().to_ascii_lowercase()
596}
597
598fn model_matches(model: &ModelInfo, requested: &str) -> bool {
599    let requested = normalize(requested);
600    normalize(&model.id) == requested
601        || model
602            .aliases
603            .iter()
604            .any(|alias| normalize(alias) == requested)
605}
606
607fn preserve_requested_model_id_case(mut model: ModelInfo, requested: &str) -> ModelInfo {
608    let requested = requested.trim();
609    if model.id.eq_ignore_ascii_case(requested) {
610        model.id = requested.to_string();
611    }
612    model
613}
614
615fn atlascloud_passthrough_model(requested: &str) -> Option<ModelInfo> {
616    let requested = requested.trim();
617    if requested.is_empty() || !requested.contains('/') {
618        return None;
619    }
620
621    Some(ModelInfo {
622        id: requested.to_string(),
623        provider: ProviderKind::Atlascloud,
624        aliases: Vec::new(),
625        supports_tools: true,
626        supports_reasoning: true,
627    })
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    #[test]
635    fn deepseek_v4_pro_alias_stays_deepseek_by_default() {
636        let registry = ModelRegistry::default();
637        let resolved = registry.resolve(Some("deepseek-v4-pro"), None);
638
639        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
640        assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
641    }
642
643    #[test]
644    fn deepseek_v4_pro_alias_resolves_to_nvidia_nim_when_provider_hinted() {
645        let registry = ModelRegistry::default();
646        let resolved = registry.resolve(Some("deepseek-v4-pro"), Some(ProviderKind::NvidiaNim));
647
648        assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
649        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
650    }
651
652    #[test]
653    fn nvidia_nim_default_uses_catalog_model_id() {
654        let registry = ModelRegistry::default();
655        let resolved = registry.resolve(None, Some(ProviderKind::NvidiaNim));
656
657        assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
658        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
659    }
660
661    #[test]
662    fn deepseek_v4_flash_alias_resolves_to_nvidia_nim_when_provider_hinted() {
663        let registry = ModelRegistry::default();
664        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::NvidiaNim));
665
666        assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
667        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
668    }
669
670    #[test]
671    fn atlascloud_default_uses_namespaced_model_id() {
672        let registry = ModelRegistry::default();
673        let resolved = registry.resolve(None, Some(ProviderKind::Atlascloud));
674
675        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
676        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
677        assert!(resolved.resolved.supports_reasoning);
678    }
679
680    #[test]
681    fn deepseek_v4_flash_alias_resolves_to_atlascloud_when_provider_hinted() {
682        let registry = ModelRegistry::default();
683        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Atlascloud));
684
685        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
686        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
687    }
688
689    #[test]
690    fn deepseek_v4_pro_alias_resolves_to_atlascloud_when_provider_hinted() {
691        let registry = ModelRegistry::default();
692        let resolved = registry.resolve(Some("deepseek-v4-pro"), Some(ProviderKind::Atlascloud));
693
694        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
695        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
696    }
697
698    #[test]
699    fn atlascloud_provider_hint_passes_through_explicit_model_id() {
700        let registry = ModelRegistry::default();
701        let resolved =
702            registry.resolve(Some("openai/gpt-5.2-chat"), Some(ProviderKind::Atlascloud));
703
704        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
705        assert_eq!(resolved.resolved.id, "openai/gpt-5.2-chat");
706        assert!(resolved.resolved.supports_tools);
707        assert!(resolved.resolved.supports_reasoning);
708        assert!(!resolved.used_fallback);
709    }
710
711    #[test]
712    fn atlascloud_provider_hint_preserves_explicit_model_id_case() {
713        let registry = ModelRegistry::default();
714        let resolved = registry.resolve(Some("Qwen/Qwen3-Coder"), Some(ProviderKind::Atlascloud));
715
716        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
717        assert_eq!(resolved.resolved.id, "Qwen/Qwen3-Coder");
718        assert!(!resolved.used_fallback);
719    }
720
721    #[test]
722    fn atlascloud_plain_unknown_model_still_uses_provider_default() {
723        let registry = ModelRegistry::default();
724        let resolved = registry.resolve(Some("not-in-atlas"), Some(ProviderKind::Atlascloud));
725
726        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
727        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
728        assert!(resolved.used_fallback);
729    }
730
731    #[test]
732    fn openrouter_default_uses_namespaced_model_id() {
733        let registry = ModelRegistry::default();
734        let resolved = registry.resolve(None, Some(ProviderKind::Openrouter));
735
736        assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
737        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
738    }
739
740    #[test]
741    fn xiaomi_mimo_default_uses_canonical_model_id() {
742        let registry = ModelRegistry::default();
743        let resolved = registry.resolve(None, Some(ProviderKind::XiaomiMimo));
744
745        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
746        assert_eq!(resolved.resolved.id, "mimo-v2.5-pro");
747        assert!(resolved.resolved.supports_reasoning);
748    }
749
750    #[test]
751    fn xiaomi_mimo_tts_aliases_resolve_when_provider_hinted() {
752        let registry = ModelRegistry::default();
753        let resolved = registry.resolve(Some("tts"), Some(ProviderKind::XiaomiMimo));
754        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
755        assert_eq!(resolved.resolved.id, "mimo-v2.5-tts");
756        assert!(!resolved.resolved.supports_tools);
757        assert!(!resolved.resolved.supports_reasoning);
758
759        let resolved = registry.resolve(Some("voice-design"), Some(ProviderKind::XiaomiMimo));
760        assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voicedesign");
761
762        let resolved = registry.resolve(Some("voiceclone"), Some(ProviderKind::XiaomiMimo));
763        assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voiceclone");
764    }
765
766    #[test]
767    fn wanjie_ark_default_uses_reasoner_model_id() {
768        let registry = ModelRegistry::default();
769        let resolved = registry.resolve(None, Some(ProviderKind::WanjieArk));
770
771        assert_eq!(resolved.resolved.provider, ProviderKind::WanjieArk);
772        assert_eq!(resolved.resolved.id, "deepseek-reasoner");
773        assert!(resolved.resolved.supports_reasoning);
774    }
775
776    #[test]
777    fn novita_default_uses_namespaced_model_id() {
778        let registry = ModelRegistry::default();
779        let resolved = registry.resolve(None, Some(ProviderKind::Novita));
780
781        assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
782        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
783    }
784
785    #[test]
786    fn fireworks_default_uses_canonical_model_id() {
787        let registry = ModelRegistry::default();
788        let resolved = registry.resolve(None, Some(ProviderKind::Fireworks));
789
790        assert_eq!(resolved.resolved.provider, ProviderKind::Fireworks);
791        assert_eq!(
792            resolved.resolved.id,
793            "accounts/fireworks/models/deepseek-v4-pro"
794        );
795    }
796
797    #[test]
798    fn siliconflow_default_uses_canonical_pro_model_id() {
799        let registry = ModelRegistry::default();
800        let resolved = registry.resolve(None, Some(ProviderKind::Siliconflow));
801
802        assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
803        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
804        assert!(resolved.resolved.supports_reasoning);
805    }
806
807    #[test]
808    fn deepseek_reasoner_alias_resolves_to_siliconflow_pro_when_provider_hinted() {
809        let registry = ModelRegistry::default();
810        let resolved = registry.resolve(Some("deepseek-reasoner"), Some(ProviderKind::Siliconflow));
811
812        assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
813        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
814    }
815
816    #[test]
817    fn deepseek_v4_flash_alias_resolves_to_siliconflow_flash_when_provider_hinted() {
818        let registry = ModelRegistry::default();
819        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Siliconflow));
820
821        assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
822        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
823    }
824
825    #[test]
826    fn sglang_default_uses_canonical_model_id() {
827        let registry = ModelRegistry::default();
828        let resolved = registry.resolve(None, Some(ProviderKind::Sglang));
829
830        assert_eq!(resolved.resolved.provider, ProviderKind::Sglang);
831        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
832    }
833
834    #[test]
835    fn deepseek_v4_flash_alias_resolves_to_openrouter_when_provider_hinted() {
836        let registry = ModelRegistry::default();
837        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Openrouter));
838
839        assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
840        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
841    }
842
843    #[test]
844    fn recent_openrouter_large_model_aliases_resolve_when_provider_hinted() {
845        let registry = ModelRegistry::default();
846
847        for (alias, expected) in [
848            ("trinity-large-thinking", "arcee-ai/trinity-large-thinking"),
849            ("qwen3.6-35b-a3b", "qwen/qwen3.6-35b-a3b"),
850            ("gemma-4-31b-it", "google/gemma-4-31b-it"),
851            ("glm-5.1", "z-ai/glm-5.1"),
852            ("minimax-m3", "minimax/minimax-m3"),
853            ("openrouter-mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro"),
854            ("openrouter-kimi-k2.6", "moonshotai/kimi-k2.6"),
855        ] {
856            let resolved = registry.resolve(Some(alias), Some(ProviderKind::Openrouter));
857
858            assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
859            assert_eq!(resolved.resolved.id, expected);
860            assert!(resolved.resolved.supports_tools);
861            assert!(resolved.resolved.supports_reasoning);
862        }
863    }
864
865    #[test]
866    fn deepseek_v4_flash_alias_resolves_to_novita_when_provider_hinted() {
867        let registry = ModelRegistry::default();
868        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Novita));
869
870        assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
871        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
872    }
873
874    #[test]
875    fn deepseek_v4_flash_alias_resolves_to_sglang_when_provider_hinted() {
876        let registry = ModelRegistry::default();
877        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Sglang));
878
879        assert_eq!(resolved.resolved.provider, ProviderKind::Sglang);
880        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
881    }
882
883    #[test]
884    fn vllm_default_uses_canonical_model_id() {
885        let registry = ModelRegistry::default();
886        let resolved = registry.resolve(None, Some(ProviderKind::Vllm));
887
888        assert_eq!(resolved.resolved.provider, ProviderKind::Vllm);
889        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
890    }
891
892    #[test]
893    fn ollama_default_uses_small_local_model_id() {
894        let registry = ModelRegistry::default();
895        let resolved = registry.resolve(None, Some(ProviderKind::Ollama));
896
897        assert_eq!(resolved.resolved.provider, ProviderKind::Ollama);
898        assert_eq!(resolved.resolved.id, "deepseek-coder:1.3b");
899        assert!(!resolved.resolved.supports_reasoning);
900    }
901
902    #[test]
903    fn ollama_requested_model_tag_is_preserved() {
904        let registry = ModelRegistry::default();
905        let resolved = registry.resolve(Some("qwen2.5-coder:7b"), Some(ProviderKind::Ollama));
906
907        assert_eq!(resolved.resolved.provider, ProviderKind::Ollama);
908        assert_eq!(resolved.resolved.id, "qwen2.5-coder:7b");
909        assert!(!resolved.used_fallback);
910    }
911
912    #[test]
913    fn deepseek_v4_flash_alias_resolves_to_vllm_when_provider_hinted() {
914        let registry = ModelRegistry::default();
915        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Vllm));
916
917        assert_eq!(resolved.resolved.provider, ProviderKind::Vllm);
918        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
919    }
920
921    #[test]
922    fn preserves_requested_model_casing_for_third_party_providers() {
923        let registry = ModelRegistry::default();
924        let resolved = registry.resolve(Some("DeepSeek-V4-Pro"), None);
925
926        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
927        assert_eq!(resolved.resolved.id, "DeepSeek-V4-Pro");
928    }
929
930    #[test]
931    fn registry_casing_takes_priority_over_requested_casing_with_provider_hint() {
932        let registry = ModelRegistry::default();
933        let resolved = registry.resolve(Some("DeepSeek-V4-Pro"), Some(ProviderKind::Deepseek));
934
935        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
936        // Registry's canonical id is used even when user provides different casing
937        assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
938    }
939
940    #[test]
941    fn preserves_requested_model_casing_without_surrounding_whitespace() {
942        let registry = ModelRegistry::default();
943        let resolved = registry.resolve(Some("  DeepSeek-V4-Pro  "), None);
944
945        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
946        assert_eq!(resolved.resolved.id, "DeepSeek-V4-Pro");
947    }
948
949    #[test]
950    fn alias_match_does_not_override_requested_casing() {
951        let registry = ModelRegistry::default();
952        let resolved = registry.resolve(Some("deepseek-reasoner"), None);
953
954        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
955        assert_eq!(resolved.resolved.id, "deepseek-v4-flash");
956    }
957}