返回 CodeWhale
lib.rs
根目录 / crates / agent / src / lib.rs
1 use std::error::Error;
2 use std::fmt;
3
4 use codewhale_config::{ProviderKind, opencode_go_model_id};
5 use serde::{Deserialize, Serialize};
6
7 /// High-level model family used for shared identity affordances across clients.
8 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9 pub enum ModelFamily {
10 DeepSeek,
11 Anthropic,
12 OpenAI,
13 Google,
14 Meta,
15 Mistral,
16 Qwen,
17 Grok,
18 Cohere,
19 GptOss,
20 Inferencer,
21 }
22
23 /// Metadata for a single model entry in the registry.
24 ///
25 /// Each model has a canonical `id` used by the provider, a list of `aliases`
26 /// that users may reference, and capability flags indicating whether the model
27 /// supports tool use and reasoning.
28 #[derive(Debug, Clone, Serialize, Deserialize)]
29 pub struct ModelInfo {
30 /// The canonical model identifier used by the provider (e.g. `"deepseek-v4-pro"`).
31 pub id: String,
32 /// The provider that serves this model.
33 pub provider: ProviderKind,
34 /// Alternative names that users can use to reference this model (case-insensitive).
35 pub aliases: Vec<String>,
36 /// Whether this model supports tool/function calling.
37 pub supports_tools: bool,
38 /// Whether this model supports extended reasoning.
39 pub supports_reasoning: bool,
40 }
41
42 /// The result of resolving a user-requested model name to a concrete model entry.
43 ///
44 /// Contains the resolved [`ModelInfo`], whether a fallback was used, and the
45 /// chain of resolution strategies that were attempted.
46 #[derive(Debug, Clone, Serialize, Deserialize)]
47 pub struct ModelResolution {
48 /// The original model name requested by the user, if any.
49 pub requested: Option<String>,
50 /// The concrete model that was resolved.
51 pub resolved: ModelInfo,
52 /// Whether the provider-owned default was used because no model was requested.
53 pub used_fallback: bool,
54 /// The ordered list of resolution strategies that were attempted.
55 pub fallback_chain: Vec<String>,
56 }
57
58 /// A model lookup that cannot name a provider-owned result truthfully.
59 ///
60 /// The registry is metadata, not route authority. In particular, a missing
61 /// provider must never be interpreted as permission to select DeepSeek (or
62 /// any other provider), and an explicit provider with no registered models
63 /// must never fall through to another provider's first catalog row.
64 #[derive(Debug, Clone, PartialEq, Eq)]
65 pub enum ModelResolutionError {
66 /// No provider was supplied. A model name alone is never route authority,
67 /// even when it happens to match one catalog entry.
68 ProviderRequired { requested: Option<String> },
69 /// The caller selected a provider for which this registry has no model
70 /// metadata to return.
71 ProviderHasNoModels {
72 provider: ProviderKind,
73 requested: Option<String>,
74 },
75 /// The caller selected a provider, then requested a model that provider's
76 /// registry rows and explicit pass-through contract do not serve.
77 ModelNotAvailableForProvider {
78 provider: ProviderKind,
79 requested: String,
80 },
81 /// The provider declares a default model, but the registry cannot return a
82 /// matching provider-owned row for it.
83 ProviderDefaultUnavailable {
84 provider: ProviderKind,
85 default_model: String,
86 },
87 }
88
89 impl fmt::Display for ModelResolutionError {
90 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
91 match self {
92 Self::ProviderRequired {
93 requested: Some(requested),
94 } => write!(
95 formatter,
96 "model '{requested}' does not identify an unambiguous provider; select a provider explicitly"
97 ),
98 Self::ProviderRequired { requested: None } => {
99 formatter.write_str("model resolution requires an explicit provider")
100 }
101 Self::ProviderHasNoModels {
102 provider,
103 requested: Some(requested),
104 } => write!(
105 formatter,
106 "provider '{}' has no registered model for '{requested}'",
107 provider.as_str()
108 ),
109 Self::ProviderHasNoModels {
110 provider,
111 requested: None,
112 } => write!(
113 formatter,
114 "provider '{}' has no registered default model",
115 provider.as_str()
116 ),
117 Self::ModelNotAvailableForProvider {
118 provider,
119 requested,
120 } => write!(
121 formatter,
122 "model '{requested}' is not available from provider '{}'",
123 provider.as_str()
124 ),
125 Self::ProviderDefaultUnavailable {
126 provider,
127 default_model,
128 } => write!(
129 formatter,
130 "provider '{}' declares default model '{default_model}', but that model is not registered for the provider",
131 provider.as_str()
132 ),
133 }
134 }
135 }
136
137 impl Error for ModelResolutionError {}
138
139 /// A registry of supported models and their aliases, used to resolve user-facing
140 /// model names to concrete provider-specific model entries.
141 ///
142 /// The default registry is populated with all built-in models across supported
143 /// providers (DeepSeek, NVIDIA NIM, OpenAI-compatible, and others).
144 #[derive(Debug, Clone)]
145 pub struct ModelRegistry {
146 models: Vec<ModelInfo>,
147 }
148
149 /// Creates a registry pre-populated with all built-in models and their aliases.
150 impl Default for ModelRegistry {
151 fn default() -> Self {
152 let mut models = vec![
153 ModelInfo {
154 id: "deepseek-v4-pro".to_string(),
155 provider: ProviderKind::Deepseek,
156 aliases: vec![],
157 supports_tools: true,
158 supports_reasoning: true,
159 },
160 ModelInfo {
161 id: "deepseek-v4-flash".to_string(),
162 provider: ProviderKind::Deepseek,
163 aliases: vec![
164 "deepseek-chat".to_string(),
165 "deepseek-reasoner".to_string(),
166 "deepseek-r1".to_string(),
167 "deepseek-v3".to_string(),
168 "deepseek-v3.2".to_string(),
169 ],
170 supports_tools: true,
171 supports_reasoning: true,
172 },
173 ModelInfo {
174 id: "deepseek-v4-flash-vision-exp".to_string(),
175 provider: ProviderKind::Deepseek,
176 aliases: vec![
177 "flash-vision".to_string(),
178 "deepseek-v4flashvisionexp".to_string(),
179 ],
180 supports_tools: true,
181 supports_reasoning: true,
182 },
183 ModelInfo {
184 id: "deepseek-ai/deepseek-v4-pro".to_string(),
185 provider: ProviderKind::NvidiaNim,
186 aliases: vec![
187 "deepseek-v4-pro".to_string(),
188 "nvidia-deepseek-v4-pro".to_string(),
189 "nim-deepseek-v4-pro".to_string(),
190 ],
191 supports_tools: true,
192 supports_reasoning: true,
193 },
194 ModelInfo {
195 id: "deepseek-ai/deepseek-v4-flash".to_string(),
196 provider: ProviderKind::NvidiaNim,
197 aliases: vec![
198 "deepseek-v4-flash".to_string(),
199 "deepseek-chat".to_string(),
200 "deepseek-reasoner".to_string(),
201 "nvidia-deepseek-v4-flash".to_string(),
202 "nim-deepseek-v4-flash".to_string(),
203 ],
204 supports_tools: true,
205 supports_reasoning: true,
206 },
207 ModelInfo {
208 id: "deepseek-v4-pro".to_string(),
209 provider: ProviderKind::Openai,
210 aliases: vec!["openai-compatible-deepseek-v4-pro".to_string()],
211 supports_tools: true,
212 supports_reasoning: true,
213 },
214 ModelInfo {
215 id: "deepseek-v4-flash".to_string(),
216 provider: ProviderKind::Openai,
217 aliases: vec!["openai-compatible-deepseek-v4-flash".to_string()],
218 supports_tools: true,
219 supports_reasoning: true,
220 },
221 // OpenAI public API models carried by the bundled catalog.
222 ModelInfo {
223 id: "gpt-5.3-codex".to_string(),
224 provider: ProviderKind::Openai,
225 aliases: vec!["gpt53-codex".to_string()],
226 supports_tools: true,
227 supports_reasoning: true,
228 },
229 ModelInfo {
230 id: "gpt-5.5".to_string(),
231 provider: ProviderKind::Openai,
232 aliases: vec!["openai-gpt-5.5".to_string()],
233 supports_tools: true,
234 supports_reasoning: true,
235 },
236 ModelInfo {
237 id: "gpt-5.5-pro".to_string(),
238 provider: ProviderKind::Openai,
239 aliases: vec!["openai-gpt-5.5-pro".to_string()],
240 supports_tools: true,
241 supports_reasoning: true,
242 },
243 // OpenAI public API GPT-5.6 family.
244 ModelInfo {
245 id: "gpt-5.6".to_string(),
246 provider: ProviderKind::Openai,
247 aliases: vec!["gpt56".to_string()],
248 supports_tools: true,
249 supports_reasoning: true,
250 },
251 ModelInfo {
252 id: "gpt-5.6-sol".to_string(),
253 provider: ProviderKind::Openai,
254 aliases: vec!["gpt56-sol".to_string()],
255 supports_tools: true,
256 supports_reasoning: true,
257 },
258 ModelInfo {
259 id: "gpt-5.6-terra".to_string(),
260 provider: ProviderKind::Openai,
261 aliases: vec!["gpt56-terra".to_string()],
262 supports_tools: true,
263 supports_reasoning: true,
264 },
265 ModelInfo {
266 id: "gpt-5.6-luna".to_string(),
267 provider: ProviderKind::Openai,
268 aliases: vec!["gpt56-luna".to_string()],
269 supports_tools: true,
270 supports_reasoning: true,
271 },
272 ModelInfo {
273 id: "deepseek-ai/deepseek-v4-flash".to_string(),
274 provider: ProviderKind::Atlascloud,
275 aliases: vec![
276 "deepseek-v4-flash".to_string(),
277 "atlascloud-deepseek-v4-flash".to_string(),
278 ],
279 supports_tools: true,
280 supports_reasoning: true,
281 },
282 ModelInfo {
283 id: "deepseek-ai/deepseek-v4-pro".to_string(),
284 provider: ProviderKind::Atlascloud,
285 aliases: vec![
286 "deepseek-v4-pro".to_string(),
287 "atlascloud-deepseek-v4-pro".to_string(),
288 ],
289 supports_tools: true,
290 supports_reasoning: true,
291 },
292 ModelInfo {
293 id: "deepseek-reasoner".to_string(),
294 provider: ProviderKind::WanjieArk,
295 aliases: vec![
296 "wanjie-deepseek-reasoner".to_string(),
297 "ark-wanjie-deepseek-reasoner".to_string(),
298 ],
299 supports_tools: true,
300 supports_reasoning: true,
301 },
302 ModelInfo {
303 id: "DeepSeek-V4-Pro".to_string(),
304 provider: ProviderKind::Volcengine,
305 aliases: vec![
306 "deepseek-v4-pro".to_string(),
307 "volcengine-deepseek-v4-pro".to_string(),
308 "ark-deepseek-v4-pro".to_string(),
309 ],
310 supports_tools: true,
311 supports_reasoning: true,
312 },
313 ModelInfo {
314 id: "DeepSeek-V4-Flash".to_string(),
315 provider: ProviderKind::Volcengine,
316 aliases: vec![
317 "deepseek-v4-flash".to_string(),
318 "deepseek-chat".to_string(),
319 "volcengine-deepseek-v4-flash".to_string(),
320 "ark-deepseek-v4-flash".to_string(),
321 ],
322 supports_tools: true,
323 supports_reasoning: true,
324 },
325 ModelInfo {
326 id: "trinity-large-thinking".to_string(),
327 provider: ProviderKind::Arcee,
328 aliases: vec![
329 "trinity".to_string(),
330 "arcee-trinity".to_string(),
331 "arcee-trinity-large-thinking".to_string(),
332 ],
333 supports_tools: true,
334 supports_reasoning: true,
335 },
336 ModelInfo {
337 id: "trinity-mini".to_string(),
338 provider: ProviderKind::Arcee,
339 aliases: vec!["arcee-trinity-mini".to_string()],
340 supports_tools: true,
341 supports_reasoning: true,
342 },
343 ModelInfo {
344 id: "deepseek/deepseek-v4-pro".to_string(),
345 provider: ProviderKind::Openrouter,
346 aliases: vec![
347 "deepseek-v4-pro".to_string(),
348 "openrouter-deepseek-v4-pro".to_string(),
349 ],
350 supports_tools: true,
351 supports_reasoning: true,
352 },
353 ModelInfo {
354 id: "deepseek/deepseek-v4-flash".to_string(),
355 provider: ProviderKind::Openrouter,
356 aliases: vec![
357 "deepseek-v4-flash".to_string(),
358 "deepseek-chat".to_string(),
359 "deepseek-reasoner".to_string(),
360 "openrouter-deepseek-v4-flash".to_string(),
361 ],
362 supports_tools: true,
363 supports_reasoning: true,
364 },
365 ModelInfo {
366 id: "deepseek/deepseek-v4-pro".to_string(),
367 provider: ProviderKind::Orcarouter,
368 aliases: vec!["orcarouter-deepseek-v4-pro".to_string()],
369 supports_tools: true,
370 supports_reasoning: true,
371 },
372 ModelInfo {
373 id: "deepseek/deepseek-v4-flash".to_string(),
374 provider: ProviderKind::Orcarouter,
375 aliases: vec!["orcarouter-deepseek-v4-flash".to_string()],
376 supports_tools: true,
377 supports_reasoning: true,
378 },
379 ModelInfo {
380 id: "orcarouter/auto".to_string(),
381 provider: ProviderKind::Orcarouter,
382 aliases: vec!["orcarouter-auto".to_string()],
383 supports_tools: true,
384 supports_reasoning: true,
385 },
386 ModelInfo {
387 id: "arcee-ai/trinity-large-thinking".to_string(),
388 provider: ProviderKind::Openrouter,
389 aliases: vec![
390 "trinity".to_string(),
391 "trinity-large-thinking".to_string(),
392 "arcee-trinity-large-thinking".to_string(),
393 ],
394 supports_tools: true,
395 supports_reasoning: true,
396 },
397 ModelInfo {
398 id: "xiaomi/mimo-v2.5-pro".to_string(),
399 provider: ProviderKind::Openrouter,
400 aliases: vec![
401 "openrouter-mimo-v2.5-pro".to_string(),
402 "openrouter-xiaomi-mimo-v2.5-pro".to_string(),
403 ],
404 supports_tools: true,
405 supports_reasoning: true,
406 },
407 ModelInfo {
408 id: "xiaomi/mimo-v2.5".to_string(),
409 provider: ProviderKind::Openrouter,
410 aliases: vec![
411 "openrouter-mimo-v2.5".to_string(),
412 "openrouter-xiaomi-mimo-v2.5".to_string(),
413 ],
414 supports_tools: true,
415 supports_reasoning: true,
416 },
417 ModelInfo {
418 id: "qwen/qwen3.6-flash".to_string(),
419 provider: ProviderKind::Openrouter,
420 aliases: vec!["qwen3.6-flash".to_string(), "qwen-3.6-flash".to_string()],
421 supports_tools: true,
422 supports_reasoning: true,
423 },
424 ModelInfo {
425 id: "qwen/qwen3.6-35b-a3b".to_string(),
426 provider: ProviderKind::Openrouter,
427 aliases: vec![
428 "qwen3.6-35b-a3b".to_string(),
429 "qwen-3.6-35b-a3b".to_string(),
430 ],
431 supports_tools: true,
432 supports_reasoning: true,
433 },
434 ModelInfo {
435 id: "qwen/qwen3.6-max-preview".to_string(),
436 provider: ProviderKind::Openrouter,
437 aliases: vec![
438 "qwen3.6-max-preview".to_string(),
439 "qwen-3.6-max-preview".to_string(),
440 "qwen-max-preview".to_string(),
441 ],
442 supports_tools: true,
443 supports_reasoning: true,
444 },
445 ModelInfo {
446 id: "qwen/qwen3.6-27b".to_string(),
447 provider: ProviderKind::Openrouter,
448 aliases: vec!["qwen3.6-27b".to_string(), "qwen-3.6-27b".to_string()],
449 supports_tools: true,
450 supports_reasoning: true,
451 },
452 ModelInfo {
453 id: "qwen/qwen3.6-plus".to_string(),
454 provider: ProviderKind::Openrouter,
455 aliases: vec!["qwen3.6-plus".to_string(), "qwen-3.6-plus".to_string()],
456 supports_tools: true,
457 supports_reasoning: true,
458 },
459 ModelInfo {
460 id: "qwen/qwen3.7-plus".to_string(),
461 provider: ProviderKind::Openrouter,
462 aliases: vec!["qwen3.7-plus".to_string(), "qwen-3.7-plus".to_string()],
463 supports_tools: true,
464 supports_reasoning: true,
465 },
466 ModelInfo {
467 id: "moonshotai/kimi-k2.7-code".to_string(),
468 provider: ProviderKind::Openrouter,
469 aliases: vec![
470 "kimi-k2.7-code".to_string(),
471 "openrouter-kimi-k2.7-code".to_string(),
472 ],
473 supports_tools: true,
474 supports_reasoning: true,
475 },
476 ModelInfo {
477 id: "moonshotai/kimi-k2.6".to_string(),
478 provider: ProviderKind::Openrouter,
479 aliases: vec!["openrouter-kimi-k2.6".to_string()],
480 supports_tools: true,
481 supports_reasoning: true,
482 },
483 ModelInfo {
484 id: "minimax/minimax-m3".to_string(),
485 provider: ProviderKind::Openrouter,
486 aliases: vec![
487 "minimax-m3".to_string(),
488 "minimax-m-3".to_string(),
489 "openrouter-minimax-m3".to_string(),
490 ],
491 supports_tools: true,
492 supports_reasoning: true,
493 },
494 ModelInfo {
495 id: "z-ai/glm-5.1".to_string(),
496 provider: ProviderKind::Openrouter,
497 aliases: vec!["glm-5.1".to_string(), "zai-glm-5.1".to_string()],
498 supports_tools: true,
499 supports_reasoning: true,
500 },
501 ModelInfo {
502 id: "z-ai/glm-5.2".to_string(),
503 provider: ProviderKind::Openrouter,
504 aliases: vec!["glm-5.2".to_string(), "zai-glm-5.2".to_string()],
505 supports_tools: true,
506 supports_reasoning: true,
507 },
508 // GLM-5.3 is live; capabilities still inherit from glm-5.2 until
509 // Z.ai publishes distinct 5.3 numbers. See
510 // crates/config/assets/models_dev.bundled.json
511 // `_meta.pending_release_metadata`.
512 ModelInfo {
513 id: "z-ai/glm-5.3".to_string(),
514 provider: ProviderKind::Openrouter,
515 aliases: vec!["glm-5.3".to_string(), "zai-glm-5.3".to_string()],
516 supports_tools: true,
517 supports_reasoning: true,
518 },
519 ModelInfo {
520 id: "z-ai/glm-5.3-flash".to_string(),
521 provider: ProviderKind::Openrouter,
522 aliases: vec!["glm-5.3-flash".to_string(), "zai-glm-5.3-flash".to_string()],
523 supports_tools: true,
524 supports_reasoning: true,
525 },
526 ModelInfo {
527 id: "z-ai/glm-5-turbo".to_string(),
528 provider: ProviderKind::Openrouter,
529 aliases: vec!["glm-5-turbo".to_string(), "zai-glm-5-turbo".to_string()],
530 supports_tools: true,
531 supports_reasoning: true,
532 },
533 ModelInfo {
534 id: "GLM-5.3".to_string(),
535 provider: ProviderKind::Zai,
536 aliases: vec![
537 "glm-5.3".to_string(),
538 "glm-5-3".to_string(),
539 "zai-glm-5.3".to_string(),
540 "zai-glm-5-3".to_string(),
541 ],
542 supports_tools: true,
543 supports_reasoning: true,
544 },
545 ModelInfo {
546 id: "GLM-5.3-Flash".to_string(),
547 provider: ProviderKind::Zai,
548 aliases: vec![
549 "glm-5.3-flash".to_string(),
550 "glm-5-3-flash".to_string(),
551 "zai-glm-5.3-flash".to_string(),
552 "zai-glm-5-3-flash".to_string(),
553 ],
554 supports_tools: true,
555 supports_reasoning: true,
556 },
557 // The first Z.ai row is the provider default. Keep this ordering
558 // aligned with `DEFAULT_ZAI_MODEL` in codewhale-config.
559 ModelInfo {
560 id: "GLM-5.2".to_string(),
561 provider: ProviderKind::Zai,
562 aliases: vec![
563 "glm-5.2".to_string(),
564 "glm-5-2".to_string(),
565 "zai-glm-5.2".to_string(),
566 "zai-glm-5-2".to_string(),
567 ],
568 supports_tools: true,
569 supports_reasoning: true,
570 },
571 ModelInfo {
572 id: "GLM-5.1".to_string(),
573 provider: ProviderKind::Zai,
574 aliases: vec![
575 "glm-5.1".to_string(),
576 "glm-5-1".to_string(),
577 "zai-glm-5.1".to_string(),
578 "zai-glm-5-1".to_string(),
579 ],
580 supports_tools: true,
581 supports_reasoning: true,
582 },
583 ModelInfo {
584 id: "GLM-5-Turbo".to_string(),
585 provider: ProviderKind::Zai,
586 aliases: vec![
587 "glm-5-turbo".to_string(),
588 "glm-5turbo".to_string(),
589 "zai-glm-5-turbo".to_string(),
590 ],
591 supports_tools: true,
592 supports_reasoning: true,
593 },
594 ModelInfo {
595 id: "tencent/hy3-preview".to_string(),
596 provider: ProviderKind::Openrouter,
597 aliases: vec![
598 "hy3-preview".to_string(),
599 "tencent-hy3-preview".to_string(),
600 "hy3".to_string(),
601 "hunyuan".to_string(),
602 "tencent-hunyuan".to_string(),
603 "hunyuan-hy3".to_string(),
604 ],
605 supports_tools: true,
606 supports_reasoning: true,
607 },
608 ModelInfo {
609 id: "google/gemma-4-31b-it".to_string(),
610 provider: ProviderKind::Openrouter,
611 aliases: vec!["gemma-4-31b".to_string(), "gemma-4-31b-it".to_string()],
612 supports_tools: true,
613 supports_reasoning: true,
614 },
615 ModelInfo {
616 id: "google/gemma-4-26b-a4b-it".to_string(),
617 provider: ProviderKind::Openrouter,
618 aliases: vec![
619 "gemma-4-26b-a4b".to_string(),
620 "gemma-4-26b-a4b-it".to_string(),
621 ],
622 supports_tools: true,
623 supports_reasoning: true,
624 },
625 ModelInfo {
626 id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free".to_string(),
627 provider: ProviderKind::Openrouter,
628 aliases: vec![
629 "nemotron-3-nano-omni".to_string(),
630 "nemotron-3-nano-omni-reasoning".to_string(),
631 ],
632 supports_tools: true,
633 supports_reasoning: true,
634 },
635 ModelInfo {
636 id: "mimo-v2.5-pro".to_string(),
637 provider: ProviderKind::XiaomiMimo,
638 aliases: vec![
639 "mimo".to_string(),
640 "pro".to_string(),
641 "xiaomi-mimo-v2.5-pro".to_string(),
642 "xiaomi-mimo-v2-5-pro".to_string(),
643 ],
644 supports_tools: true,
645 supports_reasoning: true,
646 },
647 ModelInfo {
648 id: "mimo-v2.5".to_string(),
649 provider: ProviderKind::XiaomiMimo,
650 aliases: vec![
651 "omni".to_string(),
652 "mimo-omni".to_string(),
653 "v2.5-omni".to_string(),
654 "mimo-v2.5-omni".to_string(),
655 "xiaomi-mimo-v2.5".to_string(),
656 "xiaomi-mimo-v2.5-omni".to_string(),
657 ],
658 supports_tools: true,
659 supports_reasoning: true,
660 },
661 ModelInfo {
662 id: "mimo-v2.5-asr".to_string(),
663 provider: ProviderKind::XiaomiMimo,
664 aliases: vec![
665 "asr".to_string(),
666 "speech-to-text".to_string(),
667 "transcribe".to_string(),
668 ],
669 supports_tools: false,
670 supports_reasoning: false,
671 },
672 ModelInfo {
673 id: "mimo-v2.5-tts".to_string(),
674 provider: ProviderKind::XiaomiMimo,
675 aliases: vec![
676 "tts".to_string(),
677 "speech".to_string(),
678 "mimo-tts".to_string(),
679 ],
680 supports_tools: false,
681 supports_reasoning: false,
682 },
683 ModelInfo {
684 id: "mimo-v2.5-tts-voicedesign".to_string(),
685 provider: ProviderKind::XiaomiMimo,
686 aliases: vec![
687 "voicedesign".to_string(),
688 "voice-design".to_string(),
689 "mimo-voice-design".to_string(),
690 ],
691 supports_tools: false,
692 supports_reasoning: false,
693 },
694 ModelInfo {
695 id: "mimo-v2.5-tts-voiceclone".to_string(),
696 provider: ProviderKind::XiaomiMimo,
697 aliases: vec![
698 "voiceclone".to_string(),
699 "voice-clone".to_string(),
700 "mimo-voice-clone".to_string(),
701 ],
702 supports_tools: false,
703 supports_reasoning: false,
704 },
705 ModelInfo {
706 id: "mimo-v2-tts".to_string(),
707 provider: ProviderKind::XiaomiMimo,
708 aliases: vec!["mimo-v2-speech".to_string()],
709 supports_tools: false,
710 supports_reasoning: false,
711 },
712 ModelInfo {
713 id: "deepseek/deepseek-v4-pro".to_string(),
714 provider: ProviderKind::Novita,
715 aliases: vec![
716 "deepseek-v4-pro".to_string(),
717 "novita-deepseek-v4-pro".to_string(),
718 ],
719 supports_tools: true,
720 supports_reasoning: true,
721 },
722 ModelInfo {
723 id: "deepseek/deepseek-v4-flash".to_string(),
724 provider: ProviderKind::Novita,
725 aliases: vec![
726 "deepseek-v4-flash".to_string(),
727 "deepseek-chat".to_string(),
728 "deepseek-reasoner".to_string(),
729 "novita-deepseek-v4-flash".to_string(),
730 ],
731 supports_tools: true,
732 supports_reasoning: true,
733 },
734 ModelInfo {
735 id: "accounts/fireworks/models/deepseek-v4-pro".to_string(),
736 provider: ProviderKind::Fireworks,
737 aliases: vec![
738 "deepseek-v4-pro".to_string(),
739 "fireworks-deepseek-v4-pro".to_string(),
740 ],
741 supports_tools: true,
742 supports_reasoning: true,
743 },
744 ModelInfo {
745 id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
746 provider: ProviderKind::Siliconflow,
747 // `deepseek-reasoner` and `deepseek-r1` deliberately do NOT
748 // appear here. Every other provider maps both to V4-Flash, so
749 // listing them on a Pro row made one alias mean two tiers —
750 // and Pro costs ~3x Flash per input token. An alias must name
751 // one model everywhere or it is a silent substitution.
752 aliases: vec![
753 "deepseek-v4-pro".to_string(),
754 "siliconflow-deepseek-v4-pro".to_string(),
755 ],
756 supports_tools: true,
757 supports_reasoning: true,
758 },
759 ModelInfo {
760 id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
761 provider: ProviderKind::Siliconflow,
762 aliases: vec![
763 "deepseek-v4-flash".to_string(),
764 "deepseek-chat".to_string(),
765 "deepseek-v3".to_string(),
766 "siliconflow-deepseek-v4-flash".to_string(),
767 ],
768 supports_tools: true,
769 supports_reasoning: true,
770 },
771 ModelInfo {
772 id: "trinity-large-preview".to_string(),
773 provider: ProviderKind::Arcee,
774 aliases: vec!["arcee-trinity-large-preview".to_string()],
775 supports_tools: true,
776 supports_reasoning: false,
777 },
778 ModelInfo {
779 id: "kimi-k2.7-code".to_string(),
780 provider: ProviderKind::Moonshot,
781 aliases: vec![
782 "kimi".to_string(),
783 "kimi-k2".to_string(),
784 "kimi-k2.7".to_string(),
785 "kimi-code".to_string(),
786 "moonshot-kimi-k2.7-code".to_string(),
787 ],
788 supports_tools: true,
789 supports_reasoning: true,
790 },
791 ModelInfo {
792 id: "kimi-k2.6".to_string(),
793 provider: ProviderKind::Moonshot,
794 aliases: vec!["kimi-k2.6".to_string(), "moonshot-kimi-k2.6".to_string()],
795 supports_tools: true,
796 supports_reasoning: true,
797 },
798 // Moonshot ships K3 as two distinct products under one provider
799 // id, separated by endpoint (v0.9.1 kimi-k3 dogfood report):
800 // * `kimi-k3` on the direct platform API (api.moonshot.ai/v1)
801 // * `k3` on the Kimi Code coding-plan API (api.kimi.com/coding/v1)
802 // Both must be resolvable here or `--model kimi-k3` silently
803 // reports the provider default instead. The endpoint pairing is
804 // enforced separately by `validate_kimi_code_api_model_id`; keep
805 // the two ids in separate entries so neither one's alias set can
806 // launder a request onto the other product's route.
807 ModelInfo {
808 id: "kimi-k3".to_string(),
809 provider: ProviderKind::Moonshot,
810 aliases: vec!["moonshot-kimi-k3".to_string()],
811 supports_tools: true,
812 supports_reasoning: true,
813 },
814 ModelInfo {
815 id: "k3".to_string(),
816 provider: ProviderKind::Moonshot,
817 aliases: vec!["kimi-code-k3".to_string()],
818 supports_tools: true,
819 supports_reasoning: true,
820 },
821 ModelInfo {
822 id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
823 provider: ProviderKind::Sglang,
824 aliases: vec![
825 "deepseek-v4-pro".to_string(),
826 "sglang-deepseek-v4-pro".to_string(),
827 ],
828 supports_tools: true,
829 supports_reasoning: true,
830 },
831 ModelInfo {
832 id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
833 provider: ProviderKind::Sglang,
834 aliases: vec![
835 "deepseek-v4-flash".to_string(),
836 "deepseek-chat".to_string(),
837 "deepseek-reasoner".to_string(),
838 "sglang-deepseek-v4-flash".to_string(),
839 ],
840 supports_tools: true,
841 supports_reasoning: true,
842 },
843 ModelInfo {
844 id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
845 provider: ProviderKind::Vllm,
846 aliases: vec![
847 "deepseek-v4-pro".to_string(),
848 "vllm-deepseek-v4-pro".to_string(),
849 ],
850 supports_tools: true,
851 supports_reasoning: true,
852 },
853 ModelInfo {
854 id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
855 provider: ProviderKind::Vllm,
856 aliases: vec![
857 "deepseek-v4-flash".to_string(),
858 "deepseek-chat".to_string(),
859 "deepseek-reasoner".to_string(),
860 "vllm-deepseek-v4-flash".to_string(),
861 ],
862 supports_tools: true,
863 supports_reasoning: true,
864 },
865 ModelInfo {
866 id: "deepseek-v4-flash".to_string(),
867 provider: ProviderKind::Ollama,
868 aliases: vec![],
869 supports_tools: true,
870 supports_reasoning: true,
871 },
872 ModelInfo {
873 id: "gpt-oss:120b".to_string(),
874 provider: ProviderKind::OllamaCloud,
875 aliases: vec![],
876 supports_tools: true,
877 supports_reasoning: true,
878 },
879 ModelInfo {
880 id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
881 provider: ProviderKind::Huggingface,
882 aliases: vec![
883 "deepseek-v4-pro".to_string(),
884 "hf-deepseek-v4-pro".to_string(),
885 ],
886 supports_tools: true,
887 supports_reasoning: true,
888 },
889 ModelInfo {
890 id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
891 provider: ProviderKind::Huggingface,
892 aliases: vec![
893 "deepseek-v4-flash".to_string(),
894 "deepseek-chat".to_string(),
895 "deepseek-reasoner".to_string(),
896 "hf-deepseek-v4-flash".to_string(),
897 ],
898 supports_tools: true,
899 supports_reasoning: true,
900 },
901 // ModelScope provider models
902 ModelInfo {
903 id: "Qwen/Qwen3.5-397B-A17B".to_string(),
904 provider: ProviderKind::Modelscope,
905 aliases: vec![
906 "qwen3.5-397b-a17b".to_string(),
907 "modelscope-qwen3.5-397b-a17b".to_string(),
908 ],
909 supports_tools: true,
910 supports_reasoning: true,
911 },
912 ModelInfo {
913 id: "Qwen/Qwen3.5-122B-A10B".to_string(),
914 provider: ProviderKind::Modelscope,
915 aliases: vec![
916 "qwen3.5-122b-a10b".to_string(),
917 "modelscope-qwen3.5-122b-a10b".to_string(),
918 ],
919 supports_tools: true,
920 supports_reasoning: true,
921 },
922 ModelInfo {
923 id: "Qwen/Qwen3.5-27B".to_string(),
924 provider: ProviderKind::Modelscope,
925 aliases: vec![
926 "qwen3.5-27b".to_string(),
927 "modelscope-qwen3.5-27b".to_string(),
928 ],
929 supports_tools: true,
930 supports_reasoning: true,
931 },
932 ModelInfo {
933 id: "Qwen/Qwen3.5-35B-A3B".to_string(),
934 provider: ProviderKind::Modelscope,
935 aliases: vec![
936 "qwen3.5-35b-a3b".to_string(),
937 "modelscope-qwen3.5-35b-a3b".to_string(),
938 ],
939 supports_tools: true,
940 supports_reasoning: true,
941 },
942 ModelInfo {
943 id: "Qwen/Qwen3.8-27B".to_string(),
944 provider: ProviderKind::Modelscope,
945 aliases: vec![
946 "qwen3.8-27b".to_string(),
947 "modelscope-qwen3.8-27b".to_string(),
948 ],
949 supports_tools: true,
950 supports_reasoning: true,
951 },
952 ModelInfo {
953 id: "Qwen/Qwen3.8-Flash-Next".to_string(),
954 provider: ProviderKind::Modelscope,
955 aliases: vec![
956 "qwen3.8-flash-next".to_string(),
957 "modelscope-qwen3.8-flash-next".to_string(),
958 ],
959 supports_tools: true,
960 supports_reasoning: true,
961 },
962 ModelInfo {
963 id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
964 provider: ProviderKind::Modelscope,
965 aliases: vec![
966 "deepseek-v4-pro".to_string(),
967 "modelscope-deepseek-v4-pro".to_string(),
968 ],
969 supports_tools: true,
970 supports_reasoning: true,
971 },
972 ModelInfo {
973 id: "deepseek-ai/DeepSeek-V4-Pro-0813".to_string(),
974 provider: ProviderKind::Modelscope,
975 aliases: vec![
976 "deepseek-v4-pro-0813".to_string(),
977 "modelscope-deepseek-v4-pro-0813".to_string(),
978 ],
979 supports_tools: true,
980 supports_reasoning: true,
981 },
982 ModelInfo {
983 id: "deepseek-ai/DeepSeek-V4.1-Flash".to_string(),
984 provider: ProviderKind::Modelscope,
985 aliases: vec![
986 "deepseek-v4.1-flash".to_string(),
987 "modelscope-deepseek-v4.1-flash".to_string(),
988 ],
989 supports_tools: true,
990 supports_reasoning: true,
991 },
992 ModelInfo {
993 id: "ZhipuAI/GLM-4.7-Flash".to_string(),
994 provider: ProviderKind::Modelscope,
995 aliases: vec![
996 "glm-4.7-flash".to_string(),
997 "modelscope-glm-4.7-flash".to_string(),
998 ],
999 supports_tools: true,
1000 supports_reasoning: true,
1001 },
1002 ModelInfo {
1003 id: "ZhipuAI/GLM-5.2".to_string(),
1004 provider: ProviderKind::Modelscope,
1005 aliases: vec!["glm-5.2".to_string(), "modelscope-glm-5.2".to_string()],
1006 supports_tools: true,
1007 supports_reasoning: true,
1008 },
1009 // CSDN 星图 (Starmap) — the Coding Plan's dedicated model id.
1010 // Other CSDN marketplace models resolve through pass-through.
1011 ModelInfo {
1012 id: "glm_for_coding".to_string(),
1013 provider: ProviderKind::Csdn,
1014 aliases: vec![
1015 "glm-for-coding".to_string(),
1016 "csdn-glm-for-coding".to_string(),
1017 ],
1018 supports_tools: true,
1019 supports_reasoning: true,
1020 },
1021 // Together AI provider models
1022 ModelInfo {
1023 id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
1024 provider: ProviderKind::Together,
1025 aliases: vec![
1026 "deepseek-v4-pro".to_string(),
1027 "together-deepseek-v4-pro".to_string(),
1028 ],
1029 supports_tools: true,
1030 supports_reasoning: true,
1031 },
1032 ModelInfo {
1033 id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
1034 provider: ProviderKind::Together,
1035 aliases: vec![
1036 "deepseek-v4-flash".to_string(),
1037 "deepseek-chat".to_string(),
1038 "together-deepseek-v4-flash".to_string(),
1039 ],
1040 supports_tools: true,
1041 supports_reasoning: true,
1042 },
1043 ModelInfo {
1044 // Together's published hosted endpoint is lowercase even though
1045 // the open-weight Hugging Face repository uses `Inkling`.
1046 id: "thinkingmachines/inkling".to_string(),
1047 provider: ProviderKind::Together,
1048 aliases: vec!["inkling".to_string(), "together-inkling".to_string()],
1049 supports_tools: true,
1050 supports_reasoning: true,
1051 },
1052 // Qwen 3.7 Max (OpenRouter)
1053 ModelInfo {
1054 id: "qwen/qwen3.7-max".to_string(),
1055 provider: ProviderKind::Openrouter,
1056 aliases: vec!["qwen3.7-max".to_string(), "qwen-3.7-max".to_string()],
1057 supports_tools: true,
1058 supports_reasoning: true,
1059 },
1060 // OpenAI Codex (ChatGPT OAuth) models
1061 ModelInfo {
1062 id: "gpt-5.5".to_string(),
1063 provider: ProviderKind::OpenaiCodex,
1064 aliases: vec!["codex-gpt-5.5".to_string(), "chatgpt-gpt-5.5".to_string()],
1065 supports_tools: true,
1066 supports_reasoning: true,
1067 },
1068 // Anthropic native Messages API models (#3014)
1069 ModelInfo {
1070 id: "claude-opus-4-8".to_string(),
1071 provider: ProviderKind::Anthropic,
1072 aliases: vec!["opus".to_string(), "claude-opus".to_string()],
1073 supports_tools: true,
1074 supports_reasoning: true,
1075 },
1076 // Claude Opus 5 (GA 2026-07-24; API id/alias `claude-opus-5`, 1M
1077 // context / 128K output per
1078 // https://platform.claude.com/docs/en/about-claude/models/overview).
1079 ModelInfo {
1080 id: "claude-opus-5".to_string(),
1081 provider: ProviderKind::Anthropic,
1082 aliases: vec!["opus-5".to_string()],
1083 supports_tools: true,
1084 supports_reasoning: true,
1085 },
1086 ModelInfo {
1087 id: "claude-sonnet-4-6".to_string(),
1088 provider: ProviderKind::Anthropic,
1089 aliases: vec!["sonnet".to_string(), "claude-sonnet".to_string()],
1090 supports_tools: true,
1091 supports_reasoning: true,
1092 },
1093 ModelInfo {
1094 id: "claude-haiku-4-5".to_string(),
1095 provider: ProviderKind::Anthropic,
1096 aliases: vec!["haiku".to_string(), "claude-haiku".to_string()],
1097 supports_tools: true,
1098 supports_reasoning: false,
1099 },
1100 ModelInfo {
1101 id: "claude-sonnet-5".to_string(),
1102 provider: ProviderKind::Anthropic,
1103 aliases: vec!["sonnet-5".to_string()],
1104 supports_tools: true,
1105 supports_reasoning: true,
1106 },
1107 ModelInfo {
1108 id: "claude-fable-5".to_string(),
1109 provider: ProviderKind::Anthropic,
1110 aliases: vec!["fable".to_string(), "fable-5".to_string()],
1111 supports_tools: true,
1112 supports_reasoning: true,
1113 },
1114 // OpenModel Anthropic-compatible Messages route
1115 ModelInfo {
1116 id: "deepseek-v4-flash".to_string(),
1117 provider: ProviderKind::Openmodel,
1118 aliases: vec!["openmodel".to_string(), "openmodel-deepseek".to_string()],
1119 supports_tools: true,
1120 supports_reasoning: true,
1121 },
1122 // MiniMax 2.7 (OpenRouter)
1123 ModelInfo {
1124 id: "minimax/minimax-m2.7".to_string(),
1125 provider: ProviderKind::Openrouter,
1126 aliases: vec![
1127 "minimax-2.7".to_string(),
1128 "minimax-2-7".to_string(),
1129 "openrouter-minimax-2.7".to_string(),
1130 ],
1131 supports_tools: true,
1132 supports_reasoning: true,
1133 },
1134 ModelInfo {
1135 id: "step-3.7-flash".to_string(),
1136 provider: ProviderKind::Stepfun,
1137 aliases: vec!["stepfun".to_string(), "stepflash".to_string()],
1138 supports_tools: true,
1139 supports_reasoning: true,
1140 },
1141 ModelInfo {
1142 id: "step-5-preview".to_string(),
1143 provider: ProviderKind::Stepfun,
1144 aliases: vec![],
1145 supports_tools: true,
1146 supports_reasoning: true,
1147 },
1148 ModelInfo {
1149 id: "step-3.5-flash".to_string(),
1150 provider: ProviderKind::Stepfun,
1151 aliases: vec![],
1152 supports_tools: true,
1153 supports_reasoning: true,
1154 },
1155 ModelInfo {
1156 id: "step-3.5-flash-2603".to_string(),
1157 provider: ProviderKind::Stepfun,
1158 aliases: vec![],
1159 supports_tools: true,
1160 supports_reasoning: true,
1161 },
1162 ModelInfo {
1163 id: "MiniMax-M3".to_string(),
1164 provider: ProviderKind::Minimax,
1165 aliases: vec![
1166 "minimax".to_string(),
1167 "minimax-m3".to_string(),
1168 "minimax-m-3".to_string(),
1169 ],
1170 supports_tools: true,
1171 supports_reasoning: true,
1172 },
1173 ModelInfo {
1174 id: "MiniMax-M2.7".to_string(),
1175 provider: ProviderKind::Minimax,
1176 aliases: vec![
1177 "minimax-m2.7".to_string(),
1178 "minimax-m2-7".to_string(),
1179 "minimax-m-2.7".to_string(),
1180 "minimax-m-2-7".to_string(),
1181 ],
1182 supports_tools: true,
1183 supports_reasoning: true,
1184 },
1185 ModelInfo {
1186 id: "MiniMax-M3".to_string(),
1187 provider: ProviderKind::MinimaxAnthropic,
1188 aliases: vec![
1189 "minimax-anthropic".to_string(),
1190 "minimax-anthropic-m3".to_string(),
1191 "minimax-m3".to_string(),
1192 ],
1193 supports_tools: true,
1194 supports_reasoning: true,
1195 },
1196 ModelInfo {
1197 id: "MiniMax-M2.7".to_string(),
1198 provider: ProviderKind::MinimaxAnthropic,
1199 aliases: vec![
1200 "minimax-anthropic-m2.7".to_string(),
1201 "minimax-anthropic-m2-7".to_string(),
1202 "minimax-m2.7".to_string(),
1203 ],
1204 supports_tools: true,
1205 supports_reasoning: true,
1206 },
1207 ModelInfo {
1208 id: "MiniMax-M2.7-highspeed".to_string(),
1209 provider: ProviderKind::Minimax,
1210 aliases: vec![
1211 "minimax-m2.7-highspeed".to_string(),
1212 "minimax-m2-7-highspeed".to_string(),
1213 "minimax-m-2.7-highspeed".to_string(),
1214 "minimax-m-2-7-highspeed".to_string(),
1215 ],
1216 supports_tools: true,
1217 supports_reasoning: true,
1218 },
1219 ModelInfo {
1220 id: "MiniMax-M2.5".to_string(),
1221 provider: ProviderKind::Minimax,
1222 aliases: vec![
1223 "minimax-m2.5".to_string(),
1224 "minimax-m2-5".to_string(),
1225 "minimax-m-2.5".to_string(),
1226 "minimax-m-2-5".to_string(),
1227 ],
1228 supports_tools: true,
1229 supports_reasoning: true,
1230 },
1231 ModelInfo {
1232 id: "MiniMax-M2.5-highspeed".to_string(),
1233 provider: ProviderKind::Minimax,
1234 aliases: vec![
1235 "minimax-m2.5-highspeed".to_string(),
1236 "minimax-m2-5-highspeed".to_string(),
1237 "minimax-m-2.5-highspeed".to_string(),
1238 "minimax-m-2-5-highspeed".to_string(),
1239 ],
1240 supports_tools: true,
1241 supports_reasoning: true,
1242 },
1243 ModelInfo {
1244 id: "MiniMax-M2.1".to_string(),
1245 provider: ProviderKind::Minimax,
1246 aliases: vec![
1247 "minimax-m2.1".to_string(),
1248 "minimax-m2-1".to_string(),
1249 "minimax-m-2.1".to_string(),
1250 "minimax-m-2-1".to_string(),
1251 ],
1252 supports_tools: true,
1253 supports_reasoning: true,
1254 },
1255 ModelInfo {
1256 id: "MiniMax-M2.1-highspeed".to_string(),
1257 provider: ProviderKind::Minimax,
1258 aliases: vec![
1259 "minimax-m2.1-highspeed".to_string(),
1260 "minimax-m2-1-highspeed".to_string(),
1261 "minimax-m-2.1-highspeed".to_string(),
1262 "minimax-m-2-1-highspeed".to_string(),
1263 ],
1264 supports_tools: true,
1265 supports_reasoning: true,
1266 },
1267 ModelInfo {
1268 id: "MiniMax-M2".to_string(),
1269 provider: ProviderKind::Minimax,
1270 aliases: vec!["minimax-m2".to_string(), "minimax-m-2".to_string()],
1271 supports_tools: true,
1272 supports_reasoning: true,
1273 },
1274 // NVIDIA Nemotron 3 Ultra (OpenRouter)
1275 ModelInfo {
1276 id: "nvidia/nemotron-3-ultra-550b-a55b".to_string(),
1277 provider: ProviderKind::Openrouter,
1278 aliases: vec![
1279 "nvidia/nemotron-3-ultra".to_string(),
1280 "nemotron-3-ultra".to_string(),
1281 "nemotron-3-ultra-550b-a55b".to_string(),
1282 "nvidia-nemotron-3-ultra".to_string(),
1283 "nvidia-nemotron-3-ultra-550b-a55b".to_string(),
1284 ],
1285 supports_tools: true,
1286 supports_reasoning: true,
1287 },
1288 // DeepInfra (https://deepinfra.com)
1289 ModelInfo {
1290 id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
1291 provider: ProviderKind::Deepinfra,
1292 aliases: vec![
1293 "deepseek-v4-pro".to_string(),
1294 "di-deepseek-v4-pro".to_string(),
1295 ],
1296 supports_tools: true,
1297 supports_reasoning: true,
1298 },
1299 ModelInfo {
1300 id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
1301 provider: ProviderKind::Deepinfra,
1302 aliases: vec![
1303 "deepseek-v4-flash".to_string(),
1304 "di-deepseek-v4-flash".to_string(),
1305 ],
1306 supports_tools: true,
1307 supports_reasoning: true,
1308 },
1309 // Sakana AI Fugu (https://api.sakana.ai)
1310 ModelInfo {
1311 id: "fugu".to_string(),
1312 provider: ProviderKind::Sakana,
1313 aliases: vec!["sakana-fugu".to_string(), "sakana/fugu".to_string()],
1314 supports_tools: true,
1315 supports_reasoning: false,
1316 },
1317 ModelInfo {
1318 id: "fugu-ultra-20260615".to_string(),
1319 provider: ProviderKind::Sakana,
1320 aliases: vec!["fugu-ultra".to_string(), "sakana-fugu-ultra".to_string()],
1321 supports_tools: true,
1322 supports_reasoning: true,
1323 },
1324 // Meituan LongCat (https://longcat.chat/platform)
1325 ModelInfo {
1326 id: "LongCat-2.0".to_string(),
1327 provider: ProviderKind::LongCat,
1328 aliases: vec!["longcat".to_string(), "longcat-2.0".to_string()],
1329 supports_tools: true,
1330 supports_reasoning: true,
1331 },
1332 // Meta Model API / Muse Spark. Keep these in step with
1333 // `DEFAULT_META_MODEL` in config's provider_defaults and with the
1334 // bundled models.dev catalog: this registry resolves the `muse`
1335 // aliases for the CLI and app-server, so a stale id here silently
1336 // routes them somewhere the configured default never points.
1337 ModelInfo {
1338 id: "muse-spark-1.2".to_string(),
1339 provider: ProviderKind::Meta,
1340 aliases: vec!["muse-spark".to_string(), "muse".to_string()],
1341 supports_tools: true,
1342 supports_reasoning: true,
1343 },
1344 ModelInfo {
1345 id: "muse-spark-1.2-contributor".to_string(),
1346 provider: ProviderKind::Meta,
1347 aliases: vec!["muse-spark-contributor".to_string()],
1348 supports_tools: true,
1349 supports_reasoning: true,
1350 },
1351 // xAI / Grok (https://api.x.ai/v1)
1352 ModelInfo {
1353 id: "grok-4.6".to_string(),
1354 provider: ProviderKind::Xai,
1355 aliases: vec!["grok".to_string()],
1356 supports_tools: true,
1357 supports_reasoning: true,
1358 },
1359 ModelInfo {
1360 id: "grok-4.5".to_string(),
1361 provider: ProviderKind::Xai,
1362 aliases: vec!["xai-grok-4.5".to_string()],
1363 supports_tools: true,
1364 supports_reasoning: true,
1365 },
1366 ModelInfo {
1367 id: "grok-4.3".to_string(),
1368 provider: ProviderKind::Xai,
1369 aliases: vec!["xai-grok-4.3".to_string()],
1370 supports_tools: true,
1371 supports_reasoning: true,
1372 },
1373 ModelInfo {
1374 id: "grok-build".to_string(),
1375 provider: ProviderKind::Xai,
1376 aliases: vec!["xai-grok-build".to_string()],
1377 supports_tools: true,
1378 supports_reasoning: true,
1379 },
1380 ModelInfo {
1381 id: "grok-composer-2.5-fast".to_string(),
1382 provider: ProviderKind::Xai,
1383 aliases: vec!["xai-grok-composer".to_string()],
1384 supports_tools: true,
1385 supports_reasoning: false,
1386 },
1387 ModelInfo {
1388 id: "grok-4.20-0309-reasoning".to_string(),
1389 provider: ProviderKind::Xai,
1390 aliases: vec!["xai-grok-reasoning".to_string()],
1391 supports_tools: true,
1392 supports_reasoning: true,
1393 },
1394 ModelInfo {
1395 id: "grok-4.20-0309-non-reasoning".to_string(),
1396 provider: ProviderKind::Xai,
1397 aliases: vec!["xai-grok-fast".to_string()],
1398 supports_tools: true,
1399 supports_reasoning: false,
1400 },
1401 ModelInfo {
1402 id: "gemini-3.1-pro-preview".to_string(),
1403 provider: ProviderKind::Google,
1404 aliases: vec!["gemini-3.1-pro".to_string()],
1405 supports_tools: true,
1406 supports_reasoning: true,
1407 },
1408 ModelInfo {
1409 id: "gemini-3-pro-preview".to_string(),
1410 provider: ProviderKind::Google,
1411 aliases: vec!["gemini-3-pro".to_string()],
1412 supports_tools: true,
1413 supports_reasoning: true,
1414 },
1415 // Gemini 3.7 Flash (2026-08 latest Flash; 1,048,576 in / 65,536 out,
1416 // https://ai.google.dev/gemini-api/docs/models/gemini-3.7-flash).
1417 ModelInfo {
1418 id: "gemini-3.7-flash".to_string(),
1419 provider: ProviderKind::Google,
1420 aliases: vec![],
1421 supports_tools: true,
1422 supports_reasoning: true,
1423 },
1424 ModelInfo {
1425 id: "gemini-3.6-flash".to_string(),
1426 provider: ProviderKind::Google,
1427 aliases: vec![],
1428 supports_tools: true,
1429 supports_reasoning: true,
1430 },
1431 ModelInfo {
1432 id: "gemini-3.5-flash".to_string(),
1433 provider: ProviderKind::Google,
1434 aliases: vec![],
1435 supports_tools: true,
1436 supports_reasoning: true,
1437 },
1438 ModelInfo {
1439 id: "gemini-3.5-flash-lite".to_string(),
1440 provider: ProviderKind::Google,
1441 aliases: vec![],
1442 supports_tools: true,
1443 supports_reasoning: false,
1444 },
1445 ModelInfo {
1446 id: "gemini-2.5-pro".to_string(),
1447 provider: ProviderKind::Google,
1448 aliases: vec![],
1449 supports_tools: true,
1450 supports_reasoning: true,
1451 },
1452 ModelInfo {
1453 id: "gemini-2.5-flash".to_string(),
1454 provider: ProviderKind::Google,
1455 aliases: vec![],
1456 supports_tools: true,
1457 supports_reasoning: true,
1458 },
1459 ModelInfo {
1460 id: "mistral-code-latest".to_string(),
1461 provider: ProviderKind::Mistral,
1462 aliases: vec![
1463 "codestral".to_string(),
1464 "codestral-latest".to_string(),
1465 "mistral-code".to_string(),
1466 ],
1467 supports_tools: true,
1468 supports_reasoning: false,
1469 },
1470 ModelInfo {
1471 id: "mistral-medium-latest".to_string(),
1472 provider: ProviderKind::Mistral,
1473 aliases: vec![
1474 "mistral-medium".to_string(),
1475 "mistral-medium-3-5".to_string(),
1476 ],
1477 supports_tools: true,
1478 supports_reasoning: true,
1479 },
1480 ModelInfo {
1481 id: "mistral-small-latest".to_string(),
1482 provider: ProviderKind::Mistral,
1483 aliases: vec![
1484 "mistral-small".to_string(),
1485 "mistral-small-2603".to_string(),
1486 ],
1487 supports_tools: true,
1488 supports_reasoning: true,
1489 },
1490 ModelInfo {
1491 id: "magistral-small-latest".to_string(),
1492 provider: ProviderKind::Mistral,
1493 aliases: vec!["magistral".to_string(), "magistral-small".to_string()],
1494 supports_tools: true,
1495 supports_reasoning: true,
1496 },
1497 ModelInfo {
1498 id: "mistral-large-latest".to_string(),
1499 provider: ProviderKind::Mistral,
1500 aliases: vec!["mistral-large".to_string()],
1501 supports_tools: true,
1502 supports_reasoning: false,
1503 },
1504 ];
1505 // The provider-owned roster is shared with config, routing and the picker.
1506 models.extend(codewhale_config::opencode_go_models().iter().map(|&id| {
1507 // Preserve the existing reviewed flags. Roster membership alone
1508 // proves neither capability; false withholds a positive assertion
1509 // for new models because ModelInfo cannot express unknown.
1510 let reviewed_capabilities = matches!(
1511 id,
1512 "deepseek-v4-pro"
1513 | "grok-4.5"
1514 | "glm-5.2"
1515 | "glm-5.1"
1516 | "kimi-k3"
1517 | "kimi-k2.7-code"
1518 | "kimi-k2.6"
1519 | "deepseek-v4-flash"
1520 | "mimo-v2.5"
1521 | "mimo-v2.5-pro"
1522 );
1523 ModelInfo {
1524 id: id.to_string(),
1525 provider: ProviderKind::OpencodeGo,
1526 aliases: vec![format!("opencode-go/{id}")],
1527 supports_tools: reviewed_capabilities,
1528 supports_reasoning: reviewed_capabilities,
1529 }
1530 }));
1531 Self::new(models)
1532 }
1533 }
1534
1535 impl ModelRegistry {
1536 /// Creates a new registry from a list of [`ModelInfo`] entries.
1537 ///
1538 #[must_use]
1539 pub fn new(models: Vec<ModelInfo>) -> Self {
1540 Self { models }
1541 }
1542
1543 /// Returns a clone of all models in the registry.
1544 #[must_use]
1545 pub fn list(&self) -> Vec<ModelInfo> {
1546 self.models.clone()
1547 }
1548
1549 /// Returns whether a selector is known only outside the selected provider.
1550 ///
1551 /// This is rejection metadata, never route authority: callers may use it
1552 /// to reject a clearly foreign model, but must not use the matching row to
1553 /// select a provider or credential slot.
1554 #[must_use]
1555 pub fn is_known_for_other_provider(
1556 &self,
1557 requested: &str,
1558 selected_provider: ProviderKind,
1559 ) -> bool {
1560 let known_here = self
1561 .models
1562 .iter()
1563 .any(|model| model.provider == selected_provider && model_matches(model, requested));
1564 !known_here
1565 && self
1566 .models
1567 .iter()
1568 .any(|model| model.provider != selected_provider && model_matches(model, requested))
1569 }
1570
1571 /// Resolves a user-requested model name to a concrete [`ModelInfo`].
1572 ///
1573 /// Resolution follows this priority order:
1574 /// 1. If the provider is Ollama, the requested name is used as-is (to
1575 /// support arbitrary local model tags like `qwen2.5-coder:7b`).
1576 /// 2. If a `provider_hint` is given, search for a model matching that
1577 /// provider whose id or alias matches the request (case-insensitive).
1578 /// 3. Provider-specific pass-through contracts may preserve arbitrary
1579 /// model ids.
1580 /// 4. An omitted model falls back to the explicitly selected provider's
1581 /// documented default.
1582 /// 5. A requested model outside that provider fails closed. Model text is
1583 /// metadata and never authorizes a provider or credential switch.
1584 pub fn resolve(
1585 &self,
1586 requested: Option<&str>,
1587 provider_hint: Option<ProviderKind>,
1588 ) -> Result<ModelResolution, ModelResolutionError> {
1589 let requested = requested.filter(|name| !name.trim().is_empty());
1590 let mut fallback_chain = Vec::new();
1591 let Some(provider) = provider_hint else {
1592 return Err(ModelResolutionError::ProviderRequired {
1593 requested: requested.map(ToOwned::to_owned),
1594 });
1595 };
1596
1597 if let Some(name) = requested {
1598 fallback_chain.push(format!("requested:{name}"));
1599 if matches!(
1600 provider_hint,
1601 Some(ProviderKind::Ollama | ProviderKind::OllamaCloud)
1602 ) {
1603 return Ok(ModelResolution {
1604 requested: Some(name.to_string()),
1605 resolved: ModelInfo {
1606 id: name.trim().to_string(),
1607 provider: provider_hint.expect("matched provider hint"),
1608 aliases: Vec::new(),
1609 supports_tools: true,
1610 supports_reasoning: false,
1611 },
1612 used_fallback: false,
1613 fallback_chain,
1614 });
1615 }
1616 // Resolve within Go's roster without falling through to a same-named
1617 // model on another provider.
1618 if provider_hint == Some(ProviderKind::OpencodeGo)
1619 && let Some(canonical) = opencode_go_model_id(name)
1620 && let Some(model) = self
1621 .models
1622 .iter()
1623 .find(|model| {
1624 model.provider == ProviderKind::OpencodeGo
1625 && model.id.eq_ignore_ascii_case(canonical)
1626 })
1627 .cloned()
1628 {
1629 return Ok(ModelResolution {
1630 requested: Some(name.to_string()),
1631 resolved: model,
1632 used_fallback: false,
1633 fallback_chain,
1634 });
1635 }
1636 if provider_hint != Some(ProviderKind::OpencodeGo)
1637 && let Some(provider) = provider_hint
1638 && let Some(model) = self
1639 .models
1640 .iter()
1641 .find(|m| m.provider == provider && model_matches(m, name))
1642 .cloned()
1643 {
1644 return Ok(ModelResolution {
1645 requested: Some(name.to_string()),
1646 resolved: model,
1647 used_fallback: false,
1648 fallback_chain,
1649 });
1650 }
1651 if provider_hint == Some(ProviderKind::Atlascloud)
1652 && let Some(model) = atlascloud_passthrough_model(name)
1653 {
1654 return Ok(ModelResolution {
1655 requested: Some(name.to_string()),
1656 resolved: model,
1657 used_fallback: false,
1658 fallback_chain,
1659 });
1660 }
1661 if provider_hint == Some(ProviderKind::Arcee)
1662 && let Some(model) = arcee_passthrough_model(name)
1663 {
1664 return Ok(ModelResolution {
1665 requested: Some(name.to_string()),
1666 resolved: model,
1667 used_fallback: false,
1668 fallback_chain,
1669 });
1670 }
1671 if provider_hint == Some(ProviderKind::XiaomiMimo)
1672 && let Some(model) = xiaomi_mimo_passthrough_model(name)
1673 {
1674 return Ok(ModelResolution {
1675 requested: Some(name.to_string()),
1676 resolved: model,
1677 used_fallback: false,
1678 fallback_chain,
1679 });
1680 }
1681 // A provider's own declared default is available from that
1682 // provider by definition — the descriptor owns that fact (#6443:
1683 // `deepseek-flash` is the Deepseek default and resolved nowhere).
1684 // Registry rows canonicalize aliases and carry capability
1685 // metadata; they must not gate the name the provider declares.
1686 let declared_default = provider.provider().default_model();
1687 if !declared_default.trim().is_empty()
1688 && name.trim().eq_ignore_ascii_case(declared_default.trim())
1689 {
1690 return Ok(ModelResolution {
1691 requested: Some(name.to_string()),
1692 resolved: Self::descriptor_default_model(provider, declared_default),
1693 used_fallback: false,
1694 fallback_chain,
1695 });
1696 }
1697 if !self.models.iter().any(|model| model.provider == provider) {
1698 return Err(ModelResolutionError::ProviderHasNoModels {
1699 provider,
1700 requested: Some(name.to_string()),
1701 });
1702 }
1703 return Err(ModelResolutionError::ModelNotAvailableForProvider {
1704 provider,
1705 requested: name.to_string(),
1706 });
1707 }
1708
1709 fallback_chain.push(format!("provider_default:{}", provider.as_str()));
1710 let default_model = provider.provider().default_model();
1711 if let Some(model) = self
1712 .models
1713 .iter()
1714 .find(|model| model.provider == provider && model_matches(model, default_model))
1715 .cloned()
1716 {
1717 return Ok(ModelResolution {
1718 requested: None,
1719 resolved: model,
1720 used_fallback: true,
1721 fallback_chain,
1722 });
1723 }
1724 // Same rule as the explicit branch: the descriptor's declared default
1725 // resolves for its own provider even without a registry row (#6443).
1726 // Ollama is the exception: its descriptor default is the placeholder
1727 // `unknown`, and the real default comes from the live local catalog
1728 // (Y-2), so a placeholder must never resolve as a model.
1729 if !default_model.trim().is_empty() && provider != ProviderKind::Ollama {
1730 return Ok(ModelResolution {
1731 requested: None,
1732 resolved: Self::descriptor_default_model(provider, default_model),
1733 used_fallback: true,
1734 fallback_chain,
1735 });
1736 }
1737 if !self.models.iter().any(|model| model.provider == provider) {
1738 return Err(ModelResolutionError::ProviderHasNoModels {
1739 provider,
1740 requested: None,
1741 });
1742 }
1743
1744 Err(ModelResolutionError::ProviderDefaultUnavailable {
1745 provider,
1746 default_model: default_model.to_string(),
1747 })
1748 }
1749
1750 /// The [`ModelInfo`] a provider's declared default resolves to when the
1751 /// registry carries no explicit row for it. The descriptor owns the
1752 /// identity; capability metadata stays conservative rather than
1753 /// fabricating a capability the registry never recorded.
1754 fn descriptor_default_model(provider: ProviderKind, id: &str) -> ModelInfo {
1755 ModelInfo {
1756 id: id.trim().to_string(),
1757 provider,
1758 aliases: Vec::new(),
1759 supports_tools: true,
1760 supports_reasoning: false,
1761 }
1762 }
1763 }
1764
1765 fn normalize(value: &str) -> String {
1766 value.trim().to_ascii_lowercase()
1767 }
1768
1769 #[must_use]
1770 /// Classify a model identifier by its underlying model family.
1771 pub fn model_family(model_id: &str) -> ModelFamily {
1772 let normalized = normalize(model_id);
1773 if normalized.is_empty() {
1774 return ModelFamily::Inferencer;
1775 }
1776
1777 if normalized.contains("deepseek") {
1778 return ModelFamily::DeepSeek;
1779 }
1780 if normalized.contains("claude") || normalized.contains("anthropic") {
1781 return ModelFamily::Anthropic;
1782 }
1783 if normalized.contains("gpt-oss") || normalized.contains("gpt_oss") {
1784 return ModelFamily::GptOss;
1785 }
1786 if normalized.starts_with("gpt-")
1787 || normalized.contains("/gpt-")
1788 || normalized.contains("openai/")
1789 {
1790 return ModelFamily::OpenAI;
1791 }
1792 if normalized.contains("gemini")
1793 || normalized.contains("gemma")
1794 || normalized.contains("google/")
1795 {
1796 return ModelFamily::Google;
1797 }
1798 if normalized.contains("llama")
1799 || normalized.contains("muse-spark")
1800 || normalized.contains("meta-")
1801 || normalized.contains("meta/")
1802 {
1803 return ModelFamily::Meta;
1804 }
1805 if normalized.contains("mistral")
1806 || normalized.contains("mixtral")
1807 || normalized.contains("codestral")
1808 {
1809 return ModelFamily::Mistral;
1810 }
1811 if normalized.contains("qwen") {
1812 return ModelFamily::Qwen;
1813 }
1814 if normalized.contains("grok") {
1815 return ModelFamily::Grok;
1816 }
1817 if normalized.contains("cohere") || normalized.contains("command-r") {
1818 return ModelFamily::Cohere;
1819 }
1820
1821 ModelFamily::Inferencer
1822 }
1823
1824 fn model_matches(model: &ModelInfo, requested: &str) -> bool {
1825 let requested = normalize(requested);
1826 normalize(&model.id) == requested
1827 || model
1828 .aliases
1829 .iter()
1830 .any(|alias| normalize(alias) == requested)
1831 }
1832
1833 fn atlascloud_passthrough_model(requested: &str) -> Option<ModelInfo> {
1834 let requested = requested.trim();
1835 if requested.is_empty() || !requested.contains('/') {
1836 return None;
1837 }
1838
1839 Some(ModelInfo {
1840 id: requested.to_string(),
1841 provider: ProviderKind::Atlascloud,
1842 aliases: Vec::new(),
1843 supports_tools: true,
1844 supports_reasoning: true,
1845 })
1846 }
1847
1848 fn arcee_passthrough_model(requested: &str) -> Option<ModelInfo> {
1849 let requested = requested.trim();
1850 if requested.is_empty() {
1851 return None;
1852 }
1853 let supports_reasoning = requested.to_ascii_lowercase().contains("thinking");
1854
1855 Some(ModelInfo {
1856 id: requested.to_string(),
1857 provider: ProviderKind::Arcee,
1858 aliases: Vec::new(),
1859 supports_tools: true,
1860 supports_reasoning,
1861 })
1862 }
1863
1864 fn xiaomi_mimo_passthrough_model(requested: &str) -> Option<ModelInfo> {
1865 let requested = requested.trim();
1866 if requested.is_empty() || requested.chars().any(char::is_control) {
1867 return None;
1868 }
1869
1870 Some(ModelInfo {
1871 id: requested.to_string(),
1872 provider: ProviderKind::XiaomiMimo,
1873 aliases: Vec::new(),
1874 supports_tools: true,
1875 supports_reasoning: true,
1876 })
1877 }
1878
1879 #[cfg(test)]
1880 mod tests {
1881 use super::*;
1882
1883 trait ModelRegistryTestExt {
1884 fn resolve_ok(
1885 &self,
1886 requested: Option<&str>,
1887 provider_hint: Option<ProviderKind>,
1888 ) -> ModelResolution;
1889 }
1890
1891 impl ModelRegistryTestExt for ModelRegistry {
1892 fn resolve_ok(
1893 &self,
1894 requested: Option<&str>,
1895 provider_hint: Option<ProviderKind>,
1896 ) -> ModelResolution {
1897 self.resolve(requested, provider_hint)
1898 .expect("test route should resolve")
1899 }
1900 }
1901
1902 #[test]
1903 fn model_registry_new_preserves_model_rows_and_aliases() {
1904 let models = vec![
1905 ModelInfo {
1906 id: "Model-A".to_string(),
1907 provider: ProviderKind::Deepseek,
1908 aliases: vec!["alias-1".to_string(), " ALIAS-2 ".to_string()],
1909 supports_tools: true,
1910 supports_reasoning: false,
1911 },
1912 ModelInfo {
1913 id: "model-b".to_string(),
1914 provider: ProviderKind::Deepseek,
1915 aliases: vec!["alias-1".to_string()],
1916 supports_tools: true,
1917 supports_reasoning: true,
1918 },
1919 ];
1920
1921 let registry = ModelRegistry::new(models);
1922
1923 let rows = registry.list();
1924 assert_eq!(rows.len(), 2);
1925 assert_eq!(rows[0].id, "Model-A");
1926 assert_eq!(rows[0].aliases, ["alias-1", " ALIAS-2 "]);
1927 assert_eq!(rows[1].id, "model-b");
1928 }
1929
1930 #[test]
1931 fn deepseek_v4_pro_alias_stays_deepseek_when_provider_selected() {
1932 let registry = ModelRegistry::default();
1933 let resolved = registry.resolve_ok(Some("deepseek-v4-pro"), Some(ProviderKind::Deepseek));
1934
1935 assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
1936 assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
1937 }
1938
1939 #[test]
1940 fn providerless_unknown_model_requires_explicit_route_authority() {
1941 let registry = ModelRegistry::default();
1942
1943 for requested in [None, Some("deepseek-v4-pro"), Some("not-in-the-catalog")] {
1944 let error = ModelRegistry::resolve(&registry, requested, None)
1945 .expect_err("provider-less fallback must fail closed");
1946 assert_eq!(
1947 error,
1948 ModelResolutionError::ProviderRequired {
1949 requested: requested.map(str::to_string),
1950 }
1951 );
1952 if requested.is_none() || requested == Some("not-in-the-catalog") {
1953 assert!(!error.to_string().to_ascii_lowercase().contains("deepseek"));
1954 }
1955 }
1956 }
1957
1958 #[test]
1959 fn providerless_unknown_selectors_never_mint_provider_authority() {
1960 let registry = ModelRegistry::default();
1961
1962 for requested in [
1963 "deepseek-v4-not-a-real-model",
1964 "gpt-not-a-real-model",
1965 "provider/model-that-does-not-exist",
1966 ] {
1967 assert!(matches!(
1968 ModelRegistry::resolve(&registry, Some(requested), None),
1969 Err(ModelResolutionError::ProviderRequired {
1970 requested: Some(returned),
1971 }) if returned == requested
1972 ));
1973 }
1974 for requested in ["", " "] {
1975 assert!(matches!(
1976 ModelRegistry::resolve(&registry, Some(requested), None),
1977 Err(ModelResolutionError::ProviderRequired { requested: None })
1978 ));
1979 }
1980 }
1981
1982 #[test]
1983 fn explicit_provider_with_no_registry_rows_never_borrows_global_default() {
1984 let registry = ModelRegistry::new(Vec::new());
1985
1986 let error = ModelRegistry::resolve(
1987 &registry,
1988 Some("provider-owned-model"),
1989 Some(ProviderKind::Openrouter),
1990 )
1991 .expect_err("an empty provider catalog must not borrow another route");
1992 assert_eq!(
1993 error,
1994 ModelResolutionError::ProviderHasNoModels {
1995 provider: ProviderKind::Openrouter,
1996 requested: Some("provider-owned-model".to_string()),
1997 }
1998 );
1999 assert!(!error.to_string().to_ascii_lowercase().contains("deepseek"));
2000 }
2001
2002 #[test]
2003 fn explicit_deepseek_selection_retains_its_provider_owned_default() {
2004 let registry = ModelRegistry::default();
2005 let resolved = registry.resolve_ok(None, Some(ProviderKind::Deepseek));
2006
2007 assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
2008 // The descriptor's declared default, not the first cloud row: the
2009 // registry's Deepseek rows start at v4-pro, and borrowing that here is
2010 // exactly the mismatch #6443 fixed.
2011 assert_eq!(resolved.resolved.id, "deepseek-flash");
2012 assert!(resolved.used_fallback);
2013 assert_eq!(resolved.fallback_chain, ["provider_default:deepseek"]);
2014 }
2015
2016 #[test]
2017 fn explicit_openai_selection_uses_its_documented_default_not_first_catalog_row() {
2018 let registry = ModelRegistry::default();
2019
2020 for requested in [None, Some(""), Some(" ")] {
2021 let resolved = registry.resolve_ok(requested, Some(ProviderKind::Openai));
2022 assert_eq!(resolved.requested, None);
2023 assert_eq!(resolved.resolved.provider, ProviderKind::Openai);
2024 assert_eq!(resolved.resolved.id, "gpt-5.6");
2025 assert!(resolved.used_fallback);
2026 assert_eq!(resolved.fallback_chain, ["provider_default:openai"]);
2027 }
2028 }
2029
2030 #[test]
2031 fn provider_default_without_a_registry_row_resolves_to_its_own_id() {
2032 // SHA-6443: the descriptor owns its declared default. A registry that
2033 // carries unrelated rows must still resolve the provider's own
2034 // default — and must never borrow another provider's model.
2035 let registry = ModelRegistry::new(vec![ModelInfo {
2036 id: "not-the-openai-default".to_string(),
2037 provider: ProviderKind::Openai,
2038 aliases: Vec::new(),
2039 supports_tools: true,
2040 supports_reasoning: true,
2041 }]);
2042
2043 let resolved = registry
2044 .resolve(None, Some(ProviderKind::Openai))
2045 .expect("the provider's declared default resolves for that provider");
2046 assert_eq!(resolved.resolved.id, "gpt-5.6");
2047 assert_eq!(resolved.resolved.provider, ProviderKind::Openai);
2048 assert!(resolved.used_fallback);
2049 }
2050
2051 #[test]
2052 fn deepseek_vision_model_lists_and_resolves_with_aliases() {
2053 let registry = ModelRegistry::default();
2054 let listed = registry.list();
2055
2056 assert!(listed.iter().any(|model| {
2057 model.provider == ProviderKind::Deepseek
2058 && model.id == "deepseek-v4-flash-vision-exp"
2059 && model.aliases
2060 == [
2061 "flash-vision".to_string(),
2062 "deepseek-v4flashvisionexp".to_string(),
2063 ]
2064 }));
2065
2066 for selector in [
2067 "deepseek-v4-flash-vision-exp",
2068 "flash-vision",
2069 "deepseek-v4flashvisionexp",
2070 ] {
2071 let resolved = registry.resolve_ok(Some(selector), Some(ProviderKind::Deepseek));
2072 assert_eq!(
2073 resolved.resolved.id, "deepseek-v4-flash-vision-exp",
2074 "{selector} must resolve to the experimental vision model"
2075 );
2076 assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
2077 assert!(!resolved.used_fallback, "{selector} must not fall back");
2078 }
2079 }
2080
2081 #[test]
2082 fn deepseek_v4_pro_alias_resolves_to_nvidia_nim_when_provider_hinted() {
2083 let registry = ModelRegistry::default();
2084 let resolved = registry.resolve_ok(Some("deepseek-v4-pro"), Some(ProviderKind::NvidiaNim));
2085
2086 assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
2087 assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
2088 }
2089
2090 #[test]
2091 fn nvidia_nim_default_uses_catalog_model_id() {
2092 let registry = ModelRegistry::default();
2093 let resolved = registry.resolve_ok(None, Some(ProviderKind::NvidiaNim));
2094
2095 assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
2096 assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
2097 }
2098
2099 #[test]
2100 fn deepseek_v4_flash_alias_resolves_to_nvidia_nim_when_provider_hinted() {
2101 let registry = ModelRegistry::default();
2102 let resolved =
2103 registry.resolve_ok(Some("deepseek-v4-flash"), Some(ProviderKind::NvidiaNim));
2104
2105 assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
2106 assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
2107 }
2108
2109 #[test]
2110 fn atlascloud_default_uses_namespaced_model_id() {
2111 let registry = ModelRegistry::default();
2112 let resolved = registry.resolve_ok(None, Some(ProviderKind::Atlascloud));
2113
2114 assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
2115 assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
2116 assert!(resolved.resolved.supports_reasoning);
2117 }
2118
2119 #[test]
2120 fn deepseek_v4_flash_alias_resolves_to_atlascloud_when_provider_hinted() {
2121 let registry = ModelRegistry::default();
2122 let resolved =
2123 registry.resolve_ok(Some("deepseek-v4-flash"), Some(ProviderKind::Atlascloud));
2124
2125 assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
2126 assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
2127 }
2128
2129 #[test]
2130 fn deepseek_v4_pro_alias_resolves_to_atlascloud_when_provider_hinted() {
2131 let registry = ModelRegistry::default();
2132 let resolved = registry.resolve_ok(Some("deepseek-v4-pro"), Some(ProviderKind::Atlascloud));
2133
2134 assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
2135 assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
2136 }
2137
2138 #[test]
2139 fn atlascloud_provider_hint_passes_through_explicit_model_id() {
2140 let registry = ModelRegistry::default();
2141 let resolved =
2142 registry.resolve_ok(Some("openai/gpt-5.2-chat"), Some(ProviderKind::Atlascloud));
2143
2144 assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
2145 assert_eq!(resolved.resolved.id, "openai/gpt-5.2-chat");
2146 assert!(resolved.resolved.supports_tools);
2147 assert!(resolved.resolved.supports_reasoning);
2148 assert!(!resolved.used_fallback);
2149 }
2150
2151 #[test]
2152 fn atlascloud_provider_hint_preserves_explicit_model_id_case() {
2153 let registry = ModelRegistry::default();
2154 let resolved =
2155 registry.resolve_ok(Some("Qwen/Qwen3-Coder"), Some(ProviderKind::Atlascloud));
2156
2157 assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
2158 assert_eq!(resolved.resolved.id, "Qwen/Qwen3-Coder");
2159 assert!(!resolved.used_fallback);
2160 }
2161
2162 #[test]
2163 fn atlascloud_plain_unknown_model_rejects_instead_of_using_default() {
2164 let registry = ModelRegistry::default();
2165 let error = registry
2166 .resolve(Some("not-in-atlas"), Some(ProviderKind::Atlascloud))
2167 .expect_err("a requested unknown model must not become the provider default");
2168
2169 assert_eq!(
2170 error,
2171 ModelResolutionError::ModelNotAvailableForProvider {
2172 provider: ProviderKind::Atlascloud,
2173 requested: "not-in-atlas".to_string(),
2174 }
2175 );
2176 }
2177
2178 #[test]
2179 fn openrouter_default_uses_namespaced_model_id() {
2180 let registry = ModelRegistry::default();
2181 let resolved = registry.resolve_ok(None, Some(ProviderKind::Openrouter));
2182
2183 assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
2184 assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
2185 }
2186
2187 #[test]
2188 fn xiaomi_mimo_default_uses_canonical_model_id() {
2189 let registry = ModelRegistry::default();
2190 let resolved = registry.resolve_ok(None, Some(ProviderKind::XiaomiMimo));
2191
2192 assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
2193 assert_eq!(resolved.resolved.id, "mimo-v2.5-pro");
2194 assert!(resolved.resolved.supports_reasoning);
2195 }
2196
2197 #[test]
2198 fn moonshot_default_and_aliases_use_kimi_k27_code() {
2199 let registry = ModelRegistry::default();
2200
2201 for requested in [None, Some("kimi"), Some("kimi-k2.7-code")] {
2202 let resolved = registry.resolve_ok(requested, Some(ProviderKind::Moonshot));
2203
2204 assert_eq!(resolved.resolved.provider, ProviderKind::Moonshot);
2205 assert_eq!(resolved.resolved.id, "kimi-k2.7-code");
2206 assert!(resolved.resolved.supports_tools);
2207 assert!(resolved.resolved.supports_reasoning);
2208 }
2209 }
2210
2211 #[test]
2212 fn moonshot_explicit_kimi_k26_remains_available() {
2213 let registry = ModelRegistry::default();
2214 let resolved = registry.resolve_ok(Some("kimi-k2.6"), Some(ProviderKind::Moonshot));
2215
2216 assert_eq!(resolved.resolved.provider, ProviderKind::Moonshot);
2217 assert_eq!(resolved.resolved.id, "kimi-k2.6");
2218 assert!(resolved.resolved.supports_reasoning);
2219 }
2220
2221 /// v0.9.1 dogfood report: a user ran `--provider moonshot --model kimi-k3` and was told
2222 /// the model was `kimi-k2.7-code`. The registry knew neither Moonshot K3
2223 /// product, so the explicit request fell through to the provider default.
2224 #[test]
2225 fn moonshot_resolves_both_k3_products_without_crossing_them() {
2226 let registry = ModelRegistry::default();
2227
2228 for (requested, expected) in [("kimi-k3", "kimi-k3"), ("k3", "k3")] {
2229 let resolved = registry.resolve_ok(Some(requested), Some(ProviderKind::Moonshot));
2230
2231 assert_eq!(resolved.resolved.provider, ProviderKind::Moonshot);
2232 assert_eq!(resolved.resolved.id, expected, "{resolved:?}");
2233 assert!(
2234 !resolved.used_fallback,
2235 "an explicit Moonshot K3 request is not a fallback: {resolved:?}"
2236 );
2237 }
2238 }
2239
2240 /// The bare `k3` id belongs to the Kimi Code coding-plan endpoint and
2241 /// `kimi-k3` to the direct platform endpoint. Neither may be laundered
2242 /// into the other's id by alias expansion.
2243 #[test]
2244 fn moonshot_k3_ids_are_never_rewritten_into_each_other() {
2245 let registry = ModelRegistry::default();
2246
2247 assert_eq!(
2248 registry
2249 .resolve_ok(Some("kimi-k3"), Some(ProviderKind::Moonshot))
2250 .resolved
2251 .id,
2252 "kimi-k3"
2253 );
2254 assert_eq!(
2255 registry
2256 .resolve_ok(Some("k3"), Some(ProviderKind::Moonshot))
2257 .resolved
2258 .id,
2259 "k3"
2260 );
2261 }
2262
2263 /// A provider-scoped question must never be answered with another
2264 /// vendor's model. `kimi-k3` also exists in the OpenCode Go catalog;
2265 /// before this fix that entry answered `--provider moonshot` requests.
2266 #[test]
2267 fn a_provider_hint_never_resolves_to_another_providers_model() {
2268 let registry = ModelRegistry::default();
2269
2270 let error = registry
2271 .resolve(Some("glm-5.2"), Some(ProviderKind::Moonshot))
2272 .expect_err("a Moonshot request must not be answered by Z.ai or a default");
2273 assert_eq!(
2274 error,
2275 ModelResolutionError::ModelNotAvailableForProvider {
2276 provider: ProviderKind::Moonshot,
2277 requested: "glm-5.2".to_string(),
2278 }
2279 );
2280
2281 let go = registry.resolve_ok(Some("kimi-k3"), Some(ProviderKind::OpencodeGo));
2282 assert_eq!(go.resolved.provider, ProviderKind::OpencodeGo);
2283 assert_eq!(go.resolved.id, "kimi-k3");
2284 }
2285
2286 #[test]
2287 fn xiaomi_mimo_tts_aliases_resolve_when_provider_hinted() {
2288 let registry = ModelRegistry::default();
2289 let resolved = registry.resolve_ok(Some("tts"), Some(ProviderKind::XiaomiMimo));
2290 assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
2291 assert_eq!(resolved.resolved.id, "mimo-v2.5-tts");
2292 assert!(!resolved.resolved.supports_tools);
2293 assert!(!resolved.resolved.supports_reasoning);
2294
2295 let resolved = registry.resolve_ok(Some("voice-design"), Some(ProviderKind::XiaomiMimo));
2296 assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voicedesign");
2297
2298 let resolved = registry.resolve_ok(Some("voiceclone"), Some(ProviderKind::XiaomiMimo));
2299 assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voiceclone");
2300 }
2301
2302 #[test]
2303 fn xiaomi_mimo_chat_aliases_resolve_when_provider_hinted() {
2304 let registry = ModelRegistry::default();
2305
2306 let resolved = registry.resolve_ok(Some("omni"), Some(ProviderKind::XiaomiMimo));
2307 assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
2308 assert_eq!(resolved.resolved.id, "mimo-v2.5");
2309 assert!(resolved.resolved.supports_tools);
2310 }
2311
2312 #[test]
2313 fn xiaomi_mimo_provider_hint_preserves_custom_model_id() {
2314 let registry = ModelRegistry::default();
2315 let resolved =
2316 registry.resolve_ok(Some("account-custom-mimo"), Some(ProviderKind::XiaomiMimo));
2317
2318 assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
2319 assert_eq!(resolved.resolved.id, "account-custom-mimo");
2320 assert!(!resolved.used_fallback);
2321 }
2322
2323 #[test]
2324 fn xiaomi_mimo_provider_hint_does_not_reclassify_openrouter_model_id() {
2325 let registry = ModelRegistry::default();
2326 let resolved = registry.resolve_ok(
2327 Some("deepseek/deepseek-v4-pro"),
2328 Some(ProviderKind::XiaomiMimo),
2329 );
2330
2331 assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
2332 assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
2333 assert!(!resolved.used_fallback);
2334 }
2335
2336 #[test]
2337 fn wanjie_ark_default_uses_reasoner_model_id() {
2338 let registry = ModelRegistry::default();
2339 let resolved = registry.resolve_ok(None, Some(ProviderKind::WanjieArk));
2340
2341 assert_eq!(resolved.resolved.provider, ProviderKind::WanjieArk);
2342 assert_eq!(resolved.resolved.id, "deepseek-reasoner");
2343 assert!(resolved.resolved.supports_reasoning);
2344 }
2345
2346 #[test]
2347 fn novita_default_uses_namespaced_model_id() {
2348 let registry = ModelRegistry::default();
2349 let resolved = registry.resolve_ok(None, Some(ProviderKind::Novita));
2350
2351 assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
2352 assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
2353 }
2354
2355 #[test]
2356 fn fireworks_default_uses_canonical_model_id() {
2357 let registry = ModelRegistry::default();
2358 let resolved = registry.resolve_ok(None, Some(ProviderKind::Fireworks));
2359
2360 assert_eq!(resolved.resolved.provider, ProviderKind::Fireworks);
2361 assert_eq!(
2362 resolved.resolved.id,
2363 "accounts/fireworks/models/deepseek-v4-pro"
2364 );
2365 }
2366
2367 #[test]
2368 fn siliconflow_default_uses_canonical_pro_model_id() {
2369 let registry = ModelRegistry::default();
2370 let resolved = registry.resolve_ok(None, Some(ProviderKind::Siliconflow));
2371
2372 assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
2373 assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
2374 assert!(resolved.resolved.supports_reasoning);
2375 }
2376
2377 #[test]
2378 fn arcee_default_uses_direct_trinity_large_thinking_model_id() {
2379 let registry = ModelRegistry::default();
2380 let resolved = registry.resolve_ok(None, Some(ProviderKind::Arcee));
2381
2382 assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
2383 assert_eq!(resolved.resolved.id, "trinity-large-thinking");
2384 assert!(resolved.resolved.supports_reasoning);
2385 }
2386
2387 #[test]
2388 fn arcee_trinity_alias_resolves_to_direct_large_thinking_not_openrouter() {
2389 let registry = ModelRegistry::default();
2390 let resolved = registry.resolve_ok(Some("trinity"), Some(ProviderKind::Arcee));
2391
2392 assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
2393 assert_eq!(resolved.resolved.id, "trinity-large-thinking");
2394 assert!(resolved.resolved.supports_reasoning);
2395 }
2396
2397 #[test]
2398 fn arcee_trinity_mini_remains_explicit_compatibility_model() {
2399 let registry = ModelRegistry::default();
2400 let resolved = registry.resolve_ok(Some("trinity-mini"), Some(ProviderKind::Arcee));
2401
2402 assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
2403 assert_eq!(resolved.resolved.id, "trinity-mini");
2404 assert!(resolved.resolved.supports_reasoning);
2405 assert!(!resolved.used_fallback);
2406 }
2407
2408 #[test]
2409 fn arcee_provider_hint_preserves_explicit_future_model_id() {
2410 let registry = ModelRegistry::default();
2411 let resolved = registry.resolve_ok(Some("trinity-large-next"), Some(ProviderKind::Arcee));
2412
2413 assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
2414 assert_eq!(resolved.resolved.id, "trinity-large-next");
2415 assert!(!resolved.resolved.supports_reasoning);
2416 assert!(!resolved.used_fallback);
2417 }
2418
2419 #[test]
2420 fn deepseek_reasoner_does_not_silently_substitute_siliconflow_pro() {
2421 let registry = ModelRegistry::default();
2422 let error = registry
2423 .resolve(Some("deepseek-reasoner"), Some(ProviderKind::Siliconflow))
2424 .expect_err("an absent alias must not become SiliconFlow's first/default row");
2425
2426 assert_eq!(
2427 error,
2428 ModelResolutionError::ModelNotAvailableForProvider {
2429 provider: ProviderKind::Siliconflow,
2430 requested: "deepseek-reasoner".to_string(),
2431 }
2432 );
2433 }
2434
2435 #[test]
2436 fn deepseek_v4_flash_alias_resolves_to_siliconflow_flash_when_provider_hinted() {
2437 let registry = ModelRegistry::default();
2438 let resolved =
2439 registry.resolve_ok(Some("deepseek-v4-flash"), Some(ProviderKind::Siliconflow));
2440
2441 assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
2442 assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
2443 }
2444
2445 #[test]
2446 fn sglang_default_uses_canonical_model_id() {
2447 let registry = ModelRegistry::default();
2448 let resolved = registry.resolve_ok(None, Some(ProviderKind::Sglang));
2449
2450 assert_eq!(resolved.resolved.provider, ProviderKind::Sglang);
2451 assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
2452 }
2453
2454 #[test]
2455 fn zai_direct_models_resolve_when_provider_hinted() {
2456 let registry = ModelRegistry::default();
2457
2458 // Keep the agent registry fallback aligned with codewhale-config's
2459 // DEFAULT_ZAI_MODEL.
2460 let default = registry.resolve_ok(None, Some(ProviderKind::Zai));
2461 assert_eq!(default.resolved.provider, ProviderKind::Zai);
2462 assert_eq!(default.resolved.id, "GLM-5.3");
2463 assert!(default.used_fallback);
2464 assert_eq!(default.fallback_chain, ["provider_default:zai"]);
2465
2466 for (alias, expected) in [
2467 ("GLM-5.1", "GLM-5.1"),
2468 ("glm-5-1", "GLM-5.1"),
2469 ("GLM-5.2", "GLM-5.2"),
2470 ("glm-5.2", "GLM-5.2"),
2471 ("zai-glm-5-2", "GLM-5.2"),
2472 ("GLM-5.3", "GLM-5.3"),
2473 ("glm-5.3", "GLM-5.3"),
2474 ("glm-5-3", "GLM-5.3"),
2475 ("zai-glm-5-3", "GLM-5.3"),
2476 ("GLM-5.3-Flash", "GLM-5.3-Flash"),
2477 ("glm-5.3-flash", "GLM-5.3-Flash"),
2478 ("glm-5-3-flash", "GLM-5.3-Flash"),
2479 ("zai-glm-5.3-flash", "GLM-5.3-Flash"),
2480 ("GLM-5-Turbo", "GLM-5-Turbo"),
2481 ("glm-5-turbo", "GLM-5-Turbo"),
2482 ("zai-glm-5-turbo", "GLM-5-Turbo"),
2483 ] {
2484 let resolved = registry.resolve_ok(Some(alias), Some(ProviderKind::Zai));
2485
2486 assert_eq!(resolved.resolved.provider, ProviderKind::Zai);
2487 assert_eq!(resolved.resolved.id, expected);
2488 assert!(!resolved.used_fallback);
2489 assert!(resolved.resolved.supports_tools);
2490 assert!(resolved.resolved.supports_reasoning);
2491 }
2492 }
2493
2494 #[test]
2495 fn first_party_recent_provider_models_are_listed() {
2496 let registry = ModelRegistry::default();
2497 let models = registry.list();
2498
2499 for (provider, id) in [
2500 (ProviderKind::Zai, "GLM-5.2"),
2501 (ProviderKind::Stepfun, "step-3.7-flash"),
2502 (ProviderKind::Minimax, "MiniMax-M2.1"),
2503 (ProviderKind::MinimaxAnthropic, "MiniMax-M3"),
2504 (ProviderKind::Openmodel, "deepseek-v4-flash"),
2505 (ProviderKind::Meta, "muse-spark-1.2"),
2506 (ProviderKind::Xai, "grok-4.6"),
2507 ] {
2508 assert!(
2509 models
2510 .iter()
2511 .any(|model| model.provider == provider && model.id == id),
2512 "expected {provider:?} model {id} in registry"
2513 );
2514 }
2515 }
2516
2517 #[test]
2518 fn opencode_go_lists_documented_models_without_inventing_capabilities() {
2519 let registry = ModelRegistry::default();
2520 let listed = registry.list();
2521 let models: Vec<&str> = listed
2522 .iter()
2523 .filter(|model| model.provider == ProviderKind::OpencodeGo)
2524 .map(|model| model.id.as_str())
2525 .collect();
2526
2527 // Literal expectations independently catch an incomplete shared roster
2528 // and prevent new compatibility entries from claiming capabilities.
2529 let expected = [
2530 ("deepseek-v4-pro", true),
2531 ("grok-4.5", true),
2532 ("glm-5.2", true),
2533 ("glm-5.1", true),
2534 ("kimi-k3", true),
2535 ("kimi-k2.7-code", true),
2536 ("kimi-k2.6", true),
2537 ("deepseek-v4-flash", true),
2538 ("mimo-v2.5", true),
2539 ("mimo-v2.5-pro", true),
2540 ("glm-5.3-flash", false),
2541 ("glm-5.3", false),
2542 ("longcat-2.0", false),
2543 ("deepseek-v4-flash-vision-exp", false),
2544 ("hy4-preview", false),
2545 ("hy3", false),
2546 ("omen-alpha", false),
2547 ("deepseek-v4.1-flash", false),
2548 ("grok-4.6", false),
2549 ("gpt-5.6-luna", false),
2550 ("muse-spark-1.3-contributor", false),
2551 ("muse-spark-1.2-contributor", false),
2552 ("minimax-m3", false),
2553 ("minimax-m2.7", false),
2554 ("minimax-m2.5", false),
2555 ("qwen3.8-max", false),
2556 ("qwen3.8-flash", false),
2557 ("qwen3.7-max", false),
2558 ("qwen3.7-plus", false),
2559 ("qwen3.6-plus", false),
2560 ];
2561 assert_eq!(
2562 models,
2563 expected.iter().map(|(id, _)| *id).collect::<Vec<_>>()
2564 );
2565
2566 let default = registry.resolve_ok(None, Some(ProviderKind::OpencodeGo));
2567 assert_eq!(default.resolved.provider, ProviderKind::OpencodeGo);
2568 assert_eq!(default.resolved.id, "deepseek-v4-pro");
2569
2570 for (model, expected_capabilities) in expected {
2571 for requested in [model.to_string(), format!("opencode-go/{model}")] {
2572 let resolved =
2573 registry.resolve_ok(Some(&requested), Some(ProviderKind::OpencodeGo));
2574 assert_eq!(resolved.resolved.provider, ProviderKind::OpencodeGo);
2575 assert_eq!(resolved.resolved.id, model);
2576 assert!(!resolved.used_fallback);
2577 assert_eq!(
2578 resolved.resolved.aliases,
2579 vec![format!("opencode-go/{model}")],
2580 "{requested}"
2581 );
2582 assert_eq!(
2583 resolved.resolved.supports_tools, expected_capabilities,
2584 "{requested} tool support"
2585 );
2586 assert_eq!(
2587 resolved.resolved.supports_reasoning, expected_capabilities,
2588 "{requested} reasoning support"
2589 );
2590 }
2591 }
2592
2593 for non_chat in ["claude-unproven", "unknown-model", "gpt-unlisted"] {
2594 for requested in [non_chat.to_string(), format!("opencode-go/{non_chat}")] {
2595 let rejected = registry
2596 .resolve(Some(&requested), Some(ProviderKind::OpencodeGo))
2597 .expect_err("unknown Go id must not fall back to another provider");
2598 assert_eq!(
2599 rejected,
2600 ModelResolutionError::ModelNotAvailableForProvider {
2601 provider: ProviderKind::OpencodeGo,
2602 requested,
2603 }
2604 );
2605 }
2606 }
2607 }
2608
2609 #[test]
2610 fn xai_grok_models_resolve_when_provider_hinted() {
2611 let registry = ModelRegistry::default();
2612
2613 let default = registry.resolve_ok(None, Some(ProviderKind::Xai));
2614 assert_eq!(default.resolved.provider, ProviderKind::Xai);
2615 assert_eq!(default.resolved.id, "grok-4.6");
2616 assert!(default.used_fallback);
2617
2618 let alias = registry.resolve_ok(Some("grok"), Some(ProviderKind::Xai));
2619 assert_eq!(alias.resolved.provider, ProviderKind::Xai);
2620 assert_eq!(alias.resolved.id, "grok-4.6");
2621 assert!(!alias.used_fallback);
2622
2623 let fast = registry.resolve_ok(
2624 Some("grok-4.20-0309-non-reasoning"),
2625 Some(ProviderKind::Xai),
2626 );
2627 assert_eq!(fast.resolved.provider, ProviderKind::Xai);
2628 assert_eq!(fast.resolved.id, "grok-4.20-0309-non-reasoning");
2629 assert!(!fast.resolved.supports_reasoning);
2630 }
2631
2632 #[test]
2633 fn meta_muse_spark_resolves_when_provider_hinted() {
2634 let registry = ModelRegistry::default();
2635
2636 let default = registry.resolve_ok(None, Some(ProviderKind::Meta));
2637 assert_eq!(default.resolved.provider, ProviderKind::Meta);
2638 assert_eq!(default.resolved.id, "muse-spark-1.2");
2639 assert!(default.used_fallback);
2640
2641 let alias = registry.resolve_ok(Some("muse-spark"), Some(ProviderKind::Meta));
2642 assert_eq!(alias.resolved.provider, ProviderKind::Meta);
2643 assert_eq!(alias.resolved.id, "muse-spark-1.2");
2644 assert!(!alias.used_fallback);
2645 assert_eq!(model_family("muse-spark-1.2"), ModelFamily::Meta);
2646 }
2647
2648 #[test]
2649 fn openai_gpt56_family_resolves_when_provider_hinted() {
2650 let registry = ModelRegistry::default();
2651 for model in ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] {
2652 let resolved = registry.resolve_ok(Some(model), Some(ProviderKind::Openai));
2653 assert_eq!(resolved.resolved.provider, ProviderKind::Openai, "{model}");
2654 assert_eq!(resolved.resolved.id, model, "{model}");
2655 assert!(resolved.resolved.supports_tools, "{model}");
2656 assert!(resolved.resolved.supports_reasoning, "{model}");
2657 assert!(!resolved.used_fallback, "{model}");
2658 }
2659 }
2660
2661 #[test]
2662 fn grok_ids_stay_in_grok_family() {
2663 assert_eq!(model_family("grok-4.6"), ModelFamily::Grok);
2664 assert_eq!(model_family("grok-4.5"), ModelFamily::Grok);
2665 assert_eq!(
2666 model_family("grok-4.20-0309-non-reasoning"),
2667 ModelFamily::Grok
2668 );
2669 }
2670
2671 #[test]
2672 fn stepfun_and_minimax_direct_models_resolve_when_provider_hinted() {
2673 let registry = ModelRegistry::default();
2674
2675 let stepfun = registry.resolve_ok(None, Some(ProviderKind::Stepfun));
2676 assert_eq!(stepfun.resolved.provider, ProviderKind::Stepfun);
2677 assert_eq!(stepfun.resolved.id, "step-3.7-flash");
2678
2679 for (alias, expected) in [
2680 ("minimax", "MiniMax-M3"),
2681 ("minimax-m3", "MiniMax-M3"),
2682 ("minimax-m2.7", "MiniMax-M2.7"),
2683 ("minimax-m2-7-highspeed", "MiniMax-M2.7-highspeed"),
2684 ("minimax-m2.1", "MiniMax-M2.1"),
2685 ("minimax-m2", "MiniMax-M2"),
2686 ] {
2687 let resolved = registry.resolve_ok(Some(alias), Some(ProviderKind::Minimax));
2688
2689 assert_eq!(resolved.resolved.provider, ProviderKind::Minimax);
2690 assert_eq!(resolved.resolved.id, expected);
2691 assert!(!resolved.used_fallback);
2692 assert!(resolved.resolved.supports_tools);
2693 assert!(resolved.resolved.supports_reasoning);
2694 }
2695 }
2696
2697 #[test]
2698 fn minimax_anthropic_models_resolve_when_provider_hinted() {
2699 let registry = ModelRegistry::default();
2700
2701 for (alias, expected) in [
2702 ("minimax-anthropic", "MiniMax-M3"),
2703 ("minimax-m3", "MiniMax-M3"),
2704 ("minimax-m2.7", "MiniMax-M2.7"),
2705 ] {
2706 let resolved = registry.resolve_ok(Some(alias), Some(ProviderKind::MinimaxAnthropic));
2707
2708 assert_eq!(resolved.resolved.provider, ProviderKind::MinimaxAnthropic);
2709 assert_eq!(resolved.resolved.id, expected);
2710 assert!(!resolved.used_fallback);
2711 assert!(resolved.resolved.supports_tools);
2712 assert!(resolved.resolved.supports_reasoning);
2713 }
2714 }
2715
2716 #[test]
2717 fn deepseek_v4_flash_alias_resolves_to_openrouter_when_provider_hinted() {
2718 let registry = ModelRegistry::default();
2719 let resolved =
2720 registry.resolve_ok(Some("deepseek-v4-flash"), Some(ProviderKind::Openrouter));
2721
2722 assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
2723 assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
2724 }
2725
2726 #[test]
2727 fn recent_openrouter_large_model_aliases_resolve_when_provider_hinted() {
2728 let registry = ModelRegistry::default();
2729
2730 for (alias, expected) in [
2731 ("trinity-large-thinking", "arcee-ai/trinity-large-thinking"),
2732 ("qwen3.6-flash", "qwen/qwen3.6-flash"),
2733 ("qwen3.6-35b-a3b", "qwen/qwen3.6-35b-a3b"),
2734 ("qwen3.6-max-preview", "qwen/qwen3.6-max-preview"),
2735 ("qwen3.6-plus", "qwen/qwen3.6-plus"),
2736 ("gemma-4-31b-it", "google/gemma-4-31b-it"),
2737 ("glm-5.1", "z-ai/glm-5.1"),
2738 ("glm-5.2", "z-ai/glm-5.2"),
2739 ("glm-5.3", "z-ai/glm-5.3"),
2740 ("glm-5.3-flash", "z-ai/glm-5.3-flash"),
2741 ("minimax-m3", "minimax/minimax-m3"),
2742 ("minimax-2.7", "minimax/minimax-m2.7"),
2743 ("openrouter-mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro"),
2744 ("openrouter-kimi-k2.7-code", "moonshotai/kimi-k2.7-code"),
2745 ("openrouter-kimi-k2.6", "moonshotai/kimi-k2.6"),
2746 ("nemotron-3-ultra", "nvidia/nemotron-3-ultra-550b-a55b"),
2747 (
2748 "nvidia/nemotron-3-ultra",
2749 "nvidia/nemotron-3-ultra-550b-a55b",
2750 ),
2751 ] {
2752 let resolved = registry.resolve_ok(Some(alias), Some(ProviderKind::Openrouter));
2753
2754 assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
2755 assert_eq!(resolved.resolved.id, expected);
2756 assert!(resolved.resolved.supports_tools);
2757 assert!(resolved.resolved.supports_reasoning);
2758 }
2759 }
2760
2761 #[test]
2762 fn deepseek_v4_flash_alias_resolves_to_novita_when_provider_hinted() {
2763 let registry = ModelRegistry::default();
2764 let resolved = registry.resolve_ok(Some("deepseek-v4-flash"), Some(ProviderKind::Novita));
2765
2766 assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
2767 assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
2768 }
2769
2770 #[test]
2771 fn together_inkling_keeps_published_wire_identity() {
2772 let registry = ModelRegistry::default();
2773 for requested in ["thinkingmachines/inkling", "inkling", "together-inkling"] {
2774 let resolved = registry.resolve_ok(Some(requested), Some(ProviderKind::Together));
2775
2776 assert_eq!(resolved.resolved.provider, ProviderKind::Together);
2777 assert_eq!(resolved.resolved.id, "thinkingmachines/inkling");
2778 assert!(resolved.resolved.supports_tools);
2779 assert!(resolved.resolved.supports_reasoning);
2780 assert!(!resolved.used_fallback);
2781 }
2782
2783 assert!(matches!(
2784 registry.resolve(Some("inkling"), None),
2785 Err(ModelResolutionError::ProviderRequired { .. })
2786 ));
2787 }
2788
2789 #[test]
2790 fn registry_lists_and_resolves_every_v090_catalog_addition() {
2791 let registry = ModelRegistry::default();
2792 let advertised = [
2793 (ProviderKind::Anthropic, "claude-sonnet-5"),
2794 (ProviderKind::Anthropic, "claude-fable-5"),
2795 (ProviderKind::Openai, "gpt-5.3-codex"),
2796 (ProviderKind::Openai, "gpt-5.5"),
2797 (ProviderKind::Openai, "gpt-5.5-pro"),
2798 (ProviderKind::Openrouter, "qwen/qwen3.7-plus"),
2799 (ProviderKind::Arcee, "trinity-mini"),
2800 ];
2801
2802 let listed = registry.list();
2803 for (provider, model_id) in advertised {
2804 assert!(
2805 listed
2806 .iter()
2807 .any(|model| model.provider == provider && model.id == model_id),
2808 "missing {model_id} ({}) from model list",
2809 provider.as_str()
2810 );
2811 let resolved = registry.resolve_ok(Some(model_id), Some(provider));
2812 assert_eq!(resolved.resolved.provider, provider, "{model_id}");
2813 assert_eq!(resolved.resolved.id, model_id, "{model_id}");
2814 assert!(!resolved.used_fallback, "{model_id}");
2815 }
2816 }
2817
2818 #[test]
2819 fn gpt_55_stays_provider_scoped_between_openai_and_codex() {
2820 let registry = ModelRegistry::default();
2821
2822 assert!(matches!(
2823 registry.resolve(Some("gpt-5.5"), None),
2824 Err(ModelResolutionError::ProviderRequired { .. })
2825 ));
2826
2827 let codex = registry.resolve_ok(Some("gpt-5.5"), Some(ProviderKind::OpenaiCodex));
2828 assert_eq!(codex.resolved.provider, ProviderKind::OpenaiCodex);
2829 assert_eq!(codex.resolved.id, "gpt-5.5");
2830 assert!(!codex.used_fallback);
2831 }
2832
2833 #[test]
2834 fn deepseek_v4_flash_alias_resolves_to_sglang_when_provider_hinted() {
2835 let registry = ModelRegistry::default();
2836 let resolved = registry.resolve_ok(Some("deepseek-v4-flash"), Some(ProviderKind::Sglang));
2837
2838 assert_eq!(resolved.resolved.provider, ProviderKind::Sglang);
2839 assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
2840 }
2841
2842 #[test]
2843 fn vllm_default_uses_canonical_model_id() {
2844 let registry = ModelRegistry::default();
2845 let resolved = registry.resolve_ok(None, Some(ProviderKind::Vllm));
2846
2847 assert_eq!(resolved.resolved.provider, ProviderKind::Vllm);
2848 assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
2849 }
2850
2851 #[test]
2852 fn ollama_default_is_unavailable_until_the_local_catalog_answers() {
2853 // Y-2: `DEFAULT_OLLAMA_MODEL` is deliberately "unknown". The real
2854 // default comes from the live local catalog, so the header never names
2855 // a model the session cannot reach; without that catalog the registry
2856 // must say so instead of resolving a costume.
2857 let registry = ModelRegistry::default();
2858 let error = registry
2859 .resolve(None, Some(ProviderKind::Ollama))
2860 .expect_err("the placeholder default must not resolve");
2861
2862 assert!(matches!(
2863 error,
2864 ModelResolutionError::ProviderDefaultUnavailable {
2865 provider: ProviderKind::Ollama,
2866 ref default_model,
2867 } if default_model == "unknown"
2868 ));
2869 }
2870
2871 #[test]
2872 fn ollama_cloud_default_uses_the_hosted_catalog_model_id() {
2873 let registry = ModelRegistry::default();
2874 let resolved = registry.resolve_ok(None, Some(ProviderKind::OllamaCloud));
2875
2876 assert_eq!(resolved.resolved.provider, ProviderKind::OllamaCloud);
2877 assert_eq!(resolved.resolved.id, "gpt-oss:120b");
2878 assert!(resolved.resolved.supports_reasoning);
2879 }
2880
2881 #[test]
2882 fn ollama_requested_model_tag_is_preserved() {
2883 let registry = ModelRegistry::default();
2884 let resolved = registry.resolve_ok(Some("qwen2.5-coder:7b"), Some(ProviderKind::Ollama));
2885
2886 assert_eq!(resolved.resolved.provider, ProviderKind::Ollama);
2887 assert_eq!(resolved.resolved.id, "qwen2.5-coder:7b");
2888 assert!(!resolved.used_fallback);
2889 }
2890
2891 #[test]
2892 fn deepseek_v4_flash_alias_resolves_to_vllm_when_provider_hinted() {
2893 let registry = ModelRegistry::default();
2894 let resolved = registry.resolve_ok(Some("deepseek-v4-flash"), Some(ProviderKind::Vllm));
2895
2896 assert_eq!(resolved.resolved.provider, ProviderKind::Vllm);
2897 assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
2898 }
2899
2900 #[test]
2901 fn providerless_cased_model_text_does_not_authorize_deepseek() {
2902 let registry = ModelRegistry::default();
2903 assert!(matches!(
2904 registry.resolve(Some("DeepSeek-V4-Pro"), None),
2905 Err(ModelResolutionError::ProviderRequired { .. })
2906 ));
2907 }
2908
2909 #[test]
2910 fn registry_casing_takes_priority_over_requested_casing_with_provider_hint() {
2911 let registry = ModelRegistry::default();
2912 let resolved = registry.resolve_ok(Some("DeepSeek-V4-Pro"), Some(ProviderKind::Deepseek));
2913
2914 assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
2915 // Registry's canonical id is used even when user provides different casing
2916 assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
2917 }
2918
2919 #[test]
2920 fn providerless_whitespace_model_text_does_not_authorize_deepseek() {
2921 let registry = ModelRegistry::default();
2922 assert!(matches!(
2923 registry.resolve(Some(" DeepSeek-V4-Pro "), None),
2924 Err(ModelResolutionError::ProviderRequired { .. })
2925 ));
2926 }
2927
2928 #[test]
2929 fn alias_match_does_not_override_requested_casing() {
2930 let registry = ModelRegistry::default();
2931 let resolved = registry.resolve_ok(Some("deepseek-reasoner"), Some(ProviderKind::Deepseek));
2932
2933 assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
2934 assert_eq!(resolved.resolved.id, "deepseek-v4-flash");
2935 }
2936
2937 #[test]
2938 fn model_family_classifies_known_model_ids() {
2939 assert_eq!(model_family("deepseek-v4-pro"), ModelFamily::DeepSeek);
2940 assert_eq!(model_family("openai/gpt-5.4"), ModelFamily::OpenAI);
2941 assert_eq!(
2942 model_family("anthropic/claude-opus-4-7"),
2943 ModelFamily::Anthropic
2944 );
2945 assert_eq!(
2946 model_family("meta-llama/llama-3.3-70b-instruct"),
2947 ModelFamily::Meta
2948 );
2949 assert_eq!(model_family("Qwen/Qwen3-Coder"), ModelFamily::Qwen);
2950 }
2951
2952 #[test]
2953 fn model_family_uses_underlying_model_for_router_ids() {
2954 assert_eq!(
2955 model_family("groq/llama-3.3-70b-versatile"),
2956 ModelFamily::Meta
2957 );
2958 assert_eq!(
2959 model_family("openrouter/openai/gpt-5.4"),
2960 ModelFamily::OpenAI
2961 );
2962 assert_eq!(
2963 model_family("fireworks/accounts/fireworks/models/deepseek-v4-pro"),
2964 ModelFamily::DeepSeek
2965 );
2966 }
2967
2968 #[test]
2969 fn model_family_covers_prominent_google_and_mistral_model_names() {
2970 assert_eq!(model_family("google/gemma-3-27b-it"), ModelFamily::Google);
2971 assert_eq!(
2972 model_family("mistralai/mixtral-8x22b"),
2973 ModelFamily::Mistral
2974 );
2975 assert_eq!(model_family("codestral-latest"), ModelFamily::Mistral);
2976 }
2977
2978 #[test]
2979 fn model_family_falls_back_to_inferencer_for_unknown_models() {
2980 assert_eq!(
2981 model_family("custom-gateway/my-private-model"),
2982 ModelFamily::Inferencer
2983 );
2984 assert_eq!(model_family(""), ModelFamily::Inferencer);
2985 }
2986
2987 /// SHA-6443: a provider's declared default must be a model its own
2988 /// registry can resolve. A default the registry rejects fails a test
2989 /// here, not a founder's `model resolve`.
2990 #[test]
2991 fn every_provider_default_resolves_for_its_own_provider() {
2992 let registry = ModelRegistry::default();
2993 let mut failures = Vec::new();
2994 for kind in ProviderKind::all() {
2995 let default = kind.provider().default_model();
2996 if default.trim().is_empty() {
2997 continue;
2998 }
2999 if let Err(error) = registry.resolve(Some(default), Some(*kind)) {
3000 failures.push(format!("{} ({kind:?}): {error}", default));
3001 }
3002 }
3003 assert!(
3004 failures.is_empty(),
3005 "provider defaults must resolve for their own provider:\n{}",
3006 failures.join("\n")
3007 );
3008 }
3009 }
3010
3010 lines RUST