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        ];
1172        Self::new(models)
1173    }
1174}
1175
1176impl ModelRegistry {
1177    /// Creates a new registry from a list of [`ModelInfo`] entries.
1178    ///
1179    /// Builds an internal alias map for fast lookup by model id or alias.
1180    /// If multiple models share the same id or alias, the first one registered
1181    /// takes priority.
1182    #[must_use]
1183    pub fn new(models: Vec<ModelInfo>) -> Self {
1184        let mut alias_map = HashMap::new();
1185        for (idx, model) in models.iter().enumerate() {
1186            alias_map.entry(normalize(&model.id)).or_insert(idx);
1187            for alias in &model.aliases {
1188                alias_map.entry(normalize(alias)).or_insert(idx);
1189            }
1190        }
1191        Self { models, alias_map }
1192    }
1193
1194    /// Returns a clone of all models in the registry.
1195    #[must_use]
1196    pub fn list(&self) -> Vec<ModelInfo> {
1197        self.models.clone()
1198    }
1199
1200    /// Resolves a user-requested model name to a concrete [`ModelInfo`].
1201    ///
1202    /// Resolution follows this priority order:
1203    /// 1. If the provider is Ollama, the requested name is used as-is (to
1204    ///    support arbitrary local model tags like `qwen2.5-coder:7b`).
1205    /// 2. If a `provider_hint` is given, search for a model matching that
1206    ///    provider whose id or alias matches the request (case-insensitive).
1207    /// 3. Look up the alias map for a case-insensitive match.
1208    /// 4. Fall back to the first model belonging to the hinted provider
1209    ///    (or DeepSeek if no hint was given).
1210    /// 5. As a last resort, fall back to the first model in the registry.
1211    #[must_use]
1212    pub fn resolve(
1213        &self,
1214        requested: Option<&str>,
1215        provider_hint: Option<ProviderKind>,
1216    ) -> ModelResolution {
1217        let mut fallback_chain = Vec::new();
1218
1219        if let Some(name) = requested {
1220            fallback_chain.push(format!("requested:{name}"));
1221            if provider_hint == Some(ProviderKind::Ollama) {
1222                return ModelResolution {
1223                    requested: Some(name.to_string()),
1224                    resolved: ModelInfo {
1225                        id: name.trim().to_string(),
1226                        provider: ProviderKind::Ollama,
1227                        aliases: Vec::new(),
1228                        supports_tools: true,
1229                        supports_reasoning: false,
1230                    },
1231                    used_fallback: false,
1232                    fallback_chain,
1233                };
1234            }
1235            // OpenCode Go's catalog spans Chat Completions and Anthropic
1236            // Messages, while Codewhale's provider slice intentionally speaks
1237            // Chat only. Resolve a hinted Go model through the shared Chat
1238            // allowlist and never fall through to a same-named global alias on
1239            // OpenRouter or MiniMax.
1240            if provider_hint == Some(ProviderKind::OpencodeGo)
1241                && let Some(canonical) = opencode_go_chat_model_id(name)
1242                && let Some(model) = self
1243                    .models
1244                    .iter()
1245                    .find(|model| {
1246                        model.provider == ProviderKind::OpencodeGo
1247                            && model.id.eq_ignore_ascii_case(canonical)
1248                    })
1249                    .cloned()
1250            {
1251                return ModelResolution {
1252                    requested: Some(name.to_string()),
1253                    resolved: model,
1254                    used_fallback: false,
1255                    fallback_chain,
1256                };
1257            }
1258            if provider_hint != Some(ProviderKind::OpencodeGo)
1259                && let Some(provider) = provider_hint
1260                && let Some(model) = self
1261                    .models
1262                    .iter()
1263                    .find(|m| m.provider == provider && model_matches(m, name))
1264                    .cloned()
1265            {
1266                return ModelResolution {
1267                    requested: Some(name.to_string()),
1268                    resolved: model,
1269                    used_fallback: false,
1270                    fallback_chain,
1271                };
1272            }
1273            if provider_hint == Some(ProviderKind::Atlascloud)
1274                && let Some(model) = atlascloud_passthrough_model(name)
1275            {
1276                return ModelResolution {
1277                    requested: Some(name.to_string()),
1278                    resolved: model,
1279                    used_fallback: false,
1280                    fallback_chain,
1281                };
1282            }
1283            if provider_hint == Some(ProviderKind::Arcee)
1284                && let Some(model) = arcee_passthrough_model(name)
1285            {
1286                return ModelResolution {
1287                    requested: Some(name.to_string()),
1288                    resolved: model,
1289                    used_fallback: false,
1290                    fallback_chain,
1291                };
1292            }
1293            if provider_hint == Some(ProviderKind::XiaomiMimo)
1294                && let Some(model) = xiaomi_mimo_passthrough_model(name)
1295            {
1296                return ModelResolution {
1297                    requested: Some(name.to_string()),
1298                    resolved: model,
1299                    used_fallback: false,
1300                    fallback_chain,
1301                };
1302            }
1303            // The global alias map is a *provider-less* convenience lookup. It
1304            // must never answer a provider-scoped question with another
1305            // vendor's model: before this fix, `--provider moonshot` asking for
1306            // `kimi-k3` was answered with OpenCode Go's `kimi-k3` because the
1307            // hinted provider had no such id. A hinted request that the hinted
1308            // provider cannot serve falls through to that provider's default
1309            // with `used_fallback: true`, which callers already surface.
1310            if provider_hint != Some(ProviderKind::OpencodeGo)
1311                && let Some(idx) = self.alias_map.get(&normalize(name))
1312                && provider_hint.is_none_or(|hint| self.models[*idx].provider == hint)
1313            {
1314                return ModelResolution {
1315                    requested: Some(name.to_string()),
1316                    resolved: preserve_requested_model_id_case(self.models[*idx].clone(), name),
1317                    used_fallback: false,
1318                    fallback_chain,
1319                };
1320            }
1321        }
1322
1323        let provider = provider_hint.unwrap_or(ProviderKind::Deepseek);
1324        fallback_chain.push(format!("provider_default:{}", provider.as_str()));
1325        if let Some(model) = self.models.iter().find(|m| m.provider == provider).cloned() {
1326            return ModelResolution {
1327                requested: requested.map(ToOwned::to_owned),
1328                resolved: model,
1329                used_fallback: true,
1330                fallback_chain,
1331            };
1332        }
1333
1334        let final_fallback = self.models.first().cloned().unwrap_or(ModelInfo {
1335            id: "deepseek-v4-pro".to_string(),
1336            provider: ProviderKind::Deepseek,
1337            aliases: Vec::new(),
1338            supports_tools: true,
1339            supports_reasoning: true,
1340        });
1341        fallback_chain.push("global_default:deepseek-v4-pro".to_string());
1342        ModelResolution {
1343            requested: requested.map(ToOwned::to_owned),
1344            resolved: final_fallback,
1345            used_fallback: true,
1346            fallback_chain,
1347        }
1348    }
1349}
1350
1351fn normalize(value: &str) -> String {
1352    value.trim().to_ascii_lowercase()
1353}
1354
1355#[must_use]
1356/// Classify a model identifier by its underlying model family.
1357pub fn model_family(model_id: &str) -> ModelFamily {
1358    let normalized = normalize(model_id);
1359    if normalized.is_empty() {
1360        return ModelFamily::Inferencer;
1361    }
1362
1363    if normalized.contains("deepseek") {
1364        return ModelFamily::DeepSeek;
1365    }
1366    if normalized.contains("claude") || normalized.contains("anthropic") {
1367        return ModelFamily::Anthropic;
1368    }
1369    if normalized.contains("gpt-oss") || normalized.contains("gpt_oss") {
1370        return ModelFamily::GptOss;
1371    }
1372    if normalized.starts_with("gpt-")
1373        || normalized.contains("/gpt-")
1374        || normalized.contains("openai/")
1375    {
1376        return ModelFamily::OpenAI;
1377    }
1378    if normalized.contains("gemini")
1379        || normalized.contains("gemma")
1380        || normalized.contains("google/")
1381    {
1382        return ModelFamily::Google;
1383    }
1384    if normalized.contains("llama")
1385        || normalized.contains("muse-spark")
1386        || normalized.contains("meta-")
1387        || normalized.contains("meta/")
1388    {
1389        return ModelFamily::Meta;
1390    }
1391    if normalized.contains("mistral")
1392        || normalized.contains("mixtral")
1393        || normalized.contains("codestral")
1394    {
1395        return ModelFamily::Mistral;
1396    }
1397    if normalized.contains("qwen") {
1398        return ModelFamily::Qwen;
1399    }
1400    if normalized.contains("grok") {
1401        return ModelFamily::Grok;
1402    }
1403    if normalized.contains("cohere") || normalized.contains("command-r") {
1404        return ModelFamily::Cohere;
1405    }
1406
1407    ModelFamily::Inferencer
1408}
1409
1410fn model_matches(model: &ModelInfo, requested: &str) -> bool {
1411    let requested = normalize(requested);
1412    normalize(&model.id) == requested
1413        || model
1414            .aliases
1415            .iter()
1416            .any(|alias| normalize(alias) == requested)
1417}
1418
1419fn preserve_requested_model_id_case(mut model: ModelInfo, requested: &str) -> ModelInfo {
1420    let requested = requested.trim();
1421    if model.id.eq_ignore_ascii_case(requested) {
1422        model.id = requested.to_string();
1423    }
1424    model
1425}
1426
1427fn atlascloud_passthrough_model(requested: &str) -> Option<ModelInfo> {
1428    let requested = requested.trim();
1429    if requested.is_empty() || !requested.contains('/') {
1430        return None;
1431    }
1432
1433    Some(ModelInfo {
1434        id: requested.to_string(),
1435        provider: ProviderKind::Atlascloud,
1436        aliases: Vec::new(),
1437        supports_tools: true,
1438        supports_reasoning: true,
1439    })
1440}
1441
1442fn arcee_passthrough_model(requested: &str) -> Option<ModelInfo> {
1443    let requested = requested.trim();
1444    if requested.is_empty() {
1445        return None;
1446    }
1447    let supports_reasoning = requested.to_ascii_lowercase().contains("thinking");
1448
1449    Some(ModelInfo {
1450        id: requested.to_string(),
1451        provider: ProviderKind::Arcee,
1452        aliases: Vec::new(),
1453        supports_tools: true,
1454        supports_reasoning,
1455    })
1456}
1457
1458fn xiaomi_mimo_passthrough_model(requested: &str) -> Option<ModelInfo> {
1459    let requested = requested.trim();
1460    if requested.is_empty() || requested.chars().any(char::is_control) {
1461        return None;
1462    }
1463
1464    Some(ModelInfo {
1465        id: requested.to_string(),
1466        provider: ProviderKind::XiaomiMimo,
1467        aliases: Vec::new(),
1468        supports_tools: true,
1469        supports_reasoning: true,
1470    })
1471}
1472
1473#[cfg(test)]
1474mod tests {
1475    use super::*;
1476
1477    #[test]
1478    fn model_registry_new_builds_alias_map_correctly() {
1479        let models = vec![
1480            ModelInfo {
1481                id: "Model-A".to_string(),
1482                provider: ProviderKind::Deepseek,
1483                aliases: vec!["alias-1".to_string(), " ALIAS-2 ".to_string()],
1484                supports_tools: true,
1485                supports_reasoning: false,
1486            },
1487            ModelInfo {
1488                id: "model-b".to_string(),
1489                provider: ProviderKind::Deepseek,
1490                aliases: vec!["alias-1".to_string()], // Duplicate alias, should not override
1491                supports_tools: true,
1492                supports_reasoning: true,
1493            },
1494        ];
1495
1496        let registry = ModelRegistry::new(models);
1497
1498        assert_eq!(registry.alias_map.len(), 4); // "model-a", "alias-1", "alias-2", "model-b"
1499        assert_eq!(registry.alias_map.get("model-a"), Some(&0));
1500        assert_eq!(registry.alias_map.get("alias-1"), Some(&0)); // First one wins
1501        assert_eq!(registry.alias_map.get("alias-2"), Some(&0)); // Normalized
1502        assert_eq!(registry.alias_map.get("model-b"), Some(&1));
1503    }
1504
1505    #[test]
1506    fn deepseek_v4_pro_alias_stays_deepseek_by_default() {
1507        let registry = ModelRegistry::default();
1508        let resolved = registry.resolve(Some("deepseek-v4-pro"), None);
1509
1510        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
1511        assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
1512    }
1513
1514    #[test]
1515    fn deepseek_v4_pro_alias_resolves_to_nvidia_nim_when_provider_hinted() {
1516        let registry = ModelRegistry::default();
1517        let resolved = registry.resolve(Some("deepseek-v4-pro"), Some(ProviderKind::NvidiaNim));
1518
1519        assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
1520        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
1521    }
1522
1523    #[test]
1524    fn nvidia_nim_default_uses_catalog_model_id() {
1525        let registry = ModelRegistry::default();
1526        let resolved = registry.resolve(None, Some(ProviderKind::NvidiaNim));
1527
1528        assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
1529        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
1530    }
1531
1532    #[test]
1533    fn deepseek_v4_flash_alias_resolves_to_nvidia_nim_when_provider_hinted() {
1534        let registry = ModelRegistry::default();
1535        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::NvidiaNim));
1536
1537        assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
1538        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
1539    }
1540
1541    #[test]
1542    fn atlascloud_default_uses_namespaced_model_id() {
1543        let registry = ModelRegistry::default();
1544        let resolved = registry.resolve(None, Some(ProviderKind::Atlascloud));
1545
1546        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1547        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
1548        assert!(resolved.resolved.supports_reasoning);
1549    }
1550
1551    #[test]
1552    fn deepseek_v4_flash_alias_resolves_to_atlascloud_when_provider_hinted() {
1553        let registry = ModelRegistry::default();
1554        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Atlascloud));
1555
1556        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1557        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
1558    }
1559
1560    #[test]
1561    fn deepseek_v4_pro_alias_resolves_to_atlascloud_when_provider_hinted() {
1562        let registry = ModelRegistry::default();
1563        let resolved = registry.resolve(Some("deepseek-v4-pro"), Some(ProviderKind::Atlascloud));
1564
1565        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1566        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
1567    }
1568
1569    #[test]
1570    fn atlascloud_provider_hint_passes_through_explicit_model_id() {
1571        let registry = ModelRegistry::default();
1572        let resolved =
1573            registry.resolve(Some("openai/gpt-5.2-chat"), Some(ProviderKind::Atlascloud));
1574
1575        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1576        assert_eq!(resolved.resolved.id, "openai/gpt-5.2-chat");
1577        assert!(resolved.resolved.supports_tools);
1578        assert!(resolved.resolved.supports_reasoning);
1579        assert!(!resolved.used_fallback);
1580    }
1581
1582    #[test]
1583    fn atlascloud_provider_hint_preserves_explicit_model_id_case() {
1584        let registry = ModelRegistry::default();
1585        let resolved = registry.resolve(Some("Qwen/Qwen3-Coder"), Some(ProviderKind::Atlascloud));
1586
1587        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1588        assert_eq!(resolved.resolved.id, "Qwen/Qwen3-Coder");
1589        assert!(!resolved.used_fallback);
1590    }
1591
1592    #[test]
1593    fn atlascloud_plain_unknown_model_still_uses_provider_default() {
1594        let registry = ModelRegistry::default();
1595        let resolved = registry.resolve(Some("not-in-atlas"), Some(ProviderKind::Atlascloud));
1596
1597        assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
1598        assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
1599        assert!(resolved.used_fallback);
1600    }
1601
1602    #[test]
1603    fn openrouter_default_uses_namespaced_model_id() {
1604        let registry = ModelRegistry::default();
1605        let resolved = registry.resolve(None, Some(ProviderKind::Openrouter));
1606
1607        assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
1608        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
1609    }
1610
1611    #[test]
1612    fn xiaomi_mimo_default_uses_canonical_model_id() {
1613        let registry = ModelRegistry::default();
1614        let resolved = registry.resolve(None, Some(ProviderKind::XiaomiMimo));
1615
1616        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1617        assert_eq!(resolved.resolved.id, "mimo-v2.5-pro");
1618        assert!(resolved.resolved.supports_reasoning);
1619    }
1620
1621    #[test]
1622    fn moonshot_default_and_aliases_use_kimi_k27_code() {
1623        let registry = ModelRegistry::default();
1624
1625        for requested in [None, Some("kimi"), Some("kimi-k2.7-code")] {
1626            let resolved = registry.resolve(requested, Some(ProviderKind::Moonshot));
1627
1628            assert_eq!(resolved.resolved.provider, ProviderKind::Moonshot);
1629            assert_eq!(resolved.resolved.id, "kimi-k2.7-code");
1630            assert!(resolved.resolved.supports_tools);
1631            assert!(resolved.resolved.supports_reasoning);
1632        }
1633    }
1634
1635    #[test]
1636    fn moonshot_explicit_kimi_k26_remains_available() {
1637        let registry = ModelRegistry::default();
1638        let resolved = registry.resolve(Some("kimi-k2.6"), Some(ProviderKind::Moonshot));
1639
1640        assert_eq!(resolved.resolved.provider, ProviderKind::Moonshot);
1641        assert_eq!(resolved.resolved.id, "kimi-k2.6");
1642        assert!(resolved.resolved.supports_reasoning);
1643    }
1644
1645    /// v0.9.1 dogfood report: a user ran `--provider moonshot --model kimi-k3` and was told
1646    /// the model was `kimi-k2.7-code`. The registry knew neither Moonshot K3
1647    /// product, so the explicit request fell through to the provider default.
1648    #[test]
1649    fn moonshot_resolves_both_k3_products_without_crossing_them() {
1650        let registry = ModelRegistry::default();
1651
1652        for (requested, expected) in [("kimi-k3", "kimi-k3"), ("k3", "k3")] {
1653            let resolved = registry.resolve(Some(requested), Some(ProviderKind::Moonshot));
1654
1655            assert_eq!(resolved.resolved.provider, ProviderKind::Moonshot);
1656            assert_eq!(resolved.resolved.id, expected, "{resolved:?}");
1657            assert!(
1658                !resolved.used_fallback,
1659                "an explicit Moonshot K3 request is not a fallback: {resolved:?}"
1660            );
1661        }
1662    }
1663
1664    /// The bare `k3` id belongs to the Kimi Code coding-plan endpoint and
1665    /// `kimi-k3` to the direct platform endpoint. Neither may be laundered
1666    /// into the other's id by alias expansion.
1667    #[test]
1668    fn moonshot_k3_ids_are_never_rewritten_into_each_other() {
1669        let registry = ModelRegistry::default();
1670
1671        assert_eq!(
1672            registry
1673                .resolve(Some("kimi-k3"), Some(ProviderKind::Moonshot))
1674                .resolved
1675                .id,
1676            "kimi-k3"
1677        );
1678        assert_eq!(
1679            registry
1680                .resolve(Some("k3"), Some(ProviderKind::Moonshot))
1681                .resolved
1682                .id,
1683            "k3"
1684        );
1685    }
1686
1687    /// A provider-scoped question must never be answered with another
1688    /// vendor's model. `kimi-k3` also exists in the OpenCode Go catalog;
1689    /// before this fix that entry answered `--provider moonshot` requests.
1690    #[test]
1691    fn a_provider_hint_never_resolves_to_another_providers_model() {
1692        let registry = ModelRegistry::default();
1693
1694        let resolved = registry.resolve(Some("glm-5.2"), Some(ProviderKind::Moonshot));
1695        assert_eq!(
1696            resolved.resolved.provider,
1697            ProviderKind::Moonshot,
1698            "a Moonshot request must not be answered by Z.ai: {resolved:?}"
1699        );
1700        assert!(
1701            resolved.used_fallback,
1702            "an unservable id must be reported as a fallback, not as the request: {resolved:?}"
1703        );
1704
1705        let go = registry.resolve(Some("kimi-k3"), Some(ProviderKind::OpencodeGo));
1706        assert_eq!(go.resolved.provider, ProviderKind::OpencodeGo);
1707        assert_eq!(go.resolved.id, "kimi-k3");
1708    }
1709
1710    #[test]
1711    fn xiaomi_mimo_tts_aliases_resolve_when_provider_hinted() {
1712        let registry = ModelRegistry::default();
1713        let resolved = registry.resolve(Some("tts"), Some(ProviderKind::XiaomiMimo));
1714        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1715        assert_eq!(resolved.resolved.id, "mimo-v2.5-tts");
1716        assert!(!resolved.resolved.supports_tools);
1717        assert!(!resolved.resolved.supports_reasoning);
1718
1719        let resolved = registry.resolve(Some("voice-design"), Some(ProviderKind::XiaomiMimo));
1720        assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voicedesign");
1721
1722        let resolved = registry.resolve(Some("voiceclone"), Some(ProviderKind::XiaomiMimo));
1723        assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voiceclone");
1724    }
1725
1726    #[test]
1727    fn xiaomi_mimo_chat_aliases_resolve_when_provider_hinted() {
1728        let registry = ModelRegistry::default();
1729
1730        let resolved = registry.resolve(Some("omni"), Some(ProviderKind::XiaomiMimo));
1731        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1732        assert_eq!(resolved.resolved.id, "mimo-v2.5");
1733        assert!(resolved.resolved.supports_tools);
1734    }
1735
1736    #[test]
1737    fn xiaomi_mimo_provider_hint_preserves_custom_model_id() {
1738        let registry = ModelRegistry::default();
1739        let resolved =
1740            registry.resolve(Some("account-custom-mimo"), Some(ProviderKind::XiaomiMimo));
1741
1742        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1743        assert_eq!(resolved.resolved.id, "account-custom-mimo");
1744        assert!(!resolved.used_fallback);
1745    }
1746
1747    #[test]
1748    fn xiaomi_mimo_provider_hint_does_not_reclassify_openrouter_model_id() {
1749        let registry = ModelRegistry::default();
1750        let resolved = registry.resolve(
1751            Some("deepseek/deepseek-v4-pro"),
1752            Some(ProviderKind::XiaomiMimo),
1753        );
1754
1755        assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
1756        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
1757        assert!(!resolved.used_fallback);
1758    }
1759
1760    #[test]
1761    fn wanjie_ark_default_uses_reasoner_model_id() {
1762        let registry = ModelRegistry::default();
1763        let resolved = registry.resolve(None, Some(ProviderKind::WanjieArk));
1764
1765        assert_eq!(resolved.resolved.provider, ProviderKind::WanjieArk);
1766        assert_eq!(resolved.resolved.id, "deepseek-reasoner");
1767        assert!(resolved.resolved.supports_reasoning);
1768    }
1769
1770    #[test]
1771    fn novita_default_uses_namespaced_model_id() {
1772        let registry = ModelRegistry::default();
1773        let resolved = registry.resolve(None, Some(ProviderKind::Novita));
1774
1775        assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
1776        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
1777    }
1778
1779    #[test]
1780    fn fireworks_default_uses_canonical_model_id() {
1781        let registry = ModelRegistry::default();
1782        let resolved = registry.resolve(None, Some(ProviderKind::Fireworks));
1783
1784        assert_eq!(resolved.resolved.provider, ProviderKind::Fireworks);
1785        assert_eq!(
1786            resolved.resolved.id,
1787            "accounts/fireworks/models/deepseek-v4-pro"
1788        );
1789    }
1790
1791    #[test]
1792    fn siliconflow_default_uses_canonical_pro_model_id() {
1793        let registry = ModelRegistry::default();
1794        let resolved = registry.resolve(None, Some(ProviderKind::Siliconflow));
1795
1796        assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
1797        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1798        assert!(resolved.resolved.supports_reasoning);
1799    }
1800
1801    #[test]
1802    fn arcee_default_uses_direct_trinity_large_thinking_model_id() {
1803        let registry = ModelRegistry::default();
1804        let resolved = registry.resolve(None, Some(ProviderKind::Arcee));
1805
1806        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1807        assert_eq!(resolved.resolved.id, "trinity-large-thinking");
1808        assert!(resolved.resolved.supports_reasoning);
1809    }
1810
1811    #[test]
1812    fn arcee_trinity_alias_resolves_to_direct_large_thinking_not_openrouter() {
1813        let registry = ModelRegistry::default();
1814        let resolved = registry.resolve(Some("trinity"), Some(ProviderKind::Arcee));
1815
1816        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1817        assert_eq!(resolved.resolved.id, "trinity-large-thinking");
1818        assert!(resolved.resolved.supports_reasoning);
1819    }
1820
1821    #[test]
1822    fn arcee_trinity_mini_remains_explicit_compatibility_model() {
1823        let registry = ModelRegistry::default();
1824        let resolved = registry.resolve(Some("trinity-mini"), Some(ProviderKind::Arcee));
1825
1826        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1827        assert_eq!(resolved.resolved.id, "trinity-mini");
1828        assert!(resolved.resolved.supports_reasoning);
1829        assert!(!resolved.used_fallback);
1830    }
1831
1832    #[test]
1833    fn arcee_provider_hint_preserves_explicit_future_model_id() {
1834        let registry = ModelRegistry::default();
1835        let resolved = registry.resolve(Some("trinity-large-next"), Some(ProviderKind::Arcee));
1836
1837        assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1838        assert_eq!(resolved.resolved.id, "trinity-large-next");
1839        assert!(!resolved.resolved.supports_reasoning);
1840        assert!(!resolved.used_fallback);
1841    }
1842
1843    #[test]
1844    fn deepseek_reasoner_alias_resolves_to_siliconflow_pro_when_provider_hinted() {
1845        let registry = ModelRegistry::default();
1846        let resolved = registry.resolve(Some("deepseek-reasoner"), Some(ProviderKind::Siliconflow));
1847
1848        assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
1849        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1850    }
1851
1852    #[test]
1853    fn deepseek_v4_flash_alias_resolves_to_siliconflow_flash_when_provider_hinted() {
1854        let registry = ModelRegistry::default();
1855        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Siliconflow));
1856
1857        assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
1858        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
1859    }
1860
1861    #[test]
1862    fn sglang_default_uses_canonical_model_id() {
1863        let registry = ModelRegistry::default();
1864        let resolved = registry.resolve(None, Some(ProviderKind::Sglang));
1865
1866        assert_eq!(resolved.resolved.provider, ProviderKind::Sglang);
1867        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1868    }
1869
1870    #[test]
1871    fn zai_direct_models_resolve_when_provider_hinted() {
1872        let registry = ModelRegistry::default();
1873
1874        // GLM-5.2 is now the default direct Z.AI model.
1875        let default = registry.resolve(None, Some(ProviderKind::Zai));
1876        assert_eq!(default.resolved.provider, ProviderKind::Zai);
1877        assert_eq!(default.resolved.id, "GLM-5.2");
1878
1879        for (alias, expected) in [
1880            ("GLM-5.1", "GLM-5.1"),
1881            ("glm-5-1", "GLM-5.1"),
1882            ("GLM-5.2", "GLM-5.2"),
1883            ("glm-5.2", "GLM-5.2"),
1884            ("zai-glm-5-2", "GLM-5.2"),
1885            ("GLM-5.3", "GLM-5.3"),
1886            ("glm-5.3", "GLM-5.3"),
1887            ("glm-5-3", "GLM-5.3"),
1888            ("zai-glm-5-3", "GLM-5.3"),
1889            ("GLM-5-Turbo", "GLM-5-Turbo"),
1890            ("glm-5-turbo", "GLM-5-Turbo"),
1891            ("zai-glm-5-turbo", "GLM-5-Turbo"),
1892        ] {
1893            let resolved = registry.resolve(Some(alias), Some(ProviderKind::Zai));
1894
1895            assert_eq!(resolved.resolved.provider, ProviderKind::Zai);
1896            assert_eq!(resolved.resolved.id, expected);
1897            assert!(!resolved.used_fallback);
1898            assert!(resolved.resolved.supports_tools);
1899            assert!(resolved.resolved.supports_reasoning);
1900        }
1901    }
1902
1903    #[test]
1904    fn first_party_recent_provider_models_are_listed() {
1905        let registry = ModelRegistry::default();
1906        let models = registry.list();
1907
1908        for (provider, id) in [
1909            (ProviderKind::Zai, "GLM-5.2"),
1910            (ProviderKind::Stepfun, "step-3.7-flash"),
1911            (ProviderKind::Minimax, "MiniMax-M2.1"),
1912            (ProviderKind::MinimaxAnthropic, "MiniMax-M3"),
1913            (ProviderKind::Openmodel, "deepseek-v4-flash"),
1914            (ProviderKind::Meta, "muse-spark-1.2"),
1915            (ProviderKind::Xai, "grok-4.5"),
1916        ] {
1917            assert!(
1918                models
1919                    .iter()
1920                    .any(|model| model.provider == provider && model.id == id),
1921                "expected {provider:?} model {id} in registry"
1922            );
1923        }
1924    }
1925
1926    #[test]
1927    fn opencode_go_lists_only_current_chat_completions_models() {
1928        let registry = ModelRegistry::default();
1929        let listed = registry.list();
1930        let models: Vec<&str> = listed
1931            .iter()
1932            .filter(|model| model.provider == ProviderKind::OpencodeGo)
1933            .map(|model| model.id.as_str())
1934            .collect();
1935
1936        assert_eq!(
1937            models,
1938            vec![
1939                "deepseek-v4-pro",
1940                "grok-4.5",
1941                "glm-5.2",
1942                "glm-5.1",
1943                "kimi-k3",
1944                "kimi-k2.7-code",
1945                "kimi-k2.6",
1946                "deepseek-v4-flash",
1947                "mimo-v2.5",
1948                "mimo-v2.5-pro",
1949            ]
1950        );
1951
1952        let default = registry.resolve(None, Some(ProviderKind::OpencodeGo));
1953        assert_eq!(default.resolved.provider, ProviderKind::OpencodeGo);
1954        assert_eq!(default.resolved.id, "deepseek-v4-pro");
1955
1956        for model in ["grok-4.5", "kimi-k3"] {
1957            for requested in [model.to_string(), format!("opencode-go/{model}")] {
1958                let resolved = registry.resolve(Some(&requested), Some(ProviderKind::OpencodeGo));
1959                assert_eq!(resolved.resolved.provider, ProviderKind::OpencodeGo);
1960                assert_eq!(resolved.resolved.id, model);
1961                assert!(!resolved.used_fallback);
1962            }
1963        }
1964
1965        for messages_only in [
1966            "minimax-m3",
1967            "minimax-m2.7",
1968            "minimax-m2.5",
1969            "qwen3.7-max",
1970            "qwen3.7-plus",
1971            "qwen3.6-plus",
1972        ] {
1973            for requested in [
1974                messages_only.to_string(),
1975                format!("opencode-go/{messages_only}"),
1976            ] {
1977                let rejected = registry.resolve(Some(&requested), Some(ProviderKind::OpencodeGo));
1978                assert!(rejected.used_fallback, "{requested}");
1979                assert_eq!(
1980                    rejected.resolved.provider,
1981                    ProviderKind::OpencodeGo,
1982                    "{requested} must not cross-route"
1983                );
1984                assert_eq!(rejected.resolved.id, "deepseek-v4-pro", "{requested}");
1985            }
1986        }
1987    }
1988
1989    #[test]
1990    fn xai_grok_models_resolve_when_provider_hinted() {
1991        let registry = ModelRegistry::default();
1992
1993        let default = registry.resolve(None, Some(ProviderKind::Xai));
1994        assert_eq!(default.resolved.provider, ProviderKind::Xai);
1995        assert_eq!(default.resolved.id, "grok-4.5");
1996        assert!(default.used_fallback);
1997
1998        let alias = registry.resolve(Some("grok"), Some(ProviderKind::Xai));
1999        assert_eq!(alias.resolved.provider, ProviderKind::Xai);
2000        assert_eq!(alias.resolved.id, "grok-4.5");
2001        assert!(!alias.used_fallback);
2002
2003        let fast = registry.resolve(
2004            Some("grok-4.20-0309-non-reasoning"),
2005            Some(ProviderKind::Xai),
2006        );
2007        assert_eq!(fast.resolved.provider, ProviderKind::Xai);
2008        assert_eq!(fast.resolved.id, "grok-4.20-0309-non-reasoning");
2009        assert!(!fast.resolved.supports_reasoning);
2010    }
2011
2012    #[test]
2013    fn meta_muse_spark_resolves_when_provider_hinted() {
2014        let registry = ModelRegistry::default();
2015
2016        let default = registry.resolve(None, Some(ProviderKind::Meta));
2017        assert_eq!(default.resolved.provider, ProviderKind::Meta);
2018        assert_eq!(default.resolved.id, "muse-spark-1.2");
2019        assert!(default.used_fallback);
2020
2021        let alias = registry.resolve(Some("muse-spark"), Some(ProviderKind::Meta));
2022        assert_eq!(alias.resolved.provider, ProviderKind::Meta);
2023        assert_eq!(alias.resolved.id, "muse-spark-1.2");
2024        assert!(!alias.used_fallback);
2025        assert_eq!(model_family("muse-spark-1.2"), ModelFamily::Meta);
2026    }
2027
2028    #[test]
2029    fn openai_gpt56_family_resolves_when_provider_hinted() {
2030        let registry = ModelRegistry::default();
2031        for model in ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] {
2032            let resolved = registry.resolve(Some(model), Some(ProviderKind::Openai));
2033            assert_eq!(resolved.resolved.provider, ProviderKind::Openai, "{model}");
2034            assert_eq!(resolved.resolved.id, model, "{model}");
2035            assert!(resolved.resolved.supports_tools, "{model}");
2036            assert!(resolved.resolved.supports_reasoning, "{model}");
2037            assert!(!resolved.used_fallback, "{model}");
2038        }
2039    }
2040
2041    #[test]
2042    fn grok_ids_stay_in_grok_family() {
2043        assert_eq!(model_family("grok-4.5"), ModelFamily::Grok);
2044        assert_eq!(
2045            model_family("grok-4.20-0309-non-reasoning"),
2046            ModelFamily::Grok
2047        );
2048    }
2049
2050    #[test]
2051    fn stepfun_and_minimax_direct_models_resolve_when_provider_hinted() {
2052        let registry = ModelRegistry::default();
2053
2054        let stepfun = registry.resolve(None, Some(ProviderKind::Stepfun));
2055        assert_eq!(stepfun.resolved.provider, ProviderKind::Stepfun);
2056        assert_eq!(stepfun.resolved.id, "step-3.7-flash");
2057
2058        for (alias, expected) in [
2059            ("minimax", "MiniMax-M3"),
2060            ("minimax-m3", "MiniMax-M3"),
2061            ("minimax-m2.7", "MiniMax-M2.7"),
2062            ("minimax-m2-7-highspeed", "MiniMax-M2.7-highspeed"),
2063            ("minimax-m2.1", "MiniMax-M2.1"),
2064            ("minimax-m2", "MiniMax-M2"),
2065        ] {
2066            let resolved = registry.resolve(Some(alias), Some(ProviderKind::Minimax));
2067
2068            assert_eq!(resolved.resolved.provider, ProviderKind::Minimax);
2069            assert_eq!(resolved.resolved.id, expected);
2070            assert!(!resolved.used_fallback);
2071            assert!(resolved.resolved.supports_tools);
2072            assert!(resolved.resolved.supports_reasoning);
2073        }
2074    }
2075
2076    #[test]
2077    fn minimax_anthropic_models_resolve_when_provider_hinted() {
2078        let registry = ModelRegistry::default();
2079
2080        for (alias, expected) in [
2081            ("minimax-anthropic", "MiniMax-M3"),
2082            ("minimax-m3", "MiniMax-M3"),
2083            ("minimax-m2.7", "MiniMax-M2.7"),
2084        ] {
2085            let resolved = registry.resolve(Some(alias), Some(ProviderKind::MinimaxAnthropic));
2086
2087            assert_eq!(resolved.resolved.provider, ProviderKind::MinimaxAnthropic);
2088            assert_eq!(resolved.resolved.id, expected);
2089            assert!(!resolved.used_fallback);
2090            assert!(resolved.resolved.supports_tools);
2091            assert!(resolved.resolved.supports_reasoning);
2092        }
2093    }
2094
2095    #[test]
2096    fn deepseek_v4_flash_alias_resolves_to_openrouter_when_provider_hinted() {
2097        let registry = ModelRegistry::default();
2098        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Openrouter));
2099
2100        assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
2101        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
2102    }
2103
2104    #[test]
2105    fn recent_openrouter_large_model_aliases_resolve_when_provider_hinted() {
2106        let registry = ModelRegistry::default();
2107
2108        for (alias, expected) in [
2109            ("trinity-large-thinking", "arcee-ai/trinity-large-thinking"),
2110            ("qwen3.6-flash", "qwen/qwen3.6-flash"),
2111            ("qwen3.6-35b-a3b", "qwen/qwen3.6-35b-a3b"),
2112            ("qwen3.6-max-preview", "qwen/qwen3.6-max-preview"),
2113            ("qwen3.6-plus", "qwen/qwen3.6-plus"),
2114            ("gemma-4-31b-it", "google/gemma-4-31b-it"),
2115            ("glm-5.1", "z-ai/glm-5.1"),
2116            ("glm-5.2", "z-ai/glm-5.2"),
2117            ("glm-5.3", "z-ai/glm-5.3"),
2118            ("minimax-m3", "minimax/minimax-m3"),
2119            ("minimax-2.7", "minimax/minimax-m2.7"),
2120            ("openrouter-mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro"),
2121            ("openrouter-kimi-k2.7-code", "moonshotai/kimi-k2.7-code"),
2122            ("openrouter-kimi-k2.6", "moonshotai/kimi-k2.6"),
2123            ("nemotron-3-ultra", "nvidia/nemotron-3-ultra-550b-a55b"),
2124            (
2125                "nvidia/nemotron-3-ultra",
2126                "nvidia/nemotron-3-ultra-550b-a55b",
2127            ),
2128        ] {
2129            let resolved = registry.resolve(Some(alias), Some(ProviderKind::Openrouter));
2130
2131            assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
2132            assert_eq!(resolved.resolved.id, expected);
2133            assert!(resolved.resolved.supports_tools);
2134            assert!(resolved.resolved.supports_reasoning);
2135        }
2136    }
2137
2138    #[test]
2139    fn deepseek_v4_flash_alias_resolves_to_novita_when_provider_hinted() {
2140        let registry = ModelRegistry::default();
2141        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Novita));
2142
2143        assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
2144        assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
2145    }
2146
2147    #[test]
2148    fn together_inkling_keeps_published_wire_identity() {
2149        let registry = ModelRegistry::default();
2150        for requested in ["thinkingmachines/inkling", "inkling", "together-inkling"] {
2151            let resolved = registry.resolve(Some(requested), Some(ProviderKind::Together));
2152
2153            assert_eq!(resolved.resolved.provider, ProviderKind::Together);
2154            assert_eq!(resolved.resolved.id, "thinkingmachines/inkling");
2155            assert!(resolved.resolved.supports_tools);
2156            assert!(resolved.resolved.supports_reasoning);
2157            assert!(!resolved.used_fallback);
2158        }
2159
2160        let unscoped = registry.resolve(Some("inkling"), None);
2161        assert_eq!(unscoped.resolved.provider, ProviderKind::Together);
2162        assert_eq!(unscoped.resolved.id, "thinkingmachines/inkling");
2163        assert!(!unscoped.used_fallback);
2164    }
2165
2166    #[test]
2167    fn registry_lists_and_resolves_every_v090_catalog_addition() {
2168        let registry = ModelRegistry::default();
2169        let advertised = [
2170            (ProviderKind::Anthropic, "claude-sonnet-5"),
2171            (ProviderKind::Anthropic, "claude-fable-5"),
2172            (ProviderKind::Openai, "gpt-5.3-codex"),
2173            (ProviderKind::Openai, "gpt-5.5"),
2174            (ProviderKind::Openai, "gpt-5.5-pro"),
2175            (ProviderKind::Openrouter, "qwen/qwen3.7-plus"),
2176            (ProviderKind::Arcee, "trinity-mini"),
2177        ];
2178
2179        let listed = registry.list();
2180        for (provider, model_id) in advertised {
2181            assert!(
2182                listed
2183                    .iter()
2184                    .any(|model| model.provider == provider && model.id == model_id),
2185                "missing {model_id} ({}) from model list",
2186                provider.as_str()
2187            );
2188            let resolved = registry.resolve(Some(model_id), Some(provider));
2189            assert_eq!(resolved.resolved.provider, provider, "{model_id}");
2190            assert_eq!(resolved.resolved.id, model_id, "{model_id}");
2191            assert!(!resolved.used_fallback, "{model_id}");
2192        }
2193    }
2194
2195    #[test]
2196    fn gpt_55_stays_provider_scoped_between_openai_and_codex() {
2197        let registry = ModelRegistry::default();
2198
2199        let unscoped = registry.resolve(Some("gpt-5.5"), None);
2200        assert_eq!(unscoped.resolved.provider, ProviderKind::Openai);
2201        assert_eq!(unscoped.resolved.id, "gpt-5.5");
2202        assert!(!unscoped.used_fallback);
2203
2204        let codex = registry.resolve(Some("gpt-5.5"), Some(ProviderKind::OpenaiCodex));
2205        assert_eq!(codex.resolved.provider, ProviderKind::OpenaiCodex);
2206        assert_eq!(codex.resolved.id, "gpt-5.5");
2207        assert!(!codex.used_fallback);
2208    }
2209
2210    #[test]
2211    fn deepseek_v4_flash_alias_resolves_to_sglang_when_provider_hinted() {
2212        let registry = ModelRegistry::default();
2213        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Sglang));
2214
2215        assert_eq!(resolved.resolved.provider, ProviderKind::Sglang);
2216        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
2217    }
2218
2219    #[test]
2220    fn vllm_default_uses_canonical_model_id() {
2221        let registry = ModelRegistry::default();
2222        let resolved = registry.resolve(None, Some(ProviderKind::Vllm));
2223
2224        assert_eq!(resolved.resolved.provider, ProviderKind::Vllm);
2225        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
2226    }
2227
2228    #[test]
2229    fn ollama_default_uses_small_local_model_id() {
2230        let registry = ModelRegistry::default();
2231        let resolved = registry.resolve(None, Some(ProviderKind::Ollama));
2232
2233        assert_eq!(resolved.resolved.provider, ProviderKind::Ollama);
2234        assert_eq!(resolved.resolved.id, "deepseek-v4-flash");
2235        assert!(resolved.resolved.supports_reasoning);
2236    }
2237
2238    #[test]
2239    fn ollama_requested_model_tag_is_preserved() {
2240        let registry = ModelRegistry::default();
2241        let resolved = registry.resolve(Some("qwen2.5-coder:7b"), Some(ProviderKind::Ollama));
2242
2243        assert_eq!(resolved.resolved.provider, ProviderKind::Ollama);
2244        assert_eq!(resolved.resolved.id, "qwen2.5-coder:7b");
2245        assert!(!resolved.used_fallback);
2246    }
2247
2248    #[test]
2249    fn deepseek_v4_flash_alias_resolves_to_vllm_when_provider_hinted() {
2250        let registry = ModelRegistry::default();
2251        let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Vllm));
2252
2253        assert_eq!(resolved.resolved.provider, ProviderKind::Vllm);
2254        assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
2255    }
2256
2257    #[test]
2258    fn preserves_requested_model_casing_for_third_party_providers() {
2259        let registry = ModelRegistry::default();
2260        let resolved = registry.resolve(Some("DeepSeek-V4-Pro"), None);
2261
2262        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
2263        assert_eq!(resolved.resolved.id, "DeepSeek-V4-Pro");
2264    }
2265
2266    #[test]
2267    fn registry_casing_takes_priority_over_requested_casing_with_provider_hint() {
2268        let registry = ModelRegistry::default();
2269        let resolved = registry.resolve(Some("DeepSeek-V4-Pro"), Some(ProviderKind::Deepseek));
2270
2271        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
2272        // Registry's canonical id is used even when user provides different casing
2273        assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
2274    }
2275
2276    #[test]
2277    fn preserves_requested_model_casing_without_surrounding_whitespace() {
2278        let registry = ModelRegistry::default();
2279        let resolved = registry.resolve(Some("  DeepSeek-V4-Pro  "), None);
2280
2281        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
2282        assert_eq!(resolved.resolved.id, "DeepSeek-V4-Pro");
2283    }
2284
2285    #[test]
2286    fn alias_match_does_not_override_requested_casing() {
2287        let registry = ModelRegistry::default();
2288        let resolved = registry.resolve(Some("deepseek-reasoner"), None);
2289
2290        assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
2291        assert_eq!(resolved.resolved.id, "deepseek-v4-flash");
2292    }
2293
2294    #[test]
2295    fn model_family_classifies_known_model_ids() {
2296        assert_eq!(model_family("deepseek-v4-pro"), ModelFamily::DeepSeek);
2297        assert_eq!(model_family("openai/gpt-5.4"), ModelFamily::OpenAI);
2298        assert_eq!(
2299            model_family("anthropic/claude-opus-4-7"),
2300            ModelFamily::Anthropic
2301        );
2302        assert_eq!(
2303            model_family("meta-llama/llama-3.3-70b-instruct"),
2304            ModelFamily::Meta
2305        );
2306        assert_eq!(model_family("Qwen/Qwen3-Coder"), ModelFamily::Qwen);
2307    }
2308
2309    #[test]
2310    fn model_family_uses_underlying_model_for_router_ids() {
2311        assert_eq!(
2312            model_family("groq/llama-3.3-70b-versatile"),
2313            ModelFamily::Meta
2314        );
2315        assert_eq!(
2316            model_family("openrouter/openai/gpt-5.4"),
2317            ModelFamily::OpenAI
2318        );
2319        assert_eq!(
2320            model_family("fireworks/accounts/fireworks/models/deepseek-v4-pro"),
2321            ModelFamily::DeepSeek
2322        );
2323    }
2324
2325    #[test]
2326    fn model_family_covers_prominent_google_and_mistral_model_names() {
2327        assert_eq!(model_family("google/gemma-3-27b-it"), ModelFamily::Google);
2328        assert_eq!(
2329            model_family("mistralai/mixtral-8x22b"),
2330            ModelFamily::Mistral
2331        );
2332        assert_eq!(model_family("codestral-latest"), ModelFamily::Mistral);
2333    }
2334
2335    #[test]
2336    fn model_family_falls_back_to_inferencer_for_unknown_models() {
2337        assert_eq!(
2338            model_family("custom-gateway/my-private-model"),
2339            ModelFamily::Inferencer
2340        );
2341        assert_eq!(model_family(""), ModelFamily::Inferencer);
2342    }
2343}