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