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