Skip to main content

codewhale_agent/
lib.rs

1use std::collections::HashMap;
2
3use codewhale_config::{ProviderKind, opencode_go_chat_model_id};
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            // OpenAI public API models carried by the bundled catalog.
131            ModelInfo {
132                id: "gpt-5.3-codex".to_string(),
133                provider: ProviderKind::Openai,
134                aliases: vec!["gpt53-codex".to_string()],
135                supports_tools: true,
136                supports_reasoning: true,
137            },
138            ModelInfo {
139                id: "gpt-5.5".to_string(),
140                provider: ProviderKind::Openai,
141                aliases: vec!["openai-gpt-5.5".to_string()],
142                supports_tools: true,
143                supports_reasoning: true,
144            },
145            ModelInfo {
146                id: "gpt-5.5-pro".to_string(),
147                provider: ProviderKind::Openai,
148                aliases: vec!["openai-gpt-5.5-pro".to_string()],
149                supports_tools: true,
150                supports_reasoning: true,
151            },
152            // OpenAI public API GPT-5.6 family.
153            ModelInfo {
154                id: "gpt-5.6".to_string(),
155                provider: ProviderKind::Openai,
156                aliases: vec!["gpt56".to_string()],
157                supports_tools: true,
158                supports_reasoning: true,
159            },
160            ModelInfo {
161                id: "gpt-5.6-sol".to_string(),
162                provider: ProviderKind::Openai,
163                aliases: vec!["gpt56-sol".to_string()],
164                supports_tools: true,
165                supports_reasoning: true,
166            },
167            ModelInfo {
168                id: "gpt-5.6-terra".to_string(),
169                provider: ProviderKind::Openai,
170                aliases: vec!["gpt56-terra".to_string()],
171                supports_tools: true,
172                supports_reasoning: true,
173            },
174            ModelInfo {
175                id: "gpt-5.6-luna".to_string(),
176                provider: ProviderKind::Openai,
177                aliases: vec!["gpt56-luna".to_string()],
178                supports_tools: true,
179                supports_reasoning: true,
180            },
181            ModelInfo {
182                id: "deepseek-ai/deepseek-v4-flash".to_string(),
183                provider: ProviderKind::Atlascloud,
184                aliases: vec![
185                    "deepseek-v4-flash".to_string(),
186                    "atlascloud-deepseek-v4-flash".to_string(),
187                ],
188                supports_tools: true,
189                supports_reasoning: true,
190            },
191            ModelInfo {
192                id: "deepseek-ai/deepseek-v4-pro".to_string(),
193                provider: ProviderKind::Atlascloud,
194                aliases: vec![
195                    "deepseek-v4-pro".to_string(),
196                    "atlascloud-deepseek-v4-pro".to_string(),
197                ],
198                supports_tools: true,
199                supports_reasoning: true,
200            },
201            ModelInfo {
202                id: "deepseek-reasoner".to_string(),
203                provider: ProviderKind::WanjieArk,
204                aliases: vec![
205                    "wanjie-deepseek-reasoner".to_string(),
206                    "ark-wanjie-deepseek-reasoner".to_string(),
207                ],
208                supports_tools: true,
209                supports_reasoning: true,
210            },
211            ModelInfo {
212                id: "DeepSeek-V4-Pro".to_string(),
213                provider: ProviderKind::Volcengine,
214                aliases: vec![
215                    "deepseek-v4-pro".to_string(),
216                    "volcengine-deepseek-v4-pro".to_string(),
217                    "ark-deepseek-v4-pro".to_string(),
218                ],
219                supports_tools: true,
220                supports_reasoning: true,
221            },
222            ModelInfo {
223                id: "DeepSeek-V4-Flash".to_string(),
224                provider: ProviderKind::Volcengine,
225                aliases: vec![
226                    "deepseek-v4-flash".to_string(),
227                    "deepseek-chat".to_string(),
228                    "volcengine-deepseek-v4-flash".to_string(),
229                    "ark-deepseek-v4-flash".to_string(),
230                ],
231                supports_tools: true,
232                supports_reasoning: true,
233            },
234            ModelInfo {
235                id: "trinity-large-thinking".to_string(),
236                provider: ProviderKind::Arcee,
237                aliases: vec![
238                    "trinity".to_string(),
239                    "arcee-trinity".to_string(),
240                    "arcee-trinity-large-thinking".to_string(),
241                ],
242                supports_tools: true,
243                supports_reasoning: true,
244            },
245            ModelInfo {
246                id: "trinity-mini".to_string(),
247                provider: ProviderKind::Arcee,
248                aliases: vec!["arcee-trinity-mini".to_string()],
249                supports_tools: true,
250                supports_reasoning: true,
251            },
252            ModelInfo {
253                id: "deepseek/deepseek-v4-pro".to_string(),
254                provider: ProviderKind::Openrouter,
255                aliases: vec![
256                    "deepseek-v4-pro".to_string(),
257                    "openrouter-deepseek-v4-pro".to_string(),
258                ],
259                supports_tools: true,
260                supports_reasoning: true,
261            },
262            ModelInfo {
263                id: "deepseek/deepseek-v4-flash".to_string(),
264                provider: ProviderKind::Openrouter,
265                aliases: vec![
266                    "deepseek-v4-flash".to_string(),
267                    "deepseek-chat".to_string(),
268                    "deepseek-reasoner".to_string(),
269                    "openrouter-deepseek-v4-flash".to_string(),
270                ],
271                supports_tools: true,
272                supports_reasoning: true,
273            },
274            ModelInfo {
275                id: "arcee-ai/trinity-large-thinking".to_string(),
276                provider: ProviderKind::Openrouter,
277                aliases: vec![
278                    "trinity".to_string(),
279                    "trinity-large-thinking".to_string(),
280                    "arcee-trinity-large-thinking".to_string(),
281                ],
282                supports_tools: true,
283                supports_reasoning: true,
284            },
285            ModelInfo {
286                id: "xiaomi/mimo-v2.5-pro".to_string(),
287                provider: ProviderKind::Openrouter,
288                aliases: vec![
289                    "openrouter-mimo-v2.5-pro".to_string(),
290                    "openrouter-xiaomi-mimo-v2.5-pro".to_string(),
291                ],
292                supports_tools: true,
293                supports_reasoning: true,
294            },
295            ModelInfo {
296                id: "xiaomi/mimo-v2.5".to_string(),
297                provider: ProviderKind::Openrouter,
298                aliases: vec![
299                    "openrouter-mimo-v2.5".to_string(),
300                    "openrouter-xiaomi-mimo-v2.5".to_string(),
301                ],
302                supports_tools: true,
303                supports_reasoning: true,
304            },
305            ModelInfo {
306                id: "qwen/qwen3.6-flash".to_string(),
307                provider: ProviderKind::Openrouter,
308                aliases: vec!["qwen3.6-flash".to_string(), "qwen-3.6-flash".to_string()],
309                supports_tools: true,
310                supports_reasoning: true,
311            },
312            ModelInfo {
313                id: "qwen/qwen3.6-35b-a3b".to_string(),
314                provider: ProviderKind::Openrouter,
315                aliases: vec![
316                    "qwen3.6-35b-a3b".to_string(),
317                    "qwen-3.6-35b-a3b".to_string(),
318                ],
319                supports_tools: true,
320                supports_reasoning: true,
321            },
322            ModelInfo {
323                id: "qwen/qwen3.6-max-preview".to_string(),
324                provider: ProviderKind::Openrouter,
325                aliases: vec![
326                    "qwen3.6-max-preview".to_string(),
327                    "qwen-3.6-max-preview".to_string(),
328                    "qwen-max-preview".to_string(),
329                ],
330                supports_tools: true,
331                supports_reasoning: true,
332            },
333            ModelInfo {
334                id: "qwen/qwen3.6-27b".to_string(),
335                provider: ProviderKind::Openrouter,
336                aliases: vec!["qwen3.6-27b".to_string(), "qwen-3.6-27b".to_string()],
337                supports_tools: true,
338                supports_reasoning: true,
339            },
340            ModelInfo {
341                id: "qwen/qwen3.6-plus".to_string(),
342                provider: ProviderKind::Openrouter,
343                aliases: vec!["qwen3.6-plus".to_string(), "qwen-3.6-plus".to_string()],
344                supports_tools: true,
345                supports_reasoning: true,
346            },
347            ModelInfo {
348                id: "qwen/qwen3.7-plus".to_string(),
349                provider: ProviderKind::Openrouter,
350                aliases: vec!["qwen3.7-plus".to_string(), "qwen-3.7-plus".to_string()],
351                supports_tools: true,
352                supports_reasoning: true,
353            },
354            ModelInfo {
355                id: "moonshotai/kimi-k2.7-code".to_string(),
356                provider: ProviderKind::Openrouter,
357                aliases: vec![
358                    "kimi-k2.7-code".to_string(),
359                    "openrouter-kimi-k2.7-code".to_string(),
360                ],
361                supports_tools: true,
362                supports_reasoning: true,
363            },
364            ModelInfo {
365                id: "moonshotai/kimi-k2.6".to_string(),
366                provider: ProviderKind::Openrouter,
367                aliases: vec!["openrouter-kimi-k2.6".to_string()],
368                supports_tools: true,
369                supports_reasoning: true,
370            },
371            ModelInfo {
372                id: "minimax/minimax-m3".to_string(),
373                provider: ProviderKind::Openrouter,
374                aliases: vec![
375                    "minimax-m3".to_string(),
376                    "minimax-m-3".to_string(),
377                    "openrouter-minimax-m3".to_string(),
378                ],
379                supports_tools: true,
380                supports_reasoning: true,
381            },
382            ModelInfo {
383                id: "z-ai/glm-5.1".to_string(),
384                provider: ProviderKind::Openrouter,
385                aliases: vec!["glm-5.1".to_string(), "zai-glm-5.1".to_string()],
386                supports_tools: true,
387                supports_reasoning: true,
388            },
389            ModelInfo {
390                id: "z-ai/glm-5.2".to_string(),
391                provider: ProviderKind::Openrouter,
392                aliases: vec!["glm-5.2".to_string(), "zai-glm-5.2".to_string()],
393                supports_tools: true,
394                supports_reasoning: true,
395            },
396            ModelInfo {
397                id: "z-ai/glm-5-turbo".to_string(),
398                provider: ProviderKind::Openrouter,
399                aliases: vec!["glm-5-turbo".to_string(), "zai-glm-5-turbo".to_string()],
400                supports_tools: true,
401                supports_reasoning: true,
402            },
403            ModelInfo {
404                id: "GLM-5.2".to_string(),
405                provider: ProviderKind::Zai,
406                aliases: vec![
407                    "glm-5.2".to_string(),
408                    "glm-5-2".to_string(),
409                    "zai-glm-5.2".to_string(),
410                    "zai-glm-5-2".to_string(),
411                ],
412                supports_tools: true,
413                supports_reasoning: true,
414            },
415            ModelInfo {
416                id: "GLM-5.1".to_string(),
417                provider: ProviderKind::Zai,
418                aliases: vec![
419                    "glm-5.1".to_string(),
420                    "glm-5-1".to_string(),
421                    "zai-glm-5.1".to_string(),
422                    "zai-glm-5-1".to_string(),
423                ],
424                supports_tools: true,
425                supports_reasoning: true,
426            },
427            ModelInfo {
428                id: "GLM-5-Turbo".to_string(),
429                provider: ProviderKind::Zai,
430                aliases: vec![
431                    "glm-5-turbo".to_string(),
432                    "glm-5turbo".to_string(),
433                    "zai-glm-5-turbo".to_string(),
434                ],
435                supports_tools: true,
436                supports_reasoning: true,
437            },
438            ModelInfo {
439                id: "tencent/hy3-preview".to_string(),
440                provider: ProviderKind::Openrouter,
441                aliases: vec!["hy3-preview".to_string(), "tencent-hy3-preview".to_string()],
442                supports_tools: true,
443                supports_reasoning: true,
444            },
445            ModelInfo {
446                id: "google/gemma-4-31b-it".to_string(),
447                provider: ProviderKind::Openrouter,
448                aliases: vec!["gemma-4-31b".to_string(), "gemma-4-31b-it".to_string()],
449                supports_tools: true,
450                supports_reasoning: true,
451            },
452            ModelInfo {
453                id: "google/gemma-4-26b-a4b-it".to_string(),
454                provider: ProviderKind::Openrouter,
455                aliases: vec![
456                    "gemma-4-26b-a4b".to_string(),
457                    "gemma-4-26b-a4b-it".to_string(),
458                ],
459                supports_tools: true,
460                supports_reasoning: true,
461            },
462            ModelInfo {
463                id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free".to_string(),
464                provider: ProviderKind::Openrouter,
465                aliases: vec![
466                    "nemotron-3-nano-omni".to_string(),
467                    "nemotron-3-nano-omni-reasoning".to_string(),
468                ],
469                supports_tools: true,
470                supports_reasoning: true,
471            },
472            ModelInfo {
473                id: "mimo-v2.5-pro".to_string(),
474                provider: ProviderKind::XiaomiMimo,
475                aliases: vec![
476                    "mimo".to_string(),
477                    "pro".to_string(),
478                    "xiaomi-mimo-v2.5-pro".to_string(),
479                    "xiaomi-mimo-v2-5-pro".to_string(),
480                ],
481                supports_tools: true,
482                supports_reasoning: true,
483            },
484            ModelInfo {
485                id: "mimo-v2.5".to_string(),
486                provider: ProviderKind::XiaomiMimo,
487                aliases: vec![
488                    "omni".to_string(),
489                    "mimo-omni".to_string(),
490                    "v2.5-omni".to_string(),
491                    "mimo-v2.5-omni".to_string(),
492                    "xiaomi-mimo-v2.5".to_string(),
493                    "xiaomi-mimo-v2.5-omni".to_string(),
494                ],
495                supports_tools: true,
496                supports_reasoning: true,
497            },
498            ModelInfo {
499                id: "mimo-v2.5-asr".to_string(),
500                provider: ProviderKind::XiaomiMimo,
501                aliases: vec![
502                    "asr".to_string(),
503                    "speech-to-text".to_string(),
504                    "transcribe".to_string(),
505                ],
506                supports_tools: false,
507                supports_reasoning: false,
508            },
509            ModelInfo {
510                id: "mimo-v2.5-tts".to_string(),
511                provider: ProviderKind::XiaomiMimo,
512                aliases: vec![
513                    "tts".to_string(),
514                    "speech".to_string(),
515                    "mimo-tts".to_string(),
516                ],
517                supports_tools: false,
518                supports_reasoning: false,
519            },
520            ModelInfo {
521                id: "mimo-v2.5-tts-voicedesign".to_string(),
522                provider: ProviderKind::XiaomiMimo,
523                aliases: vec![
524                    "voicedesign".to_string(),
525                    "voice-design".to_string(),
526                    "mimo-voice-design".to_string(),
527                ],
528                supports_tools: false,
529                supports_reasoning: false,
530            },
531            ModelInfo {
532                id: "mimo-v2.5-tts-voiceclone".to_string(),
533                provider: ProviderKind::XiaomiMimo,
534                aliases: vec![
535                    "voiceclone".to_string(),
536                    "voice-clone".to_string(),
537                    "mimo-voice-clone".to_string(),
538                ],
539                supports_tools: false,
540                supports_reasoning: false,
541            },
542            ModelInfo {
543                id: "mimo-v2-tts".to_string(),
544                provider: ProviderKind::XiaomiMimo,
545                aliases: vec!["mimo-v2-speech".to_string()],
546                supports_tools: false,
547                supports_reasoning: false,
548            },
549            ModelInfo {
550                id: "deepseek/deepseek-v4-pro".to_string(),
551                provider: ProviderKind::Novita,
552                aliases: vec![
553                    "deepseek-v4-pro".to_string(),
554                    "novita-deepseek-v4-pro".to_string(),
555                ],
556                supports_tools: true,
557                supports_reasoning: true,
558            },
559            ModelInfo {
560                id: "deepseek/deepseek-v4-flash".to_string(),
561                provider: ProviderKind::Novita,
562                aliases: vec![
563                    "deepseek-v4-flash".to_string(),
564                    "deepseek-chat".to_string(),
565                    "deepseek-reasoner".to_string(),
566                    "novita-deepseek-v4-flash".to_string(),
567                ],
568                supports_tools: true,
569                supports_reasoning: true,
570            },
571            ModelInfo {
572                id: "accounts/fireworks/models/deepseek-v4-pro".to_string(),
573                provider: ProviderKind::Fireworks,
574                aliases: vec![
575                    "deepseek-v4-pro".to_string(),
576                    "fireworks-deepseek-v4-pro".to_string(),
577                ],
578                supports_tools: true,
579                supports_reasoning: true,
580            },
581            ModelInfo {
582                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
583                provider: ProviderKind::Siliconflow,
584                aliases: vec![
585                    "deepseek-v4-pro".to_string(),
586                    "deepseek-reasoner".to_string(),
587                    "deepseek-r1".to_string(),
588                    "siliconflow-deepseek-v4-pro".to_string(),
589                ],
590                supports_tools: true,
591                supports_reasoning: true,
592            },
593            ModelInfo {
594                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
595                provider: ProviderKind::Siliconflow,
596                aliases: vec![
597                    "deepseek-v4-flash".to_string(),
598                    "deepseek-chat".to_string(),
599                    "deepseek-v3".to_string(),
600                    "siliconflow-deepseek-v4-flash".to_string(),
601                ],
602                supports_tools: true,
603                supports_reasoning: true,
604            },
605            ModelInfo {
606                id: "trinity-large-preview".to_string(),
607                provider: ProviderKind::Arcee,
608                aliases: vec!["arcee-trinity-large-preview".to_string()],
609                supports_tools: true,
610                supports_reasoning: false,
611            },
612            ModelInfo {
613                id: "kimi-k2.7-code".to_string(),
614                provider: ProviderKind::Moonshot,
615                aliases: vec![
616                    "kimi".to_string(),
617                    "kimi-k2".to_string(),
618                    "kimi-k2.7".to_string(),
619                    "kimi-code".to_string(),
620                    "moonshot-kimi-k2.7-code".to_string(),
621                ],
622                supports_tools: true,
623                supports_reasoning: true,
624            },
625            ModelInfo {
626                id: "kimi-k2.6".to_string(),
627                provider: ProviderKind::Moonshot,
628                aliases: vec!["kimi-k2.6".to_string(), "moonshot-kimi-k2.6".to_string()],
629                supports_tools: true,
630                supports_reasoning: true,
631            },
632            // Moonshot ships K3 as two distinct products under one provider
633            // id, separated by endpoint (v0.9.1 kimi-k3 dogfood report):
634            //   * `kimi-k3` on the direct platform API (api.moonshot.ai/v1)
635            //   * `k3` on the Kimi Code coding-plan API (api.kimi.com/coding/v1)
636            // Both must be resolvable here or `--model kimi-k3` silently
637            // reports the provider default instead. The endpoint pairing is
638            // enforced separately by `validate_kimi_code_api_model_id`; keep
639            // the two ids in separate entries so neither one's alias set can
640            // launder a request onto the other product's route.
641            ModelInfo {
642                id: "kimi-k3".to_string(),
643                provider: ProviderKind::Moonshot,
644                aliases: vec!["moonshot-kimi-k3".to_string()],
645                supports_tools: true,
646                supports_reasoning: true,
647            },
648            ModelInfo {
649                id: "k3".to_string(),
650                provider: ProviderKind::Moonshot,
651                aliases: vec!["kimi-code-k3".to_string()],
652                supports_tools: true,
653                supports_reasoning: true,
654            },
655            ModelInfo {
656                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
657                provider: ProviderKind::Sglang,
658                aliases: vec![
659                    "deepseek-v4-pro".to_string(),
660                    "sglang-deepseek-v4-pro".to_string(),
661                ],
662                supports_tools: true,
663                supports_reasoning: true,
664            },
665            ModelInfo {
666                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
667                provider: ProviderKind::Sglang,
668                aliases: vec![
669                    "deepseek-v4-flash".to_string(),
670                    "deepseek-chat".to_string(),
671                    "deepseek-reasoner".to_string(),
672                    "sglang-deepseek-v4-flash".to_string(),
673                ],
674                supports_tools: true,
675                supports_reasoning: true,
676            },
677            ModelInfo {
678                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
679                provider: ProviderKind::Vllm,
680                aliases: vec![
681                    "deepseek-v4-pro".to_string(),
682                    "vllm-deepseek-v4-pro".to_string(),
683                ],
684                supports_tools: true,
685                supports_reasoning: true,
686            },
687            ModelInfo {
688                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
689                provider: ProviderKind::Vllm,
690                aliases: vec![
691                    "deepseek-v4-flash".to_string(),
692                    "deepseek-chat".to_string(),
693                    "deepseek-reasoner".to_string(),
694                    "vllm-deepseek-v4-flash".to_string(),
695                ],
696                supports_tools: true,
697                supports_reasoning: true,
698            },
699            ModelInfo {
700                id: "deepseek-v4-flash".to_string(),
701                provider: ProviderKind::Ollama,
702                aliases: vec![],
703                supports_tools: true,
704                supports_reasoning: true,
705            },
706            ModelInfo {
707                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
708                provider: ProviderKind::Huggingface,
709                aliases: vec![
710                    "deepseek-v4-pro".to_string(),
711                    "hf-deepseek-v4-pro".to_string(),
712                ],
713                supports_tools: true,
714                supports_reasoning: true,
715            },
716            ModelInfo {
717                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
718                provider: ProviderKind::Huggingface,
719                aliases: vec![
720                    "deepseek-v4-flash".to_string(),
721                    "deepseek-chat".to_string(),
722                    "deepseek-reasoner".to_string(),
723                    "hf-deepseek-v4-flash".to_string(),
724                ],
725                supports_tools: true,
726                supports_reasoning: true,
727            },
728            // Together AI provider models
729            ModelInfo {
730                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
731                provider: ProviderKind::Together,
732                aliases: vec![
733                    "deepseek-v4-pro".to_string(),
734                    "together-deepseek-v4-pro".to_string(),
735                ],
736                supports_tools: true,
737                supports_reasoning: true,
738            },
739            ModelInfo {
740                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
741                provider: ProviderKind::Together,
742                aliases: vec![
743                    "deepseek-v4-flash".to_string(),
744                    "deepseek-chat".to_string(),
745                    "together-deepseek-v4-flash".to_string(),
746                ],
747                supports_tools: true,
748                supports_reasoning: true,
749            },
750            ModelInfo {
751                // Together's published hosted endpoint is lowercase even though
752                // the open-weight Hugging Face repository uses `Inkling`.
753                id: "thinkingmachines/inkling".to_string(),
754                provider: ProviderKind::Together,
755                aliases: vec!["inkling".to_string(), "together-inkling".to_string()],
756                supports_tools: true,
757                supports_reasoning: true,
758            },
759            // Qwen 3.7 Max (OpenRouter)
760            ModelInfo {
761                id: "qwen/qwen3.7-max".to_string(),
762                provider: ProviderKind::Openrouter,
763                aliases: vec!["qwen3.7-max".to_string(), "qwen-3.7-max".to_string()],
764                supports_tools: true,
765                supports_reasoning: true,
766            },
767            // OpenAI Codex (ChatGPT OAuth) models
768            ModelInfo {
769                id: "gpt-5.5".to_string(),
770                provider: ProviderKind::OpenaiCodex,
771                aliases: vec!["codex-gpt-5.5".to_string(), "chatgpt-gpt-5.5".to_string()],
772                supports_tools: true,
773                supports_reasoning: true,
774            },
775            // Anthropic native Messages API models (#3014)
776            ModelInfo {
777                id: "claude-opus-4-8".to_string(),
778                provider: ProviderKind::Anthropic,
779                aliases: vec!["opus".to_string(), "claude-opus".to_string()],
780                supports_tools: true,
781                supports_reasoning: true,
782            },
783            ModelInfo {
784                id: "claude-sonnet-4-6".to_string(),
785                provider: ProviderKind::Anthropic,
786                aliases: vec!["sonnet".to_string(), "claude-sonnet".to_string()],
787                supports_tools: true,
788                supports_reasoning: true,
789            },
790            ModelInfo {
791                id: "claude-haiku-4-5".to_string(),
792                provider: ProviderKind::Anthropic,
793                aliases: vec!["haiku".to_string(), "claude-haiku".to_string()],
794                supports_tools: true,
795                supports_reasoning: false,
796            },
797            ModelInfo {
798                id: "claude-sonnet-5".to_string(),
799                provider: ProviderKind::Anthropic,
800                aliases: vec!["sonnet-5".to_string()],
801                supports_tools: true,
802                supports_reasoning: true,
803            },
804            ModelInfo {
805                id: "claude-fable-5".to_string(),
806                provider: ProviderKind::Anthropic,
807                aliases: vec!["fable".to_string(), "fable-5".to_string()],
808                supports_tools: true,
809                supports_reasoning: true,
810            },
811            // OpenModel Anthropic-compatible Messages route
812            ModelInfo {
813                id: "deepseek-v4-flash".to_string(),
814                provider: ProviderKind::Openmodel,
815                aliases: vec!["openmodel".to_string(), "openmodel-deepseek".to_string()],
816                supports_tools: true,
817                supports_reasoning: true,
818            },
819            // MiniMax 2.7 (OpenRouter)
820            ModelInfo {
821                id: "minimax/minimax-m2.7".to_string(),
822                provider: ProviderKind::Openrouter,
823                aliases: vec![
824                    "minimax-2.7".to_string(),
825                    "minimax-2-7".to_string(),
826                    "openrouter-minimax-2.7".to_string(),
827                ],
828                supports_tools: true,
829                supports_reasoning: true,
830            },
831            ModelInfo {
832                id: "step-3.7-flash".to_string(),
833                provider: ProviderKind::Stepfun,
834                aliases: vec!["stepfun".to_string(), "stepflash".to_string()],
835                supports_tools: true,
836                supports_reasoning: false,
837            },
838            ModelInfo {
839                id: "MiniMax-M3".to_string(),
840                provider: ProviderKind::Minimax,
841                aliases: vec![
842                    "minimax".to_string(),
843                    "minimax-m3".to_string(),
844                    "minimax-m-3".to_string(),
845                ],
846                supports_tools: true,
847                supports_reasoning: true,
848            },
849            ModelInfo {
850                id: "MiniMax-M2.7".to_string(),
851                provider: ProviderKind::Minimax,
852                aliases: vec![
853                    "minimax-m2.7".to_string(),
854                    "minimax-m2-7".to_string(),
855                    "minimax-m-2.7".to_string(),
856                    "minimax-m-2-7".to_string(),
857                ],
858                supports_tools: true,
859                supports_reasoning: true,
860            },
861            ModelInfo {
862                id: "MiniMax-M3".to_string(),
863                provider: ProviderKind::MinimaxAnthropic,
864                aliases: vec![
865                    "minimax-anthropic".to_string(),
866                    "minimax-anthropic-m3".to_string(),
867                    "minimax-m3".to_string(),
868                ],
869                supports_tools: true,
870                supports_reasoning: true,
871            },
872            ModelInfo {
873                id: "MiniMax-M2.7".to_string(),
874                provider: ProviderKind::MinimaxAnthropic,
875                aliases: vec![
876                    "minimax-anthropic-m2.7".to_string(),
877                    "minimax-anthropic-m2-7".to_string(),
878                    "minimax-m2.7".to_string(),
879                ],
880                supports_tools: true,
881                supports_reasoning: true,
882            },
883            ModelInfo {
884                id: "MiniMax-M2.7-highspeed".to_string(),
885                provider: ProviderKind::Minimax,
886                aliases: vec![
887                    "minimax-m2.7-highspeed".to_string(),
888                    "minimax-m2-7-highspeed".to_string(),
889                    "minimax-m-2.7-highspeed".to_string(),
890                    "minimax-m-2-7-highspeed".to_string(),
891                ],
892                supports_tools: true,
893                supports_reasoning: true,
894            },
895            ModelInfo {
896                id: "MiniMax-M2.5".to_string(),
897                provider: ProviderKind::Minimax,
898                aliases: vec![
899                    "minimax-m2.5".to_string(),
900                    "minimax-m2-5".to_string(),
901                    "minimax-m-2.5".to_string(),
902                    "minimax-m-2-5".to_string(),
903                ],
904                supports_tools: true,
905                supports_reasoning: true,
906            },
907            ModelInfo {
908                id: "MiniMax-M2.5-highspeed".to_string(),
909                provider: ProviderKind::Minimax,
910                aliases: vec![
911                    "minimax-m2.5-highspeed".to_string(),
912                    "minimax-m2-5-highspeed".to_string(),
913                    "minimax-m-2.5-highspeed".to_string(),
914                    "minimax-m-2-5-highspeed".to_string(),
915                ],
916                supports_tools: true,
917                supports_reasoning: true,
918            },
919            ModelInfo {
920                id: "MiniMax-M2.1".to_string(),
921                provider: ProviderKind::Minimax,
922                aliases: vec![
923                    "minimax-m2.1".to_string(),
924                    "minimax-m2-1".to_string(),
925                    "minimax-m-2.1".to_string(),
926                    "minimax-m-2-1".to_string(),
927                ],
928                supports_tools: true,
929                supports_reasoning: true,
930            },
931            ModelInfo {
932                id: "MiniMax-M2.1-highspeed".to_string(),
933                provider: ProviderKind::Minimax,
934                aliases: vec![
935                    "minimax-m2.1-highspeed".to_string(),
936                    "minimax-m2-1-highspeed".to_string(),
937                    "minimax-m-2.1-highspeed".to_string(),
938                    "minimax-m-2-1-highspeed".to_string(),
939                ],
940                supports_tools: true,
941                supports_reasoning: true,
942            },
943            ModelInfo {
944                id: "MiniMax-M2".to_string(),
945                provider: ProviderKind::Minimax,
946                aliases: vec!["minimax-m2".to_string(), "minimax-m-2".to_string()],
947                supports_tools: true,
948                supports_reasoning: true,
949            },
950            // NVIDIA Nemotron 3 Ultra (OpenRouter)
951            ModelInfo {
952                id: "nvidia/nemotron-3-ultra-550b-a55b".to_string(),
953                provider: ProviderKind::Openrouter,
954                aliases: vec![
955                    "nvidia/nemotron-3-ultra".to_string(),
956                    "nemotron-3-ultra".to_string(),
957                    "nemotron-3-ultra-550b-a55b".to_string(),
958                    "nvidia-nemotron-3-ultra".to_string(),
959                    "nvidia-nemotron-3-ultra-550b-a55b".to_string(),
960                ],
961                supports_tools: true,
962                supports_reasoning: true,
963            },
964            // DeepInfra (https://deepinfra.com)
965            ModelInfo {
966                id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
967                provider: ProviderKind::Deepinfra,
968                aliases: vec![
969                    "deepseek-v4-pro".to_string(),
970                    "di-deepseek-v4-pro".to_string(),
971                ],
972                supports_tools: true,
973                supports_reasoning: true,
974            },
975            ModelInfo {
976                id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
977                provider: ProviderKind::Deepinfra,
978                aliases: vec![
979                    "deepseek-v4-flash".to_string(),
980                    "di-deepseek-v4-flash".to_string(),
981                ],
982                supports_tools: true,
983                supports_reasoning: true,
984            },
985            // Sakana AI Fugu (https://api.sakana.ai)
986            ModelInfo {
987                id: "fugu".to_string(),
988                provider: ProviderKind::Sakana,
989                aliases: vec!["sakana-fugu".to_string(), "sakana/fugu".to_string()],
990                supports_tools: true,
991                supports_reasoning: false,
992            },
993            ModelInfo {
994                id: "fugu-ultra-20260615".to_string(),
995                provider: ProviderKind::Sakana,
996                aliases: vec!["fugu-ultra".to_string(), "sakana-fugu-ultra".to_string()],
997                supports_tools: true,
998                supports_reasoning: true,
999            },
1000            // Meituan LongCat (https://longcat.chat/platform)
1001            ModelInfo {
1002                id: "LongCat-2.0".to_string(),
1003                provider: ProviderKind::LongCat,
1004                aliases: vec!["longcat".to_string(), "longcat-2.0".to_string()],
1005                supports_tools: true,
1006                supports_reasoning: true,
1007            },
1008            // OpenCode Go Chat Completions models (https://opencode.ai/docs/go/).
1009            // Go models documented only on `/messages` are intentionally not
1010            // advertised by this OpenAI-compatible provider slice.
1011            ModelInfo {
1012                id: "deepseek-v4-pro".to_string(),
1013                provider: ProviderKind::OpencodeGo,
1014                aliases: vec!["opencode-go/deepseek-v4-pro".to_string()],
1015                supports_tools: true,
1016                supports_reasoning: true,
1017            },
1018            ModelInfo {
1019                id: "grok-4.5".to_string(),
1020                provider: ProviderKind::OpencodeGo,
1021                aliases: vec!["opencode-go/grok-4.5".to_string()],
1022                supports_tools: true,
1023                supports_reasoning: true,
1024            },
1025            ModelInfo {
1026                id: "glm-5.2".to_string(),
1027                provider: ProviderKind::OpencodeGo,
1028                aliases: vec!["opencode-go/glm-5.2".to_string()],
1029                supports_tools: true,
1030                supports_reasoning: true,
1031            },
1032            ModelInfo {
1033                id: "glm-5.1".to_string(),
1034                provider: ProviderKind::OpencodeGo,
1035                aliases: vec!["opencode-go/glm-5.1".to_string()],
1036                supports_tools: true,
1037                supports_reasoning: true,
1038            },
1039            ModelInfo {
1040                id: "kimi-k3".to_string(),
1041                provider: ProviderKind::OpencodeGo,
1042                aliases: vec!["opencode-go/kimi-k3".to_string()],
1043                supports_tools: true,
1044                supports_reasoning: true,
1045            },
1046            ModelInfo {
1047                id: "kimi-k2.7-code".to_string(),
1048                provider: ProviderKind::OpencodeGo,
1049                aliases: vec!["opencode-go/kimi-k2.7-code".to_string()],
1050                supports_tools: true,
1051                supports_reasoning: true,
1052            },
1053            ModelInfo {
1054                id: "kimi-k2.6".to_string(),
1055                provider: ProviderKind::OpencodeGo,
1056                aliases: vec!["opencode-go/kimi-k2.6".to_string()],
1057                supports_tools: true,
1058                supports_reasoning: true,
1059            },
1060            ModelInfo {
1061                id: "deepseek-v4-flash".to_string(),
1062                provider: ProviderKind::OpencodeGo,
1063                aliases: vec!["opencode-go/deepseek-v4-flash".to_string()],
1064                supports_tools: true,
1065                supports_reasoning: true,
1066            },
1067            ModelInfo {
1068                id: "mimo-v2.5".to_string(),
1069                provider: ProviderKind::OpencodeGo,
1070                aliases: vec!["opencode-go/mimo-v2.5".to_string()],
1071                supports_tools: true,
1072                supports_reasoning: true,
1073            },
1074            ModelInfo {
1075                id: "mimo-v2.5-pro".to_string(),
1076                provider: ProviderKind::OpencodeGo,
1077                aliases: vec!["opencode-go/mimo-v2.5-pro".to_string()],
1078                supports_tools: true,
1079                supports_reasoning: true,
1080            },
1081            // Meta Model API / Muse Spark.
1082            ModelInfo {
1083                id: "muse-spark-1.1".to_string(),
1084                provider: ProviderKind::Meta,
1085                aliases: vec!["muse-spark".to_string(), "muse".to_string()],
1086                supports_tools: true,
1087                supports_reasoning: true,
1088            },
1089            // xAI / Grok (https://api.x.ai/v1)
1090            ModelInfo {
1091                id: "grok-4.5".to_string(),
1092                provider: ProviderKind::Xai,
1093                aliases: vec!["grok".to_string(), "xai-grok-4.5".to_string()],
1094                supports_tools: true,
1095                supports_reasoning: true,
1096            },
1097            ModelInfo {
1098                id: "grok-4.3".to_string(),
1099                provider: ProviderKind::Xai,
1100                aliases: vec!["xai-grok-4.3".to_string()],
1101                supports_tools: true,
1102                supports_reasoning: true,
1103            },
1104            ModelInfo {
1105                id: "grok-build".to_string(),
1106                provider: ProviderKind::Xai,
1107                aliases: vec!["xai-grok-build".to_string()],
1108                supports_tools: true,
1109                supports_reasoning: true,
1110            },
1111            ModelInfo {
1112                id: "grok-composer-2.5-fast".to_string(),
1113                provider: ProviderKind::Xai,
1114                aliases: vec!["xai-grok-composer".to_string()],
1115                supports_tools: true,
1116                supports_reasoning: false,
1117            },
1118            ModelInfo {
1119                id: "grok-4.20-0309-reasoning".to_string(),
1120                provider: ProviderKind::Xai,
1121                aliases: vec!["xai-grok-reasoning".to_string()],
1122                supports_tools: true,
1123                supports_reasoning: true,
1124            },
1125            ModelInfo {
1126                id: "grok-4.20-0309-non-reasoning".to_string(),
1127                provider: ProviderKind::Xai,
1128                aliases: vec!["xai-grok-fast".to_string()],
1129                supports_tools: true,
1130                supports_reasoning: false,
1131            },
1132        ];
1133        Self::new(models)
1134    }
1135}
1136
1137impl ModelRegistry {
1138    /// Creates a new registry from a list of [`ModelInfo`] entries.
1139    ///
1140    /// Builds an internal alias map for fast lookup by model id or alias.
1141    /// If multiple models share the same id or alias, the first one registered
1142    /// takes priority.
1143    #[must_use]
1144    pub fn new(models: Vec<ModelInfo>) -> Self {
1145        let mut alias_map = HashMap::new();
1146        for (idx, model) in models.iter().enumerate() {
1147            alias_map.entry(normalize(&model.id)).or_insert(idx);
1148            for alias in &model.aliases {
1149                alias_map.entry(normalize(alias)).or_insert(idx);
1150            }
1151        }
1152        Self { models, alias_map }
1153    }
1154
1155    /// Returns a clone of all models in the registry.
1156    #[must_use]
1157    pub fn list(&self) -> Vec<ModelInfo> {
1158        self.models.clone()
1159    }
1160
1161    /// Resolves a user-requested model name to a concrete [`ModelInfo`].
1162    ///
1163    /// Resolution follows this priority order:
1164    /// 1. If the provider is Ollama, the requested name is used as-is (to
1165    ///    support arbitrary local model tags like `qwen2.5-coder:7b`).
1166    /// 2. If a `provider_hint` is given, search for a model matching that
1167    ///    provider whose id or alias matches the request (case-insensitive).
1168    /// 3. Look up the alias map for a case-insensitive match.
1169    /// 4. Fall back to the first model belonging to the hinted provider
1170    ///    (or DeepSeek if no hint was given).
1171    /// 5. As a last resort, fall back to the first model in the registry.
1172    #[must_use]
1173    pub fn resolve(
1174        &self,
1175        requested: Option<&str>,
1176        provider_hint: Option<ProviderKind>,
1177    ) -> ModelResolution {
1178        let mut fallback_chain = Vec::new();
1179
1180        if let Some(name) = requested {
1181            fallback_chain.push(format!("requested:{name}"));
1182            if provider_hint == Some(ProviderKind::Ollama) {
1183                return ModelResolution {
1184                    requested: Some(name.to_string()),
1185                    resolved: ModelInfo {
1186                        id: name.trim().to_string(),
1187                        provider: ProviderKind::Ollama,
1188                        aliases: Vec::new(),
1189                        supports_tools: true,
1190                        supports_reasoning: false,
1191                    },
1192                    used_fallback: false,
1193                    fallback_chain,
1194                };
1195            }
1196            // OpenCode Go's catalog spans Chat Completions and Anthropic
1197            // Messages, while Codewhale's provider slice intentionally speaks
1198            // Chat only. Resolve a hinted Go model through the shared Chat
1199            // allowlist and never fall through to a same-named global alias on
1200            // OpenRouter or MiniMax.
1201            if provider_hint == Some(ProviderKind::OpencodeGo)
1202                && let Some(canonical) = opencode_go_chat_model_id(name)
1203                && let Some(model) = self
1204                    .models
1205                    .iter()
1206                    .find(|model| {
1207                        model.provider == ProviderKind::OpencodeGo
1208                            && model.id.eq_ignore_ascii_case(canonical)
1209                    })
1210                    .cloned()
1211            {
1212                return ModelResolution {
1213                    requested: Some(name.to_string()),
1214                    resolved: model,
1215                    used_fallback: false,
1216                    fallback_chain,
1217                };
1218            }
1219            if provider_hint != Some(ProviderKind::OpencodeGo)
1220                && let Some(provider) = provider_hint
1221                && let Some(model) = self
1222                    .models
1223                    .iter()
1224                    .find(|m| m.provider == provider && model_matches(m, name))
1225                    .cloned()
1226            {
1227                return ModelResolution {
1228                    requested: Some(name.to_string()),
1229                    resolved: model,
1230                    used_fallback: false,
1231                    fallback_chain,
1232                };
1233            }
1234            if provider_hint == Some(ProviderKind::Atlascloud)
1235                && let Some(model) = atlascloud_passthrough_model(name)
1236            {
1237                return ModelResolution {
1238                    requested: Some(name.to_string()),
1239                    resolved: model,
1240                    used_fallback: false,
1241                    fallback_chain,
1242                };
1243            }
1244            if provider_hint == Some(ProviderKind::Arcee)
1245                && let Some(model) = arcee_passthrough_model(name)
1246            {
1247                return ModelResolution {
1248                    requested: Some(name.to_string()),
1249                    resolved: model,
1250                    used_fallback: false,
1251                    fallback_chain,
1252                };
1253            }
1254            if provider_hint == Some(ProviderKind::XiaomiMimo)
1255                && let Some(model) = xiaomi_mimo_passthrough_model(name)
1256            {
1257                return ModelResolution {
1258                    requested: Some(name.to_string()),
1259                    resolved: model,
1260                    used_fallback: false,
1261                    fallback_chain,
1262                };
1263            }
1264            // The global alias map is a *provider-less* convenience lookup. It
1265            // must never answer a provider-scoped question with another
1266            // vendor's model: before this fix, `--provider moonshot` asking for
1267            // `kimi-k3` was answered with OpenCode Go's `kimi-k3` because the
1268            // hinted provider had no such id. A hinted request that the hinted
1269            // provider cannot serve falls through to that provider's default
1270            // with `used_fallback: true`, which callers already surface.
1271            if provider_hint != Some(ProviderKind::OpencodeGo)
1272                && let Some(idx) = self.alias_map.get(&normalize(name))
1273                && provider_hint.is_none_or(|hint| self.models[*idx].provider == hint)
1274            {
1275                return ModelResolution {
1276                    requested: Some(name.to_string()),
1277                    resolved: preserve_requested_model_id_case(self.models[*idx].clone(), name),
1278                    used_fallback: false,
1279                    fallback_chain,
1280                };
1281            }
1282        }
1283
1284        let provider = provider_hint.unwrap_or(ProviderKind::Deepseek);
1285        fallback_chain.push(format!("provider_default:{}", provider.as_str()));
1286        if let Some(model) = self.models.iter().find(|m| m.provider == provider).cloned() {
1287            return ModelResolution {
1288                requested: requested.map(ToOwned::to_owned),
1289                resolved: model,
1290                used_fallback: true,
1291                fallback_chain,
1292            };
1293        }
1294
1295        let final_fallback = self.models.first().cloned().unwrap_or(ModelInfo {
1296            id: "deepseek-v4-pro".to_string(),
1297            provider: ProviderKind::Deepseek,
1298            aliases: Vec::new(),
1299            supports_tools: true,
1300            supports_reasoning: true,
1301        });
1302        fallback_chain.push("global_default:deepseek-v4-pro".to_string());
1303        ModelResolution {
1304            requested: requested.map(ToOwned::to_owned),
1305            resolved: final_fallback,
1306            used_fallback: true,
1307            fallback_chain,
1308        }
1309    }
1310}
1311
1312fn normalize(value: &str) -> String {
1313    value.trim().to_ascii_lowercase()
1314}
1315
1316#[must_use]
1317/// Classify a model identifier by its underlying model family.
1318pub fn model_family(model_id: &str) -> ModelFamily {
1319    let normalized = normalize(model_id);
1320    if normalized.is_empty() {
1321        return ModelFamily::Inferencer;
1322    }
1323
1324    if normalized.contains("deepseek") {
1325        return ModelFamily::DeepSeek;
1326    }
1327    if normalized.contains("claude") || normalized.contains("anthropic") {
1328        return ModelFamily::Anthropic;
1329    }
1330    if normalized.contains("gpt-oss") || normalized.contains("gpt_oss") {
1331        return ModelFamily::GptOss;
1332    }
1333    if normalized.starts_with("gpt-")
1334        || normalized.contains("/gpt-")
1335        || normalized.contains("openai/")
1336    {
1337        return ModelFamily::OpenAI;
1338    }
1339    if normalized.contains("gemini")
1340        || normalized.contains("gemma")
1341        || normalized.contains("google/")
1342    {
1343        return ModelFamily::Google;
1344    }
1345    if normalized.contains("llama")
1346        || normalized.contains("muse-spark")
1347        || normalized.contains("meta-")
1348        || normalized.contains("meta/")
1349    {
1350        return ModelFamily::Meta;
1351    }
1352    if normalized.contains("mistral")
1353        || normalized.contains("mixtral")
1354        || normalized.contains("codestral")
1355    {
1356        return ModelFamily::Mistral;
1357    }
1358    if normalized.contains("qwen") {
1359        return ModelFamily::Qwen;
1360    }
1361    if normalized.contains("grok") {
1362        return ModelFamily::Grok;
1363    }
1364    if normalized.contains("cohere") || normalized.contains("command-r") {
1365        return ModelFamily::Cohere;
1366    }
1367
1368    ModelFamily::Inferencer
1369}
1370
1371fn model_matches(model: &ModelInfo, requested: &str) -> bool {
1372    let requested = normalize(requested);
1373    normalize(&model.id) == requested
1374        || model
1375            .aliases
1376            .iter()
1377            .any(|alias| normalize(alias) == requested)
1378}
1379
1380fn preserve_requested_model_id_case(mut model: ModelInfo, requested: &str) -> ModelInfo {
1381    let requested = requested.trim();
1382    if model.id.eq_ignore_ascii_case(requested) {
1383        model.id = requested.to_string();
1384    }
1385    model
1386}
1387
1388fn atlascloud_passthrough_model(requested: &str) -> Option<ModelInfo> {
1389    let requested = requested.trim();
1390    if requested.is_empty() || !requested.contains('/') {
1391        return None;
1392    }
1393
1394    Some(ModelInfo {
1395        id: requested.to_string(),
1396        provider: ProviderKind::Atlascloud,
1397        aliases: Vec::new(),
1398        supports_tools: true,
1399        supports_reasoning: true,
1400    })
1401}
1402
1403fn arcee_passthrough_model(requested: &str) -> Option<ModelInfo> {
1404    let requested = requested.trim();
1405    if requested.is_empty() {
1406        return None;
1407    }
1408    let supports_reasoning = requested.to_ascii_lowercase().contains("thinking");
1409
1410    Some(ModelInfo {
1411        id: requested.to_string(),
1412        provider: ProviderKind::Arcee,
1413        aliases: Vec::new(),
1414        supports_tools: true,
1415        supports_reasoning,
1416    })
1417}
1418
1419fn xiaomi_mimo_passthrough_model(requested: &str) -> Option<ModelInfo> {
1420    let requested = requested.trim();
1421    if requested.is_empty() || requested.chars().any(char::is_control) {
1422        return None;
1423    }
1424
1425    Some(ModelInfo {
1426        id: requested.to_string(),
1427        provider: ProviderKind::XiaomiMimo,
1428        aliases: Vec::new(),
1429        supports_tools: true,
1430        supports_reasoning: true,
1431    })
1432}
1433
1434#[cfg(test)]
1435mod tests {
1436    use super::*;
1437
1438    #[test]
1439    fn model_registry_new_builds_alias_map_correctly() {
1440        let models = vec![
1441            ModelInfo {
1442                id: "Model-A".to_string(),
1443                provider: ProviderKind::Deepseek,
1444                aliases: vec!["alias-1".to_string(), " ALIAS-2 ".to_string()],
1445                supports_tools: true,
1446                supports_reasoning: false,
1447            },
1448            ModelInfo {
1449                id: "model-b".to_string(),
1450                provider: ProviderKind::Deepseek,
1451                aliases: vec!["alias-1".to_string()], // Duplicate alias, should not override
1452                supports_tools: true,
1453                supports_reasoning: true,
1454            },
1455        ];
1456
1457        let registry = ModelRegistry::new(models);
1458
1459        assert_eq!(registry.alias_map.len(), 4); // "model-a", "alias-1", "alias-2", "model-b"
1460        assert_eq!(registry.alias_map.get("model-a"), Some(&0));
1461        assert_eq!(registry.alias_map.get("alias-1"), Some(&0)); // First one wins
1462        assert_eq!(registry.alias_map.get("alias-2"), Some(&0)); // Normalized
1463        assert_eq!(registry.alias_map.get("model-b"), Some(&1));
1464    }
1465
1466    #[test]
1467    fn deepseek_v4_pro_alias_stays_deepseek_by_default() {
1468        let registry = ModelRegistry::default();
1469        let resolved = registry.resolve(Some("deepseek-v4-pro"), None);
1470
1471        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
1472        assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
1473    }
1474
1475    #[test]
1476    fn deepseek_v4_pro_alias_resolves_to_nvidia_nim_when_provider_hinted() {
1477        let registry = ModelRegistry::default();
1478        let resolved = registry.resolve(Some("deepseek-v4-pro"), Some(ProviderKind::NvidiaNim));
1479
1480        assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
1481        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
1482    }
1483
1484    #[test]
1485    fn nvidia_nim_default_uses_catalog_model_id() {
1486        let registry = ModelRegistry::default();
1487        let resolved = registry.resolve(None, Some(ProviderKind::NvidiaNim));
1488
1489        assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
1490        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
1491    }
1492
1493    #[test]
1494    fn deepseek_v4_flash_alias_resolves_to_nvidia_nim_when_provider_hinted() {
1495        let registry = ModelRegistry::default();
1496        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::NvidiaNim));
1497
1498        assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
1499        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
1500    }
1501
1502    #[test]
1503    fn atlascloud_default_uses_namespaced_model_id() {
1504        let registry = ModelRegistry::default();
1505        let resolved = registry.resolve(None, Some(ProviderKind::Atlascloud));
1506
1507        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1508        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
1509        assert!(resolved.resolved.supports_reasoning);
1510    }
1511
1512    #[test]
1513    fn deepseek_v4_flash_alias_resolves_to_atlascloud_when_provider_hinted() {
1514        let registry = ModelRegistry::default();
1515        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Atlascloud));
1516
1517        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1518        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
1519    }
1520
1521    #[test]
1522    fn deepseek_v4_pro_alias_resolves_to_atlascloud_when_provider_hinted() {
1523        let registry = ModelRegistry::default();
1524        let resolved = registry.resolve(Some("deepseek-v4-pro"), Some(ProviderKind::Atlascloud));
1525
1526        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1527        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
1528    }
1529
1530    #[test]
1531    fn atlascloud_provider_hint_passes_through_explicit_model_id() {
1532        let registry = ModelRegistry::default();
1533        let resolved =
1534            registry.resolve(Some("openai/gpt-5.2-chat"), Some(ProviderKind::Atlascloud));
1535
1536        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1537        assert_eq!(resolved.resolved.id, "openai/gpt-5.2-chat");
1538        assert!(resolved.resolved.supports_tools);
1539        assert!(resolved.resolved.supports_reasoning);
1540        assert!(!resolved.used_fallback);
1541    }
1542
1543    #[test]
1544    fn atlascloud_provider_hint_preserves_explicit_model_id_case() {
1545        let registry = ModelRegistry::default();
1546        let resolved = registry.resolve(Some("Qwen/Qwen3-Coder"), Some(ProviderKind::Atlascloud));
1547
1548        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1549        assert_eq!(resolved.resolved.id, "Qwen/Qwen3-Coder");
1550        assert!(!resolved.used_fallback);
1551    }
1552
1553    #[test]
1554    fn atlascloud_plain_unknown_model_still_uses_provider_default() {
1555        let registry = ModelRegistry::default();
1556        let resolved = registry.resolve(Some("not-in-atlas"), Some(ProviderKind::Atlascloud));
1557
1558        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1559        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
1560        assert!(resolved.used_fallback);
1561    }
1562
1563    #[test]
1564    fn openrouter_default_uses_namespaced_model_id() {
1565        let registry = ModelRegistry::default();
1566        let resolved = registry.resolve(None, Some(ProviderKind::Openrouter));
1567
1568        assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
1569        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
1570    }
1571
1572    #[test]
1573    fn xiaomi_mimo_default_uses_canonical_model_id() {
1574        let registry = ModelRegistry::default();
1575        let resolved = registry.resolve(None, Some(ProviderKind::XiaomiMimo));
1576
1577        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1578        assert_eq!(resolved.resolved.id, "mimo-v2.5-pro");
1579        assert!(resolved.resolved.supports_reasoning);
1580    }
1581
1582    #[test]
1583    fn moonshot_default_and_aliases_use_kimi_k27_code() {
1584        let registry = ModelRegistry::default();
1585
1586        for requested in [None, Some("kimi"), Some("kimi-k2.7-code")] {
1587            let resolved = registry.resolve(requested, Some(ProviderKind::Moonshot));
1588
1589            assert_eq!(resolved.resolved.provider, ProviderKind::Moonshot);
1590            assert_eq!(resolved.resolved.id, "kimi-k2.7-code");
1591            assert!(resolved.resolved.supports_tools);
1592            assert!(resolved.resolved.supports_reasoning);
1593        }
1594    }
1595
1596    #[test]
1597    fn moonshot_explicit_kimi_k26_remains_available() {
1598        let registry = ModelRegistry::default();
1599        let resolved = registry.resolve(Some("kimi-k2.6"), Some(ProviderKind::Moonshot));
1600
1601        assert_eq!(resolved.resolved.provider, ProviderKind::Moonshot);
1602        assert_eq!(resolved.resolved.id, "kimi-k2.6");
1603        assert!(resolved.resolved.supports_reasoning);
1604    }
1605
1606    /// v0.9.1 dogfood report: a user ran `--provider moonshot --model kimi-k3` and was told
1607    /// the model was `kimi-k2.7-code`. The registry knew neither Moonshot K3
1608    /// product, so the explicit request fell through to the provider default.
1609    #[test]
1610    fn moonshot_resolves_both_k3_products_without_crossing_them() {
1611        let registry = ModelRegistry::default();
1612
1613        for (requested, expected) in [("kimi-k3", "kimi-k3"), ("k3", "k3")] {
1614            let resolved = registry.resolve(Some(requested), Some(ProviderKind::Moonshot));
1615
1616            assert_eq!(resolved.resolved.provider, ProviderKind::Moonshot);
1617            assert_eq!(resolved.resolved.id, expected, "{resolved:?}");
1618            assert!(
1619                !resolved.used_fallback,
1620                "an explicit Moonshot K3 request is not a fallback: {resolved:?}"
1621            );
1622        }
1623    }
1624
1625    /// The bare `k3` id belongs to the Kimi Code coding-plan endpoint and
1626    /// `kimi-k3` to the direct platform endpoint. Neither may be laundered
1627    /// into the other's id by alias expansion.
1628    #[test]
1629    fn moonshot_k3_ids_are_never_rewritten_into_each_other() {
1630        let registry = ModelRegistry::default();
1631
1632        assert_eq!(
1633            registry
1634                .resolve(Some("kimi-k3"), Some(ProviderKind::Moonshot))
1635                .resolved
1636                .id,
1637            "kimi-k3"
1638        );
1639        assert_eq!(
1640            registry
1641                .resolve(Some("k3"), Some(ProviderKind::Moonshot))
1642                .resolved
1643                .id,
1644            "k3"
1645        );
1646    }
1647
1648    /// A provider-scoped question must never be answered with another
1649    /// vendor's model. `kimi-k3` also exists in the OpenCode Go catalog;
1650    /// before this fix that entry answered `--provider moonshot` requests.
1651    #[test]
1652    fn a_provider_hint_never_resolves_to_another_providers_model() {
1653        let registry = ModelRegistry::default();
1654
1655        let resolved = registry.resolve(Some("glm-5.2"), Some(ProviderKind::Moonshot));
1656        assert_eq!(
1657            resolved.resolved.provider,
1658            ProviderKind::Moonshot,
1659            "a Moonshot request must not be answered by Z.ai: {resolved:?}"
1660        );
1661        assert!(
1662            resolved.used_fallback,
1663            "an unservable id must be reported as a fallback, not as the request: {resolved:?}"
1664        );
1665
1666        let go = registry.resolve(Some("kimi-k3"), Some(ProviderKind::OpencodeGo));
1667        assert_eq!(go.resolved.provider, ProviderKind::OpencodeGo);
1668        assert_eq!(go.resolved.id, "kimi-k3");
1669    }
1670
1671    #[test]
1672    fn xiaomi_mimo_tts_aliases_resolve_when_provider_hinted() {
1673        let registry = ModelRegistry::default();
1674        let resolved = registry.resolve(Some("tts"), Some(ProviderKind::XiaomiMimo));
1675        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1676        assert_eq!(resolved.resolved.id, "mimo-v2.5-tts");
1677        assert!(!resolved.resolved.supports_tools);
1678        assert!(!resolved.resolved.supports_reasoning);
1679
1680        let resolved = registry.resolve(Some("voice-design"), Some(ProviderKind::XiaomiMimo));
1681        assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voicedesign");
1682
1683        let resolved = registry.resolve(Some("voiceclone"), Some(ProviderKind::XiaomiMimo));
1684        assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voiceclone");
1685    }
1686
1687    #[test]
1688    fn xiaomi_mimo_chat_aliases_resolve_when_provider_hinted() {
1689        let registry = ModelRegistry::default();
1690
1691        let resolved = registry.resolve(Some("omni"), Some(ProviderKind::XiaomiMimo));
1692        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1693        assert_eq!(resolved.resolved.id, "mimo-v2.5");
1694        assert!(resolved.resolved.supports_tools);
1695    }
1696
1697    #[test]
1698    fn xiaomi_mimo_provider_hint_preserves_custom_model_id() {
1699        let registry = ModelRegistry::default();
1700        let resolved =
1701            registry.resolve(Some("account-custom-mimo"), Some(ProviderKind::XiaomiMimo));
1702
1703        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1704        assert_eq!(resolved.resolved.id, "account-custom-mimo");
1705        assert!(!resolved.used_fallback);
1706    }
1707
1708    #[test]
1709    fn xiaomi_mimo_provider_hint_does_not_reclassify_openrouter_model_id() {
1710        let registry = ModelRegistry::default();
1711        let resolved = registry.resolve(
1712            Some("deepseek/deepseek-v4-pro"),
1713            Some(ProviderKind::XiaomiMimo),
1714        );
1715
1716        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1717        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
1718        assert!(!resolved.used_fallback);
1719    }
1720
1721    #[test]
1722    fn wanjie_ark_default_uses_reasoner_model_id() {
1723        let registry = ModelRegistry::default();
1724        let resolved = registry.resolve(None, Some(ProviderKind::WanjieArk));
1725
1726        assert_eq!(resolved.resolved.provider, ProviderKind::WanjieArk);
1727        assert_eq!(resolved.resolved.id, "deepseek-reasoner");
1728        assert!(resolved.resolved.supports_reasoning);
1729    }
1730
1731    #[test]
1732    fn novita_default_uses_namespaced_model_id() {
1733        let registry = ModelRegistry::default();
1734        let resolved = registry.resolve(None, Some(ProviderKind::Novita));
1735
1736        assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
1737        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
1738    }
1739
1740    #[test]
1741    fn fireworks_default_uses_canonical_model_id() {
1742        let registry = ModelRegistry::default();
1743        let resolved = registry.resolve(None, Some(ProviderKind::Fireworks));
1744
1745        assert_eq!(resolved.resolved.provider, ProviderKind::Fireworks);
1746        assert_eq!(
1747            resolved.resolved.id,
1748            "accounts/fireworks/models/deepseek-v4-pro"
1749        );
1750    }
1751
1752    #[test]
1753    fn siliconflow_default_uses_canonical_pro_model_id() {
1754        let registry = ModelRegistry::default();
1755        let resolved = registry.resolve(None, Some(ProviderKind::Siliconflow));
1756
1757        assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
1758        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1759        assert!(resolved.resolved.supports_reasoning);
1760    }
1761
1762    #[test]
1763    fn arcee_default_uses_direct_trinity_large_thinking_model_id() {
1764        let registry = ModelRegistry::default();
1765        let resolved = registry.resolve(None, Some(ProviderKind::Arcee));
1766
1767        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1768        assert_eq!(resolved.resolved.id, "trinity-large-thinking");
1769        assert!(resolved.resolved.supports_reasoning);
1770    }
1771
1772    #[test]
1773    fn arcee_trinity_alias_resolves_to_direct_large_thinking_not_openrouter() {
1774        let registry = ModelRegistry::default();
1775        let resolved = registry.resolve(Some("trinity"), Some(ProviderKind::Arcee));
1776
1777        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1778        assert_eq!(resolved.resolved.id, "trinity-large-thinking");
1779        assert!(resolved.resolved.supports_reasoning);
1780    }
1781
1782    #[test]
1783    fn arcee_trinity_mini_remains_explicit_compatibility_model() {
1784        let registry = ModelRegistry::default();
1785        let resolved = registry.resolve(Some("trinity-mini"), Some(ProviderKind::Arcee));
1786
1787        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1788        assert_eq!(resolved.resolved.id, "trinity-mini");
1789        assert!(resolved.resolved.supports_reasoning);
1790        assert!(!resolved.used_fallback);
1791    }
1792
1793    #[test]
1794    fn arcee_provider_hint_preserves_explicit_future_model_id() {
1795        let registry = ModelRegistry::default();
1796        let resolved = registry.resolve(Some("trinity-large-next"), Some(ProviderKind::Arcee));
1797
1798        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1799        assert_eq!(resolved.resolved.id, "trinity-large-next");
1800        assert!(!resolved.resolved.supports_reasoning);
1801        assert!(!resolved.used_fallback);
1802    }
1803
1804    #[test]
1805    fn deepseek_reasoner_alias_resolves_to_siliconflow_pro_when_provider_hinted() {
1806        let registry = ModelRegistry::default();
1807        let resolved = registry.resolve(Some("deepseek-reasoner"), Some(ProviderKind::Siliconflow));
1808
1809        assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
1810        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1811    }
1812
1813    #[test]
1814    fn deepseek_v4_flash_alias_resolves_to_siliconflow_flash_when_provider_hinted() {
1815        let registry = ModelRegistry::default();
1816        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Siliconflow));
1817
1818        assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
1819        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
1820    }
1821
1822    #[test]
1823    fn sglang_default_uses_canonical_model_id() {
1824        let registry = ModelRegistry::default();
1825        let resolved = registry.resolve(None, Some(ProviderKind::Sglang));
1826
1827        assert_eq!(resolved.resolved.provider, ProviderKind::Sglang);
1828        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1829    }
1830
1831    #[test]
1832    fn zai_direct_models_resolve_when_provider_hinted() {
1833        let registry = ModelRegistry::default();
1834
1835        // GLM-5.2 is now the default direct Z.AI model.
1836        let default = registry.resolve(None, Some(ProviderKind::Zai));
1837        assert_eq!(default.resolved.provider, ProviderKind::Zai);
1838        assert_eq!(default.resolved.id, "GLM-5.2");
1839
1840        for (alias, expected) in [
1841            ("GLM-5.1", "GLM-5.1"),
1842            ("glm-5-1", "GLM-5.1"),
1843            ("GLM-5.2", "GLM-5.2"),
1844            ("glm-5.2", "GLM-5.2"),
1845            ("zai-glm-5-2", "GLM-5.2"),
1846            ("GLM-5-Turbo", "GLM-5-Turbo"),
1847            ("glm-5-turbo", "GLM-5-Turbo"),
1848            ("zai-glm-5-turbo", "GLM-5-Turbo"),
1849        ] {
1850            let resolved = registry.resolve(Some(alias), Some(ProviderKind::Zai));
1851
1852            assert_eq!(resolved.resolved.provider, ProviderKind::Zai);
1853            assert_eq!(resolved.resolved.id, expected);
1854            assert!(!resolved.used_fallback);
1855            assert!(resolved.resolved.supports_tools);
1856            assert!(resolved.resolved.supports_reasoning);
1857        }
1858    }
1859
1860    #[test]
1861    fn first_party_recent_provider_models_are_listed() {
1862        let registry = ModelRegistry::default();
1863        let models = registry.list();
1864
1865        for (provider, id) in [
1866            (ProviderKind::Zai, "GLM-5.2"),
1867            (ProviderKind::Stepfun, "step-3.7-flash"),
1868            (ProviderKind::Minimax, "MiniMax-M2.1"),
1869            (ProviderKind::MinimaxAnthropic, "MiniMax-M3"),
1870            (ProviderKind::Openmodel, "deepseek-v4-flash"),
1871            (ProviderKind::Meta, "muse-spark-1.1"),
1872            (ProviderKind::Xai, "grok-4.5"),
1873        ] {
1874            assert!(
1875                models
1876                    .iter()
1877                    .any(|model| model.provider == provider && model.id == id),
1878                "expected {provider:?} model {id} in registry"
1879            );
1880        }
1881    }
1882
1883    #[test]
1884    fn opencode_go_lists_only_current_chat_completions_models() {
1885        let registry = ModelRegistry::default();
1886        let listed = registry.list();
1887        let models: Vec<&str> = listed
1888            .iter()
1889            .filter(|model| model.provider == ProviderKind::OpencodeGo)
1890            .map(|model| model.id.as_str())
1891            .collect();
1892
1893        assert_eq!(
1894            models,
1895            vec![
1896                "deepseek-v4-pro",
1897                "grok-4.5",
1898                "glm-5.2",
1899                "glm-5.1",
1900                "kimi-k3",
1901                "kimi-k2.7-code",
1902                "kimi-k2.6",
1903                "deepseek-v4-flash",
1904                "mimo-v2.5",
1905                "mimo-v2.5-pro",
1906            ]
1907        );
1908
1909        let default = registry.resolve(None, Some(ProviderKind::OpencodeGo));
1910        assert_eq!(default.resolved.provider, ProviderKind::OpencodeGo);
1911        assert_eq!(default.resolved.id, "deepseek-v4-pro");
1912
1913        for model in ["grok-4.5", "kimi-k3"] {
1914            for requested in [model.to_string(), format!("opencode-go/{model}")] {
1915                let resolved = registry.resolve(Some(&requested), Some(ProviderKind::OpencodeGo));
1916                assert_eq!(resolved.resolved.provider, ProviderKind::OpencodeGo);
1917                assert_eq!(resolved.resolved.id, model);
1918                assert!(!resolved.used_fallback);
1919            }
1920        }
1921
1922        for messages_only in [
1923            "minimax-m3",
1924            "minimax-m2.7",
1925            "minimax-m2.5",
1926            "qwen3.7-max",
1927            "qwen3.7-plus",
1928            "qwen3.6-plus",
1929        ] {
1930            for requested in [
1931                messages_only.to_string(),
1932                format!("opencode-go/{messages_only}"),
1933            ] {
1934                let rejected = registry.resolve(Some(&requested), Some(ProviderKind::OpencodeGo));
1935                assert!(rejected.used_fallback, "{requested}");
1936                assert_eq!(
1937                    rejected.resolved.provider,
1938                    ProviderKind::OpencodeGo,
1939                    "{requested} must not cross-route"
1940                );
1941                assert_eq!(rejected.resolved.id, "deepseek-v4-pro", "{requested}");
1942            }
1943        }
1944    }
1945
1946    #[test]
1947    fn xai_grok_models_resolve_when_provider_hinted() {
1948        let registry = ModelRegistry::default();
1949
1950        let default = registry.resolve(None, Some(ProviderKind::Xai));
1951        assert_eq!(default.resolved.provider, ProviderKind::Xai);
1952        assert_eq!(default.resolved.id, "grok-4.5");
1953        assert!(default.used_fallback);
1954
1955        let alias = registry.resolve(Some("grok"), Some(ProviderKind::Xai));
1956        assert_eq!(alias.resolved.provider, ProviderKind::Xai);
1957        assert_eq!(alias.resolved.id, "grok-4.5");
1958        assert!(!alias.used_fallback);
1959
1960        let fast = registry.resolve(
1961            Some("grok-4.20-0309-non-reasoning"),
1962            Some(ProviderKind::Xai),
1963        );
1964        assert_eq!(fast.resolved.provider, ProviderKind::Xai);
1965        assert_eq!(fast.resolved.id, "grok-4.20-0309-non-reasoning");
1966        assert!(!fast.resolved.supports_reasoning);
1967    }
1968
1969    #[test]
1970    fn meta_muse_spark_resolves_when_provider_hinted() {
1971        let registry = ModelRegistry::default();
1972
1973        let default = registry.resolve(None, Some(ProviderKind::Meta));
1974        assert_eq!(default.resolved.provider, ProviderKind::Meta);
1975        assert_eq!(default.resolved.id, "muse-spark-1.1");
1976        assert!(default.used_fallback);
1977
1978        let alias = registry.resolve(Some("muse-spark"), Some(ProviderKind::Meta));
1979        assert_eq!(alias.resolved.provider, ProviderKind::Meta);
1980        assert_eq!(alias.resolved.id, "muse-spark-1.1");
1981        assert!(!alias.used_fallback);
1982        assert_eq!(model_family("muse-spark-1.1"), ModelFamily::Meta);
1983    }
1984
1985    #[test]
1986    fn openai_gpt56_family_resolves_when_provider_hinted() {
1987        let registry = ModelRegistry::default();
1988        for model in ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] {
1989            let resolved = registry.resolve(Some(model), Some(ProviderKind::Openai));
1990            assert_eq!(resolved.resolved.provider, ProviderKind::Openai, "{model}");
1991            assert_eq!(resolved.resolved.id, model, "{model}");
1992            assert!(resolved.resolved.supports_tools, "{model}");
1993            assert!(resolved.resolved.supports_reasoning, "{model}");
1994            assert!(!resolved.used_fallback, "{model}");
1995        }
1996    }
1997
1998    #[test]
1999    fn grok_ids_stay_in_grok_family() {
2000        assert_eq!(model_family("grok-4.5"), ModelFamily::Grok);
2001        assert_eq!(
2002            model_family("grok-4.20-0309-non-reasoning"),
2003            ModelFamily::Grok
2004        );
2005    }
2006
2007    #[test]
2008    fn stepfun_and_minimax_direct_models_resolve_when_provider_hinted() {
2009        let registry = ModelRegistry::default();
2010
2011        let stepfun = registry.resolve(None, Some(ProviderKind::Stepfun));
2012        assert_eq!(stepfun.resolved.provider, ProviderKind::Stepfun);
2013        assert_eq!(stepfun.resolved.id, "step-3.7-flash");
2014
2015        for (alias, expected) in [
2016            ("minimax", "MiniMax-M3"),
2017            ("minimax-m3", "MiniMax-M3"),
2018            ("minimax-m2.7", "MiniMax-M2.7"),
2019            ("minimax-m2-7-highspeed", "MiniMax-M2.7-highspeed"),
2020            ("minimax-m2.1", "MiniMax-M2.1"),
2021            ("minimax-m2", "MiniMax-M2"),
2022        ] {
2023            let resolved = registry.resolve(Some(alias), Some(ProviderKind::Minimax));
2024
2025            assert_eq!(resolved.resolved.provider, ProviderKind::Minimax);
2026            assert_eq!(resolved.resolved.id, expected);
2027            assert!(!resolved.used_fallback);
2028            assert!(resolved.resolved.supports_tools);
2029            assert!(resolved.resolved.supports_reasoning);
2030        }
2031    }
2032
2033    #[test]
2034    fn minimax_anthropic_models_resolve_when_provider_hinted() {
2035        let registry = ModelRegistry::default();
2036
2037        for (alias, expected) in [
2038            ("minimax-anthropic", "MiniMax-M3"),
2039            ("minimax-m3", "MiniMax-M3"),
2040            ("minimax-m2.7", "MiniMax-M2.7"),
2041        ] {
2042            let resolved = registry.resolve(Some(alias), Some(ProviderKind::MinimaxAnthropic));
2043
2044            assert_eq!(resolved.resolved.provider, ProviderKind::MinimaxAnthropic);
2045            assert_eq!(resolved.resolved.id, expected);
2046            assert!(!resolved.used_fallback);
2047            assert!(resolved.resolved.supports_tools);
2048            assert!(resolved.resolved.supports_reasoning);
2049        }
2050    }
2051
2052    #[test]
2053    fn deepseek_v4_flash_alias_resolves_to_openrouter_when_provider_hinted() {
2054        let registry = ModelRegistry::default();
2055        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Openrouter));
2056
2057        assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
2058        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
2059    }
2060
2061    #[test]
2062    fn recent_openrouter_large_model_aliases_resolve_when_provider_hinted() {
2063        let registry = ModelRegistry::default();
2064
2065        for (alias, expected) in [
2066            ("trinity-large-thinking", "arcee-ai/trinity-large-thinking"),
2067            ("qwen3.6-flash", "qwen/qwen3.6-flash"),
2068            ("qwen3.6-35b-a3b", "qwen/qwen3.6-35b-a3b"),
2069            ("qwen3.6-max-preview", "qwen/qwen3.6-max-preview"),
2070            ("qwen3.6-plus", "qwen/qwen3.6-plus"),
2071            ("gemma-4-31b-it", "google/gemma-4-31b-it"),
2072            ("glm-5.1", "z-ai/glm-5.1"),
2073            ("glm-5.2", "z-ai/glm-5.2"),
2074            ("minimax-m3", "minimax/minimax-m3"),
2075            ("minimax-2.7", "minimax/minimax-m2.7"),
2076            ("openrouter-mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro"),
2077            ("openrouter-kimi-k2.7-code", "moonshotai/kimi-k2.7-code"),
2078            ("openrouter-kimi-k2.6", "moonshotai/kimi-k2.6"),
2079            ("nemotron-3-ultra", "nvidia/nemotron-3-ultra-550b-a55b"),
2080            (
2081                "nvidia/nemotron-3-ultra",
2082                "nvidia/nemotron-3-ultra-550b-a55b",
2083            ),
2084        ] {
2085            let resolved = registry.resolve(Some(alias), Some(ProviderKind::Openrouter));
2086
2087            assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
2088            assert_eq!(resolved.resolved.id, expected);
2089            assert!(resolved.resolved.supports_tools);
2090            assert!(resolved.resolved.supports_reasoning);
2091        }
2092    }
2093
2094    #[test]
2095    fn deepseek_v4_flash_alias_resolves_to_novita_when_provider_hinted() {
2096        let registry = ModelRegistry::default();
2097        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Novita));
2098
2099        assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
2100        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
2101    }
2102
2103    #[test]
2104    fn together_inkling_keeps_published_wire_identity() {
2105        let registry = ModelRegistry::default();
2106        for requested in ["thinkingmachines/inkling", "inkling", "together-inkling"] {
2107            let resolved = registry.resolve(Some(requested), Some(ProviderKind::Together));
2108
2109            assert_eq!(resolved.resolved.provider, ProviderKind::Together);
2110            assert_eq!(resolved.resolved.id, "thinkingmachines/inkling");
2111            assert!(resolved.resolved.supports_tools);
2112            assert!(resolved.resolved.supports_reasoning);
2113            assert!(!resolved.used_fallback);
2114        }
2115
2116        let unscoped = registry.resolve(Some("inkling"), None);
2117        assert_eq!(unscoped.resolved.provider, ProviderKind::Together);
2118        assert_eq!(unscoped.resolved.id, "thinkingmachines/inkling");
2119        assert!(!unscoped.used_fallback);
2120    }
2121
2122    #[test]
2123    fn registry_lists_and_resolves_every_v090_catalog_addition() {
2124        let registry = ModelRegistry::default();
2125        let advertised = [
2126            (ProviderKind::Anthropic, "claude-sonnet-5"),
2127            (ProviderKind::Anthropic, "claude-fable-5"),
2128            (ProviderKind::Openai, "gpt-5.3-codex"),
2129            (ProviderKind::Openai, "gpt-5.5"),
2130            (ProviderKind::Openai, "gpt-5.5-pro"),
2131            (ProviderKind::Openrouter, "qwen/qwen3.7-plus"),
2132            (ProviderKind::Arcee, "trinity-mini"),
2133        ];
2134
2135        let listed = registry.list();
2136        for (provider, model_id) in advertised {
2137            assert!(
2138                listed
2139                    .iter()
2140                    .any(|model| model.provider == provider && model.id == model_id),
2141                "missing {model_id} ({}) from model list",
2142                provider.as_str()
2143            );
2144            let resolved = registry.resolve(Some(model_id), Some(provider));
2145            assert_eq!(resolved.resolved.provider, provider, "{model_id}");
2146            assert_eq!(resolved.resolved.id, model_id, "{model_id}");
2147            assert!(!resolved.used_fallback, "{model_id}");
2148        }
2149    }
2150
2151    #[test]
2152    fn gpt_55_stays_provider_scoped_between_openai_and_codex() {
2153        let registry = ModelRegistry::default();
2154
2155        let unscoped = registry.resolve(Some("gpt-5.5"), None);
2156        assert_eq!(unscoped.resolved.provider, ProviderKind::Openai);
2157        assert_eq!(unscoped.resolved.id, "gpt-5.5");
2158        assert!(!unscoped.used_fallback);
2159
2160        let codex = registry.resolve(Some("gpt-5.5"), Some(ProviderKind::OpenaiCodex));
2161        assert_eq!(codex.resolved.provider, ProviderKind::OpenaiCodex);
2162        assert_eq!(codex.resolved.id, "gpt-5.5");
2163        assert!(!codex.used_fallback);
2164    }
2165
2166    #[test]
2167    fn deepseek_v4_flash_alias_resolves_to_sglang_when_provider_hinted() {
2168        let registry = ModelRegistry::default();
2169        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Sglang));
2170
2171        assert_eq!(resolved.resolved.provider, ProviderKind::Sglang);
2172        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
2173    }
2174
2175    #[test]
2176    fn vllm_default_uses_canonical_model_id() {
2177        let registry = ModelRegistry::default();
2178        let resolved = registry.resolve(None, Some(ProviderKind::Vllm));
2179
2180        assert_eq!(resolved.resolved.provider, ProviderKind::Vllm);
2181        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
2182    }
2183
2184    #[test]
2185    fn ollama_default_uses_small_local_model_id() {
2186        let registry = ModelRegistry::default();
2187        let resolved = registry.resolve(None, Some(ProviderKind::Ollama));
2188
2189        assert_eq!(resolved.resolved.provider, ProviderKind::Ollama);
2190        assert_eq!(resolved.resolved.id, "deepseek-v4-flash");
2191        assert!(resolved.resolved.supports_reasoning);
2192    }
2193
2194    #[test]
2195    fn ollama_requested_model_tag_is_preserved() {
2196        let registry = ModelRegistry::default();
2197        let resolved = registry.resolve(Some("qwen2.5-coder:7b"), Some(ProviderKind::Ollama));
2198
2199        assert_eq!(resolved.resolved.provider, ProviderKind::Ollama);
2200        assert_eq!(resolved.resolved.id, "qwen2.5-coder:7b");
2201        assert!(!resolved.used_fallback);
2202    }
2203
2204    #[test]
2205    fn deepseek_v4_flash_alias_resolves_to_vllm_when_provider_hinted() {
2206        let registry = ModelRegistry::default();
2207        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Vllm));
2208
2209        assert_eq!(resolved.resolved.provider, ProviderKind::Vllm);
2210        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
2211    }
2212
2213    #[test]
2214    fn preserves_requested_model_casing_for_third_party_providers() {
2215        let registry = ModelRegistry::default();
2216        let resolved = registry.resolve(Some("DeepSeek-V4-Pro"), None);
2217
2218        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
2219        assert_eq!(resolved.resolved.id, "DeepSeek-V4-Pro");
2220    }
2221
2222    #[test]
2223    fn registry_casing_takes_priority_over_requested_casing_with_provider_hint() {
2224        let registry = ModelRegistry::default();
2225        let resolved = registry.resolve(Some("DeepSeek-V4-Pro"), Some(ProviderKind::Deepseek));
2226
2227        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
2228        // Registry's canonical id is used even when user provides different casing
2229        assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
2230    }
2231
2232    #[test]
2233    fn preserves_requested_model_casing_without_surrounding_whitespace() {
2234        let registry = ModelRegistry::default();
2235        let resolved = registry.resolve(Some("  DeepSeek-V4-Pro  "), None);
2236
2237        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
2238        assert_eq!(resolved.resolved.id, "DeepSeek-V4-Pro");
2239    }
2240
2241    #[test]
2242    fn alias_match_does_not_override_requested_casing() {
2243        let registry = ModelRegistry::default();
2244        let resolved = registry.resolve(Some("deepseek-reasoner"), None);
2245
2246        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
2247        assert_eq!(resolved.resolved.id, "deepseek-v4-flash");
2248    }
2249
2250    #[test]
2251    fn model_family_classifies_known_model_ids() {
2252        assert_eq!(model_family("deepseek-v4-pro"), ModelFamily::DeepSeek);
2253        assert_eq!(model_family("openai/gpt-5.4"), ModelFamily::OpenAI);
2254        assert_eq!(
2255            model_family("anthropic/claude-opus-4-7"),
2256            ModelFamily::Anthropic
2257        );
2258        assert_eq!(
2259            model_family("meta-llama/llama-3.3-70b-instruct"),
2260            ModelFamily::Meta
2261        );
2262        assert_eq!(model_family("Qwen/Qwen3-Coder"), ModelFamily::Qwen);
2263    }
2264
2265    #[test]
2266    fn model_family_uses_underlying_model_for_router_ids() {
2267        assert_eq!(
2268            model_family("groq/llama-3.3-70b-versatile"),
2269            ModelFamily::Meta
2270        );
2271        assert_eq!(
2272            model_family("openrouter/openai/gpt-5.4"),
2273            ModelFamily::OpenAI
2274        );
2275        assert_eq!(
2276            model_family("fireworks/accounts/fireworks/models/deepseek-v4-pro"),
2277            ModelFamily::DeepSeek
2278        );
2279    }
2280
2281    #[test]
2282    fn model_family_covers_prominent_google_and_mistral_model_names() {
2283        assert_eq!(model_family("google/gemma-3-27b-it"), ModelFamily::Google);
2284        assert_eq!(
2285            model_family("mistralai/mixtral-8x22b"),
2286            ModelFamily::Mistral
2287        );
2288        assert_eq!(model_family("codestral-latest"), ModelFamily::Mistral);
2289    }
2290
2291    #[test]
2292    fn model_family_falls_back_to_inferencer_for_unknown_models() {
2293        assert_eq!(
2294            model_family("custom-gateway/my-private-model"),
2295            ModelFamily::Inferencer
2296        );
2297        assert_eq!(model_family(""), ModelFamily::Inferencer);
2298    }
2299}