返回 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 Self::new(
153 codewhale_config::catalog::reviewed::bundled_reviewed()
154 .selections
155 .iter()
156 .map(|row| ModelInfo {
157 id: row.id.clone(),
158 provider: ProviderKind::parse_config_identity(&row.provider)
159 .expect("validated bundled provider selector"),
160 aliases: row.aliases.clone(),
161 supports_tools: row.supports_tools,
162 supports_reasoning: row.supports_reasoning,
163 })
164 .collect(),
165 )
166 }
167 }
168
169 impl ModelRegistry {
170 /// Creates a new registry from a list of [`ModelInfo`] entries.
171 ///
172 #[must_use]
173 pub fn new(models: Vec<ModelInfo>) -> Self {
174 Self { models }
175 }
176
177 /// Returns a clone of all models in the registry.
178 #[must_use]
179 pub fn list(&self) -> Vec<ModelInfo> {
180 self.models.clone()
181 }
182
183 /// Returns whether a selector is known only outside the selected provider.
184 ///
185 /// This is rejection metadata, never route authority: callers may use it
186 /// to reject a clearly foreign model, but must not use the matching row to
187 /// select a provider or credential slot.
188 #[must_use]
189 pub fn is_known_for_other_provider(
190 &self,
191 requested: &str,
192 selected_provider: ProviderKind,
193 ) -> bool {
194 let known_here = self
195 .models
196 .iter()
197 .any(|model| model.provider == selected_provider && model_matches(model, requested));
198 !known_here
199 && self
200 .models
201 .iter()
202 .any(|model| model.provider != selected_provider && model_matches(model, requested))
203 }
204
205 /// Resolves a user-requested model name to a concrete [`ModelInfo`].
206 ///
207 /// Resolution follows this priority order:
208 /// 1. If the provider is Ollama, the requested name is used as-is (to
209 /// support arbitrary local model tags like `qwen2.5-coder:7b`).
210 /// 2. If a `provider_hint` is given, search for a model matching that
211 /// provider whose id or alias matches the request (case-insensitive).
212 /// 3. Provider-specific pass-through contracts may preserve arbitrary
213 /// model ids.
214 /// 4. An omitted model falls back to the explicitly selected provider's
215 /// documented default.
216 /// 5. A requested model outside that provider fails closed. Model text is
217 /// metadata and never authorizes a provider or credential switch.
218 pub fn resolve(
219 &self,
220 requested: Option<&str>,
221 provider_hint: Option<ProviderKind>,
222 ) -> Result<ModelResolution, ModelResolutionError> {
223 let requested = requested.filter(|name| !name.trim().is_empty());
224 let mut fallback_chain = Vec::new();
225 let Some(provider) = provider_hint else {
226 return Err(ModelResolutionError::ProviderRequired {
227 requested: requested.map(ToOwned::to_owned),
228 });
229 };
230
231 if let Some(name) = requested {
232 fallback_chain.push(format!("requested:{name}"));
233 if matches!(
234 provider_hint,
235 Some(ProviderKind::Ollama | ProviderKind::OllamaCloud)
236 ) {
237 return Ok(ModelResolution {
238 requested: Some(name.to_string()),
239 resolved: ModelInfo {
240 id: name.trim().to_string(),
241 provider: provider_hint.expect("matched provider hint"),
242 aliases: Vec::new(),
243 supports_tools: true,
244 supports_reasoning: false,
245 },
246 used_fallback: false,
247 fallback_chain,
248 });
249 }
250 // Resolve within Go's roster without falling through to a same-named
251 // model on another provider.
252 if provider_hint == Some(ProviderKind::OpencodeGo)
253 && let Some(canonical) = opencode_go_model_id(name)
254 && let Some(model) = self
255 .models
256 .iter()
257 .find(|model| {
258 model.provider == ProviderKind::OpencodeGo
259 && model.id.eq_ignore_ascii_case(canonical)
260 })
261 .cloned()
262 {
263 return Ok(ModelResolution {
264 requested: Some(name.to_string()),
265 resolved: model,
266 used_fallback: false,
267 fallback_chain,
268 });
269 }
270 if provider_hint != Some(ProviderKind::OpencodeGo)
271 && let Some(provider) = provider_hint
272 && let Some(model) = self
273 .models
274 .iter()
275 .find(|m| m.provider == provider && model_matches(m, name))
276 .cloned()
277 {
278 return Ok(ModelResolution {
279 requested: Some(name.to_string()),
280 resolved: model,
281 used_fallback: false,
282 fallback_chain,
283 });
284 }
285 if provider_hint == Some(ProviderKind::Atlascloud)
286 && let Some(model) = atlascloud_passthrough_model(name)
287 {
288 return Ok(ModelResolution {
289 requested: Some(name.to_string()),
290 resolved: model,
291 used_fallback: false,
292 fallback_chain,
293 });
294 }
295 if provider_hint == Some(ProviderKind::Arcee)
296 && let Some(model) = arcee_passthrough_model(name)
297 {
298 return Ok(ModelResolution {
299 requested: Some(name.to_string()),
300 resolved: model,
301 used_fallback: false,
302 fallback_chain,
303 });
304 }
305 if provider_hint == Some(ProviderKind::XiaomiMimo)
306 && let Some(model) = xiaomi_mimo_passthrough_model(name)
307 {
308 return Ok(ModelResolution {
309 requested: Some(name.to_string()),
310 resolved: model,
311 used_fallback: false,
312 fallback_chain,
313 });
314 }
315 // A provider's own declared default is available from that
316 // provider by definition — the descriptor owns that fact (#6443:
317 // `deepseek-flash` is the Deepseek default and resolved nowhere).
318 // Registry rows canonicalize aliases and carry capability
319 // metadata; they must not gate the name the provider declares.
320 let declared_default = provider.provider().default_model();
321 if !declared_default.trim().is_empty()
322 && name.trim().eq_ignore_ascii_case(declared_default.trim())
323 {
324 return Ok(ModelResolution {
325 requested: Some(name.to_string()),
326 resolved: Self::descriptor_default_model(provider, declared_default),
327 used_fallback: false,
328 fallback_chain,
329 });
330 }
331 if !self.models.iter().any(|model| model.provider == provider) {
332 return Err(ModelResolutionError::ProviderHasNoModels {
333 provider,
334 requested: Some(name.to_string()),
335 });
336 }
337 return Err(ModelResolutionError::ModelNotAvailableForProvider {
338 provider,
339 requested: name.to_string(),
340 });
341 }
342
343 fallback_chain.push(format!("provider_default:{}", provider.as_str()));
344 let default_model = provider.provider().default_model();
345 if let Some(model) = self
346 .models
347 .iter()
348 .find(|model| model.provider == provider && model_matches(model, default_model))
349 .cloned()
350 {
351 return Ok(ModelResolution {
352 requested: None,
353 resolved: model,
354 used_fallback: true,
355 fallback_chain,
356 });
357 }
358 // Same rule as the explicit branch: the descriptor's declared default
359 // resolves for its own provider even without a registry row (#6443).
360 // Ollama is the exception: its descriptor default is the placeholder
361 // `unknown`, and the real default comes from the live local catalog
362 // (Y-2), so a placeholder must never resolve as a model.
363 if !default_model.trim().is_empty() && provider != ProviderKind::Ollama {
364 return Ok(ModelResolution {
365 requested: None,
366 resolved: Self::descriptor_default_model(provider, default_model),
367 used_fallback: true,
368 fallback_chain,
369 });
370 }
371 if !self.models.iter().any(|model| model.provider == provider) {
372 return Err(ModelResolutionError::ProviderHasNoModels {
373 provider,
374 requested: None,
375 });
376 }
377
378 Err(ModelResolutionError::ProviderDefaultUnavailable {
379 provider,
380 default_model: default_model.to_string(),
381 })
382 }
383
384 /// The [`ModelInfo`] a provider's declared default resolves to when the
385 /// registry carries no explicit row for it. The descriptor owns the
386 /// identity; capability metadata stays conservative rather than
387 /// fabricating a capability the registry never recorded.
388 fn descriptor_default_model(provider: ProviderKind, id: &str) -> ModelInfo {
389 ModelInfo {
390 id: id.trim().to_string(),
391 provider,
392 aliases: Vec::new(),
393 supports_tools: true,
394 supports_reasoning: false,
395 }
396 }
397 }
398
399 fn normalize(value: &str) -> String {
400 value.trim().to_ascii_lowercase()
401 }
402
403 #[must_use]
404 /// Classify a model identifier by its underlying model family.
405 pub fn model_family(model_id: &str) -> ModelFamily {
406 let normalized = normalize(model_id);
407 if normalized.is_empty() {
408 return ModelFamily::Inferencer;
409 }
410
411 if normalized.contains("deepseek") {
412 return ModelFamily::DeepSeek;
413 }
414 if normalized.contains("claude") || normalized.contains("anthropic") {
415 return ModelFamily::Anthropic;
416 }
417 if normalized.contains("gpt-oss") || normalized.contains("gpt_oss") {
418 return ModelFamily::GptOss;
419 }
420 if normalized.starts_with("gpt-")
421 || normalized.contains("/gpt-")
422 || normalized.contains("openai/")
423 {
424 return ModelFamily::OpenAI;
425 }
426 if normalized.contains("gemini")
427 || normalized.contains("gemma")
428 || normalized.contains("google/")
429 {
430 return ModelFamily::Google;
431 }
432 if normalized.contains("llama")
433 || normalized.contains("muse-spark")
434 || normalized.contains("meta-")
435 || normalized.contains("meta/")
436 {
437 return ModelFamily::Meta;
438 }
439 if normalized.contains("mistral")
440 || normalized.contains("mixtral")
441 || normalized.contains("codestral")
442 {
443 return ModelFamily::Mistral;
444 }
445 if normalized.contains("qwen") {
446 return ModelFamily::Qwen;
447 }
448 if normalized.contains("grok") {
449 return ModelFamily::Grok;
450 }
451 if normalized.contains("cohere") || normalized.contains("command-r") {
452 return ModelFamily::Cohere;
453 }
454
455 ModelFamily::Inferencer
456 }
457
458 fn model_matches(model: &ModelInfo, requested: &str) -> bool {
459 let requested = normalize(requested);
460 normalize(&model.id) == requested
461 || model
462 .aliases
463 .iter()
464 .any(|alias| normalize(alias) == requested)
465 }
466
467 fn atlascloud_passthrough_model(requested: &str) -> Option<ModelInfo> {
468 let requested = requested.trim();
469 if requested.is_empty() || !requested.contains('/') {
470 return None;
471 }
472
473 Some(ModelInfo {
474 id: requested.to_string(),
475 provider: ProviderKind::Atlascloud,
476 aliases: Vec::new(),
477 supports_tools: true,
478 supports_reasoning: true,
479 })
480 }
481
482 fn arcee_passthrough_model(requested: &str) -> Option<ModelInfo> {
483 let requested = requested.trim();
484 if requested.is_empty() {
485 return None;
486 }
487 let supports_reasoning = requested.to_ascii_lowercase().contains("thinking");
488
489 Some(ModelInfo {
490 id: requested.to_string(),
491 provider: ProviderKind::Arcee,
492 aliases: Vec::new(),
493 supports_tools: true,
494 supports_reasoning,
495 })
496 }
497
498 fn xiaomi_mimo_passthrough_model(requested: &str) -> Option<ModelInfo> {
499 let requested = requested.trim();
500 if requested.is_empty() || requested.chars().any(char::is_control) {
501 return None;
502 }
503
504 Some(ModelInfo {
505 id: requested.to_string(),
506 provider: ProviderKind::XiaomiMimo,
507 aliases: Vec::new(),
508 supports_tools: true,
509 supports_reasoning: true,
510 })
511 }
512
513 #[cfg(test)]
514 mod tests {
515 use super::*;
516
517 trait ModelRegistryTestExt {
518 fn resolve_ok(
519 &self,
520 requested: Option<&str>,
521 provider_hint: Option<ProviderKind>,
522 ) -> ModelResolution;
523 }
524
525 impl ModelRegistryTestExt for ModelRegistry {
526 fn resolve_ok(
527 &self,
528 requested: Option<&str>,
529 provider_hint: Option<ProviderKind>,
530 ) -> ModelResolution {
531 self.resolve(requested, provider_hint)
532 .expect("test route should resolve")
533 }
534 }
535
536 #[test]
537 fn model_registry_new_preserves_model_rows_and_aliases() {
538 let models = vec![
539 ModelInfo {
540 id: "Model-A".to_string(),
541 provider: ProviderKind::Deepseek,
542 aliases: vec!["alias-1".to_string(), " ALIAS-2 ".to_string()],
543 supports_tools: true,
544 supports_reasoning: false,
545 },
546 ModelInfo {
547 id: "model-b".to_string(),
548 provider: ProviderKind::Deepseek,
549 aliases: vec!["alias-1".to_string()],
550 supports_tools: true,
551 supports_reasoning: true,
552 },
553 ];
554
555 let registry = ModelRegistry::new(models);
556
557 let rows = registry.list();
558 assert_eq!(rows.len(), 2);
559 assert_eq!(rows[0].id, "Model-A");
560 assert_eq!(rows[0].aliases, ["alias-1", " ALIAS-2 "]);
561 assert_eq!(rows[1].id, "model-b");
562 }
563
564 #[test]
565 fn deepseek_v4_pro_alias_stays_deepseek_when_provider_selected() {
566 let registry = ModelRegistry::default();
567 let resolved = registry.resolve_ok(Some("deepseek-v4-pro"), Some(ProviderKind::Deepseek));
568
569 assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
570 assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
571 }
572
573 #[test]
574 fn providerless_unknown_model_requires_explicit_route_authority() {
575 let registry = ModelRegistry::default();
576
577 for requested in [None, Some("deepseek-v4-pro"), Some("not-in-the-catalog")] {
578 let error = ModelRegistry::resolve(&registry, requested, None)
579 .expect_err("provider-less fallback must fail closed");
580 assert_eq!(
581 error,
582 ModelResolutionError::ProviderRequired {
583 requested: requested.map(str::to_string),
584 }
585 );
586 if requested.is_none() || requested == Some("not-in-the-catalog") {
587 assert!(!error.to_string().to_ascii_lowercase().contains("deepseek"));
588 }
589 }
590 }
591
592 #[test]
593 fn providerless_unknown_selectors_never_mint_provider_authority() {
594 let registry = ModelRegistry::default();
595
596 for requested in [
597 "deepseek-v4-not-a-real-model",
598 "gpt-not-a-real-model",
599 "provider/model-that-does-not-exist",
600 ] {
601 assert!(matches!(
602 ModelRegistry::resolve(&registry, Some(requested), None),
603 Err(ModelResolutionError::ProviderRequired {
604 requested: Some(returned),
605 }) if returned == requested
606 ));
607 }
608 for requested in ["", " "] {
609 assert!(matches!(
610 ModelRegistry::resolve(&registry, Some(requested), None),
611 Err(ModelResolutionError::ProviderRequired { requested: None })
612 ));
613 }
614 }
615
616 #[test]
617 fn explicit_provider_with_no_registry_rows_never_borrows_global_default() {
618 let registry = ModelRegistry::new(Vec::new());
619
620 let error = ModelRegistry::resolve(
621 &registry,
622 Some("provider-owned-model"),
623 Some(ProviderKind::Openrouter),
624 )
625 .expect_err("an empty provider catalog must not borrow another route");
626 assert_eq!(
627 error,
628 ModelResolutionError::ProviderHasNoModels {
629 provider: ProviderKind::Openrouter,
630 requested: Some("provider-owned-model".to_string()),
631 }
632 );
633 assert!(!error.to_string().to_ascii_lowercase().contains("deepseek"));
634 }
635
636 #[test]
637 fn explicit_deepseek_selection_retains_its_provider_owned_default() {
638 let registry = ModelRegistry::default();
639 let resolved = registry.resolve_ok(None, Some(ProviderKind::Deepseek));
640
641 assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
642 // The descriptor's declared default, not the first cloud row: the
643 // registry's Deepseek rows start at v4-pro, and borrowing that here is
644 // exactly the mismatch #6443 fixed.
645 assert_eq!(resolved.resolved.id, "deepseek-flash");
646 assert!(resolved.used_fallback);
647 assert_eq!(resolved.fallback_chain, ["provider_default:deepseek"]);
648 }
649
650 #[test]
651 fn explicit_openai_selection_uses_its_documented_default_not_first_catalog_row() {
652 let registry = ModelRegistry::default();
653
654 for requested in [None, Some(""), Some(" ")] {
655 let resolved = registry.resolve_ok(requested, Some(ProviderKind::Openai));
656 assert_eq!(resolved.requested, None);
657 assert_eq!(resolved.resolved.provider, ProviderKind::Openai);
658 assert_eq!(resolved.resolved.id, "gpt-5.6");
659 assert!(resolved.used_fallback);
660 assert_eq!(resolved.fallback_chain, ["provider_default:openai"]);
661 }
662 }
663
664 #[test]
665 fn provider_default_without_a_registry_row_resolves_to_its_own_id() {
666 // SHA-6443: the descriptor owns its declared default. A registry that
667 // carries unrelated rows must still resolve the provider's own
668 // default — and must never borrow another provider's model.
669 let registry = ModelRegistry::new(vec![ModelInfo {
670 id: "not-the-openai-default".to_string(),
671 provider: ProviderKind::Openai,
672 aliases: Vec::new(),
673 supports_tools: true,
674 supports_reasoning: true,
675 }]);
676
677 let resolved = registry
678 .resolve(None, Some(ProviderKind::Openai))
679 .expect("the provider's declared default resolves for that provider");
680 assert_eq!(resolved.resolved.id, "gpt-5.6");
681 assert_eq!(resolved.resolved.provider, ProviderKind::Openai);
682 assert!(resolved.used_fallback);
683 }
684
685 #[test]
686 fn deepseek_vision_model_lists_and_resolves_with_aliases() {
687 let registry = ModelRegistry::default();
688 let listed = registry.list();
689
690 assert!(listed.iter().any(|model| {
691 model.provider == ProviderKind::Deepseek
692 && model.id == "deepseek-v4-flash-vision-exp"
693 && model.aliases
694 == [
695 "flash-vision".to_string(),
696 "deepseek-v4flashvisionexp".to_string(),
697 ]
698 }));
699
700 for selector in [
701 "deepseek-v4-flash-vision-exp",
702 "flash-vision",
703 "deepseek-v4flashvisionexp",
704 ] {
705 let resolved = registry.resolve_ok(Some(selector), Some(ProviderKind::Deepseek));
706 assert_eq!(
707 resolved.resolved.id, "deepseek-v4-flash-vision-exp",
708 "{selector} must resolve to the experimental vision model"
709 );
710 assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
711 assert!(!resolved.used_fallback, "{selector} must not fall back");
712 }
713 }
714
715 #[test]
716 fn deepseek_v4_pro_alias_resolves_to_nvidia_nim_when_provider_hinted() {
717 let registry = ModelRegistry::default();
718 let resolved = registry.resolve_ok(Some("deepseek-v4-pro"), Some(ProviderKind::NvidiaNim));
719
720 assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
721 assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
722 }
723
724 #[test]
725 fn nvidia_nim_default_uses_catalog_model_id() {
726 let registry = ModelRegistry::default();
727 let resolved = registry.resolve_ok(None, Some(ProviderKind::NvidiaNim));
728
729 assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
730 assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
731 }
732
733 #[test]
734 fn deepseek_v4_flash_alias_resolves_to_nvidia_nim_when_provider_hinted() {
735 let registry = ModelRegistry::default();
736 let resolved =
737 registry.resolve_ok(Some("deepseek-v4-flash"), Some(ProviderKind::NvidiaNim));
738
739 assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
740 assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
741 }
742
743 #[test]
744 fn atlascloud_default_uses_namespaced_model_id() {
745 let registry = ModelRegistry::default();
746 let resolved = registry.resolve_ok(None, Some(ProviderKind::Atlascloud));
747
748 assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
749 assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
750 assert!(resolved.resolved.supports_reasoning);
751 }
752
753 #[test]
754 fn deepseek_v4_flash_alias_resolves_to_atlascloud_when_provider_hinted() {
755 let registry = ModelRegistry::default();
756 let resolved =
757 registry.resolve_ok(Some("deepseek-v4-flash"), Some(ProviderKind::Atlascloud));
758
759 assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
760 assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
761 }
762
763 #[test]
764 fn deepseek_v4_pro_alias_resolves_to_atlascloud_when_provider_hinted() {
765 let registry = ModelRegistry::default();
766 let resolved = registry.resolve_ok(Some("deepseek-v4-pro"), Some(ProviderKind::Atlascloud));
767
768 assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
769 assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
770 }
771
772 #[test]
773 fn atlascloud_provider_hint_passes_through_explicit_model_id() {
774 let registry = ModelRegistry::default();
775 let resolved =
776 registry.resolve_ok(Some("openai/gpt-5.2-chat"), Some(ProviderKind::Atlascloud));
777
778 assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
779 assert_eq!(resolved.resolved.id, "openai/gpt-5.2-chat");
780 assert!(resolved.resolved.supports_tools);
781 assert!(resolved.resolved.supports_reasoning);
782 assert!(!resolved.used_fallback);
783 }
784
785 #[test]
786 fn atlascloud_provider_hint_preserves_explicit_model_id_case() {
787 let registry = ModelRegistry::default();
788 let resolved =
789 registry.resolve_ok(Some("Qwen/Qwen3-Coder"), Some(ProviderKind::Atlascloud));
790
791 assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
792 assert_eq!(resolved.resolved.id, "Qwen/Qwen3-Coder");
793 assert!(!resolved.used_fallback);
794 }
795
796 #[test]
797 fn atlascloud_plain_unknown_model_rejects_instead_of_using_default() {
798 let registry = ModelRegistry::default();
799 let error = registry
800 .resolve(Some("not-in-atlas"), Some(ProviderKind::Atlascloud))
801 .expect_err("a requested unknown model must not become the provider default");
802
803 assert_eq!(
804 error,
805 ModelResolutionError::ModelNotAvailableForProvider {
806 provider: ProviderKind::Atlascloud,
807 requested: "not-in-atlas".to_string(),
808 }
809 );
810 }
811
812 #[test]
813 fn openrouter_default_uses_namespaced_model_id() {
814 let registry = ModelRegistry::default();
815 let resolved = registry.resolve_ok(None, Some(ProviderKind::Openrouter));
816
817 assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
818 assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
819 }
820
821 #[test]
822 fn xiaomi_mimo_default_uses_canonical_model_id() {
823 let registry = ModelRegistry::default();
824 let resolved = registry.resolve_ok(None, Some(ProviderKind::XiaomiMimo));
825
826 assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
827 assert_eq!(resolved.resolved.id, "mimo-v2.5-pro");
828 assert!(resolved.resolved.supports_reasoning);
829 }
830
831 #[test]
832 fn moonshot_default_and_aliases_use_kimi_k27_code() {
833 let registry = ModelRegistry::default();
834
835 for requested in [None, Some("kimi"), Some("kimi-k2.7-code")] {
836 let resolved = registry.resolve_ok(requested, Some(ProviderKind::Moonshot));
837
838 assert_eq!(resolved.resolved.provider, ProviderKind::Moonshot);
839 assert_eq!(resolved.resolved.id, "kimi-k2.7-code");
840 assert!(resolved.resolved.supports_tools);
841 assert!(resolved.resolved.supports_reasoning);
842 }
843 }
844
845 #[test]
846 fn moonshot_explicit_kimi_k26_remains_available() {
847 let registry = ModelRegistry::default();
848 let resolved = registry.resolve_ok(Some("kimi-k2.6"), Some(ProviderKind::Moonshot));
849
850 assert_eq!(resolved.resolved.provider, ProviderKind::Moonshot);
851 assert_eq!(resolved.resolved.id, "kimi-k2.6");
852 assert!(resolved.resolved.supports_reasoning);
853 }
854
855 /// v0.9.1 dogfood report: a user ran `--provider moonshot --model kimi-k3` and was told
856 /// the model was `kimi-k2.7-code`. The registry knew neither Moonshot K3
857 /// product, so the explicit request fell through to the provider default.
858 #[test]
859 fn moonshot_resolves_both_k3_products_without_crossing_them() {
860 let registry = ModelRegistry::default();
861
862 for (requested, expected) in [("kimi-k3", "kimi-k3"), ("k3", "k3")] {
863 let resolved = registry.resolve_ok(Some(requested), Some(ProviderKind::Moonshot));
864
865 assert_eq!(resolved.resolved.provider, ProviderKind::Moonshot);
866 assert_eq!(resolved.resolved.id, expected, "{resolved:?}");
867 assert!(
868 !resolved.used_fallback,
869 "an explicit Moonshot K3 request is not a fallback: {resolved:?}"
870 );
871 }
872 }
873
874 /// The bare `k3` id belongs to the Kimi Code coding-plan endpoint and
875 /// `kimi-k3` to the direct platform endpoint. Neither may be laundered
876 /// into the other's id by alias expansion.
877 #[test]
878 fn moonshot_k3_ids_are_never_rewritten_into_each_other() {
879 let registry = ModelRegistry::default();
880
881 assert_eq!(
882 registry
883 .resolve_ok(Some("kimi-k3"), Some(ProviderKind::Moonshot))
884 .resolved
885 .id,
886 "kimi-k3"
887 );
888 assert_eq!(
889 registry
890 .resolve_ok(Some("k3"), Some(ProviderKind::Moonshot))
891 .resolved
892 .id,
893 "k3"
894 );
895 }
896
897 /// A provider-scoped question must never be answered with another
898 /// vendor's model. `kimi-k3` also exists in the OpenCode Go catalog;
899 /// before this fix that entry answered `--provider moonshot` requests.
900 #[test]
901 fn a_provider_hint_never_resolves_to_another_providers_model() {
902 let registry = ModelRegistry::default();
903
904 let error = registry
905 .resolve(Some("glm-5.2"), Some(ProviderKind::Moonshot))
906 .expect_err("a Moonshot request must not be answered by Z.ai or a default");
907 assert_eq!(
908 error,
909 ModelResolutionError::ModelNotAvailableForProvider {
910 provider: ProviderKind::Moonshot,
911 requested: "glm-5.2".to_string(),
912 }
913 );
914
915 let go = registry.resolve_ok(Some("kimi-k3"), Some(ProviderKind::OpencodeGo));
916 assert_eq!(go.resolved.provider, ProviderKind::OpencodeGo);
917 assert_eq!(go.resolved.id, "kimi-k3");
918 }
919
920 #[test]
921 fn xiaomi_mimo_tts_aliases_resolve_when_provider_hinted() {
922 let registry = ModelRegistry::default();
923 let resolved = registry.resolve_ok(Some("tts"), Some(ProviderKind::XiaomiMimo));
924 assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
925 assert_eq!(resolved.resolved.id, "mimo-v2.5-tts");
926 assert!(!resolved.resolved.supports_tools);
927 assert!(!resolved.resolved.supports_reasoning);
928
929 let resolved = registry.resolve_ok(Some("voice-design"), Some(ProviderKind::XiaomiMimo));
930 assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voicedesign");
931
932 let resolved = registry.resolve_ok(Some("voiceclone"), Some(ProviderKind::XiaomiMimo));
933 assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voiceclone");
934 }
935
936 #[test]
937 fn xiaomi_mimo_chat_aliases_resolve_when_provider_hinted() {
938 let registry = ModelRegistry::default();
939
940 let resolved = registry.resolve_ok(Some("omni"), Some(ProviderKind::XiaomiMimo));
941 assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
942 assert_eq!(resolved.resolved.id, "mimo-v2.5");
943 assert!(resolved.resolved.supports_tools);
944 }
945
946 #[test]
947 fn xiaomi_mimo_provider_hint_preserves_custom_model_id() {
948 let registry = ModelRegistry::default();
949 let resolved =
950 registry.resolve_ok(Some("account-custom-mimo"), Some(ProviderKind::XiaomiMimo));
951
952 assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
953 assert_eq!(resolved.resolved.id, "account-custom-mimo");
954 assert!(!resolved.used_fallback);
955 }
956
957 #[test]
958 fn xiaomi_mimo_provider_hint_does_not_reclassify_openrouter_model_id() {
959 let registry = ModelRegistry::default();
960 let resolved = registry.resolve_ok(
961 Some("deepseek/deepseek-v4-pro"),
962 Some(ProviderKind::XiaomiMimo),
963 );
964
965 assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
966 assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
967 assert!(!resolved.used_fallback);
968 }
969
970 #[test]
971 fn wanjie_ark_default_uses_reasoner_model_id() {
972 let registry = ModelRegistry::default();
973 let resolved = registry.resolve_ok(None, Some(ProviderKind::WanjieArk));
974
975 assert_eq!(resolved.resolved.provider, ProviderKind::WanjieArk);
976 assert_eq!(resolved.resolved.id, "deepseek-reasoner");
977 assert!(resolved.resolved.supports_reasoning);
978 }
979
980 #[test]
981 fn novita_default_uses_namespaced_model_id() {
982 let registry = ModelRegistry::default();
983 let resolved = registry.resolve_ok(None, Some(ProviderKind::Novita));
984
985 assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
986 assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
987 }
988
989 #[test]
990 fn fireworks_default_uses_canonical_model_id() {
991 let registry = ModelRegistry::default();
992 let resolved = registry.resolve_ok(None, Some(ProviderKind::Fireworks));
993
994 assert_eq!(resolved.resolved.provider, ProviderKind::Fireworks);
995 assert_eq!(
996 resolved.resolved.id,
997 "accounts/fireworks/models/deepseek-v4-pro"
998 );
999 }
1000
1001 #[test]
1002 fn siliconflow_default_uses_canonical_pro_model_id() {
1003 let registry = ModelRegistry::default();
1004 let resolved = registry.resolve_ok(None, Some(ProviderKind::Siliconflow));
1005
1006 assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
1007 assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1008 assert!(resolved.resolved.supports_reasoning);
1009 }
1010
1011 #[test]
1012 fn arcee_default_uses_direct_trinity_large_thinking_model_id() {
1013 let registry = ModelRegistry::default();
1014 let resolved = registry.resolve_ok(None, Some(ProviderKind::Arcee));
1015
1016 assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1017 assert_eq!(resolved.resolved.id, "trinity-large-thinking");
1018 assert!(resolved.resolved.supports_reasoning);
1019 }
1020
1021 #[test]
1022 fn arcee_trinity_alias_resolves_to_direct_large_thinking_not_openrouter() {
1023 let registry = ModelRegistry::default();
1024 let resolved = registry.resolve_ok(Some("trinity"), Some(ProviderKind::Arcee));
1025
1026 assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1027 assert_eq!(resolved.resolved.id, "trinity-large-thinking");
1028 assert!(resolved.resolved.supports_reasoning);
1029 }
1030
1031 #[test]
1032 fn arcee_trinity_mini_remains_explicit_compatibility_model() {
1033 let registry = ModelRegistry::default();
1034 let resolved = registry.resolve_ok(Some("trinity-mini"), Some(ProviderKind::Arcee));
1035
1036 assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1037 assert_eq!(resolved.resolved.id, "trinity-mini");
1038 assert!(resolved.resolved.supports_reasoning);
1039 assert!(!resolved.used_fallback);
1040 }
1041
1042 #[test]
1043 fn arcee_provider_hint_preserves_explicit_future_model_id() {
1044 let registry = ModelRegistry::default();
1045 let resolved = registry.resolve_ok(Some("trinity-large-next"), Some(ProviderKind::Arcee));
1046
1047 assert_eq!(resolved.resolved.provider, ProviderKind::Arcee);
1048 assert_eq!(resolved.resolved.id, "trinity-large-next");
1049 assert!(!resolved.resolved.supports_reasoning);
1050 assert!(!resolved.used_fallback);
1051 }
1052
1053 #[test]
1054 fn deepseek_reasoner_does_not_silently_substitute_siliconflow_pro() {
1055 let registry = ModelRegistry::default();
1056 let error = registry
1057 .resolve(Some("deepseek-reasoner"), Some(ProviderKind::Siliconflow))
1058 .expect_err("an absent alias must not become SiliconFlow's first/default row");
1059
1060 assert_eq!(
1061 error,
1062 ModelResolutionError::ModelNotAvailableForProvider {
1063 provider: ProviderKind::Siliconflow,
1064 requested: "deepseek-reasoner".to_string(),
1065 }
1066 );
1067 }
1068
1069 #[test]
1070 fn deepseek_v4_flash_alias_resolves_to_siliconflow_flash_when_provider_hinted() {
1071 let registry = ModelRegistry::default();
1072 let resolved =
1073 registry.resolve_ok(Some("deepseek-v4-flash"), Some(ProviderKind::Siliconflow));
1074
1075 assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow);
1076 assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
1077 }
1078
1079 #[test]
1080 fn sglang_default_uses_canonical_model_id() {
1081 let registry = ModelRegistry::default();
1082 let resolved = registry.resolve_ok(None, Some(ProviderKind::Sglang));
1083
1084 assert_eq!(resolved.resolved.provider, ProviderKind::Sglang);
1085 assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1086 }
1087
1088 #[test]
1089 fn zai_direct_models_resolve_when_provider_hinted() {
1090 let registry = ModelRegistry::default();
1091
1092 // Keep the agent registry fallback aligned with codewhale-config's
1093 // DEFAULT_ZAI_MODEL.
1094 let default = registry.resolve_ok(None, Some(ProviderKind::Zai));
1095 assert_eq!(default.resolved.provider, ProviderKind::Zai);
1096 assert_eq!(default.resolved.id, "GLM-5.3");
1097 assert!(default.used_fallback);
1098 assert_eq!(default.fallback_chain, ["provider_default:zai"]);
1099
1100 for (alias, expected) in [
1101 ("GLM-5.1", "GLM-5.1"),
1102 ("glm-5-1", "GLM-5.1"),
1103 ("GLM-5.2", "GLM-5.2"),
1104 ("glm-5.2", "GLM-5.2"),
1105 ("zai-glm-5-2", "GLM-5.2"),
1106 ("GLM-5.3", "GLM-5.3"),
1107 ("glm-5.3", "GLM-5.3"),
1108 ("glm-5-3", "GLM-5.3"),
1109 ("zai-glm-5-3", "GLM-5.3"),
1110 ("GLM-5.3-Flash", "GLM-5.3-Flash"),
1111 ("glm-5.3-flash", "GLM-5.3-Flash"),
1112 ("glm-5-3-flash", "GLM-5.3-Flash"),
1113 ("zai-glm-5.3-flash", "GLM-5.3-Flash"),
1114 ("GLM-5-Turbo", "GLM-5-Turbo"),
1115 ("glm-5-turbo", "GLM-5-Turbo"),
1116 ("zai-glm-5-turbo", "GLM-5-Turbo"),
1117 ] {
1118 let resolved = registry.resolve_ok(Some(alias), Some(ProviderKind::Zai));
1119
1120 assert_eq!(resolved.resolved.provider, ProviderKind::Zai);
1121 assert_eq!(resolved.resolved.id, expected);
1122 assert!(!resolved.used_fallback);
1123 assert!(resolved.resolved.supports_tools);
1124 assert!(resolved.resolved.supports_reasoning);
1125 }
1126 }
1127
1128 #[test]
1129 fn first_party_recent_provider_models_are_listed() {
1130 let registry = ModelRegistry::default();
1131 let models = registry.list();
1132
1133 for (provider, id) in [
1134 (ProviderKind::Zai, "GLM-5.2"),
1135 (ProviderKind::Stepfun, "step-3.7-flash"),
1136 (ProviderKind::Minimax, "MiniMax-M2.1"),
1137 (ProviderKind::MinimaxAnthropic, "MiniMax-M3"),
1138 (ProviderKind::Openmodel, "deepseek-v4-flash"),
1139 (ProviderKind::Meta, "muse-spark-1.2"),
1140 (ProviderKind::Xai, "grok-4.6"),
1141 ] {
1142 assert!(
1143 models
1144 .iter()
1145 .any(|model| model.provider == provider && model.id == id),
1146 "expected {provider:?} model {id} in registry"
1147 );
1148 }
1149 }
1150
1151 #[test]
1152 fn opencode_go_lists_documented_models_without_inventing_capabilities() {
1153 let registry = ModelRegistry::default();
1154 let listed = registry.list();
1155 let models: Vec<&str> = listed
1156 .iter()
1157 .filter(|model| model.provider == ProviderKind::OpencodeGo)
1158 .map(|model| model.id.as_str())
1159 .collect();
1160
1161 // Literal expectations independently catch an incomplete shared roster
1162 // and prevent new compatibility entries from claiming capabilities.
1163 let expected = [
1164 ("deepseek-v4-pro", true),
1165 ("grok-4.5", true),
1166 ("glm-5.2", true),
1167 ("glm-5.1", true),
1168 ("kimi-k3", true),
1169 ("kimi-k2.7-code", true),
1170 ("kimi-k2.6", true),
1171 ("deepseek-v4-flash", true),
1172 ("mimo-v2.5", true),
1173 ("mimo-v2.5-pro", true),
1174 ("glm-5.3-flash", false),
1175 ("glm-5.3", false),
1176 ("longcat-2.0", false),
1177 ("deepseek-v4-flash-vision-exp", false),
1178 ("hy4-preview", false),
1179 ("hy3", false),
1180 ("omen-alpha", false),
1181 ("deepseek-v4.1-flash", false),
1182 ("grok-4.6", false),
1183 ("gpt-5.6-luna", false),
1184 ("muse-spark-1.3-contributor", false),
1185 ("muse-spark-1.2-contributor", false),
1186 ("minimax-m3", false),
1187 ("minimax-m2.7", false),
1188 ("minimax-m2.5", false),
1189 ("qwen3.8-max", false),
1190 ("qwen3.8-flash", false),
1191 ("qwen3.7-max", false),
1192 ("qwen3.7-plus", false),
1193 ("qwen3.6-plus", false),
1194 ];
1195 assert_eq!(
1196 models,
1197 expected.iter().map(|(id, _)| *id).collect::<Vec<_>>()
1198 );
1199
1200 let default = registry.resolve_ok(None, Some(ProviderKind::OpencodeGo));
1201 assert_eq!(default.resolved.provider, ProviderKind::OpencodeGo);
1202 assert_eq!(default.resolved.id, "deepseek-v4-pro");
1203
1204 for (model, expected_capabilities) in expected {
1205 for requested in [model.to_string(), format!("opencode-go/{model}")] {
1206 let resolved =
1207 registry.resolve_ok(Some(&requested), Some(ProviderKind::OpencodeGo));
1208 assert_eq!(resolved.resolved.provider, ProviderKind::OpencodeGo);
1209 assert_eq!(resolved.resolved.id, model);
1210 assert!(!resolved.used_fallback);
1211 assert_eq!(
1212 resolved.resolved.aliases,
1213 vec![format!("opencode-go/{model}")],
1214 "{requested}"
1215 );
1216 assert_eq!(
1217 resolved.resolved.supports_tools, expected_capabilities,
1218 "{requested} tool support"
1219 );
1220 assert_eq!(
1221 resolved.resolved.supports_reasoning, expected_capabilities,
1222 "{requested} reasoning support"
1223 );
1224 }
1225 }
1226
1227 for non_chat in ["claude-unproven", "unknown-model", "gpt-unlisted"] {
1228 for requested in [non_chat.to_string(), format!("opencode-go/{non_chat}")] {
1229 let rejected = registry
1230 .resolve(Some(&requested), Some(ProviderKind::OpencodeGo))
1231 .expect_err("unknown Go id must not fall back to another provider");
1232 assert_eq!(
1233 rejected,
1234 ModelResolutionError::ModelNotAvailableForProvider {
1235 provider: ProviderKind::OpencodeGo,
1236 requested,
1237 }
1238 );
1239 }
1240 }
1241 }
1242
1243 #[test]
1244 fn xai_grok_models_resolve_when_provider_hinted() {
1245 let registry = ModelRegistry::default();
1246
1247 let default = registry.resolve_ok(None, Some(ProviderKind::Xai));
1248 assert_eq!(default.resolved.provider, ProviderKind::Xai);
1249 assert_eq!(default.resolved.id, "grok-4.6");
1250 assert!(default.used_fallback);
1251
1252 let alias = registry.resolve_ok(Some("grok"), Some(ProviderKind::Xai));
1253 assert_eq!(alias.resolved.provider, ProviderKind::Xai);
1254 assert_eq!(alias.resolved.id, "grok-4.6");
1255 assert!(!alias.used_fallback);
1256
1257 let fast = registry.resolve_ok(
1258 Some("grok-4.20-0309-non-reasoning"),
1259 Some(ProviderKind::Xai),
1260 );
1261 assert_eq!(fast.resolved.provider, ProviderKind::Xai);
1262 assert_eq!(fast.resolved.id, "grok-4.20-0309-non-reasoning");
1263 assert!(!fast.resolved.supports_reasoning);
1264 }
1265
1266 #[test]
1267 fn meta_muse_spark_resolves_when_provider_hinted() {
1268 let registry = ModelRegistry::default();
1269
1270 let default = registry.resolve_ok(None, Some(ProviderKind::Meta));
1271 assert_eq!(default.resolved.provider, ProviderKind::Meta);
1272 assert_eq!(default.resolved.id, "muse-spark-1.2");
1273 assert!(default.used_fallback);
1274
1275 let alias = registry.resolve_ok(Some("muse-spark"), Some(ProviderKind::Meta));
1276 assert_eq!(alias.resolved.provider, ProviderKind::Meta);
1277 assert_eq!(alias.resolved.id, "muse-spark-1.2");
1278 assert!(!alias.used_fallback);
1279 assert_eq!(model_family("muse-spark-1.2"), ModelFamily::Meta);
1280 }
1281
1282 #[test]
1283 fn openai_gpt56_family_resolves_when_provider_hinted() {
1284 let registry = ModelRegistry::default();
1285 for model in ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] {
1286 let resolved = registry.resolve_ok(Some(model), Some(ProviderKind::Openai));
1287 assert_eq!(resolved.resolved.provider, ProviderKind::Openai, "{model}");
1288 assert_eq!(resolved.resolved.id, model, "{model}");
1289 assert!(resolved.resolved.supports_tools, "{model}");
1290 assert!(resolved.resolved.supports_reasoning, "{model}");
1291 assert!(!resolved.used_fallback, "{model}");
1292 }
1293 }
1294
1295 #[test]
1296 fn grok_ids_stay_in_grok_family() {
1297 assert_eq!(model_family("grok-4.6"), ModelFamily::Grok);
1298 assert_eq!(model_family("grok-4.5"), ModelFamily::Grok);
1299 assert_eq!(
1300 model_family("grok-4.20-0309-non-reasoning"),
1301 ModelFamily::Grok
1302 );
1303 }
1304
1305 #[test]
1306 fn stepfun_and_minimax_direct_models_resolve_when_provider_hinted() {
1307 let registry = ModelRegistry::default();
1308
1309 let stepfun = registry.resolve_ok(None, Some(ProviderKind::Stepfun));
1310 assert_eq!(stepfun.resolved.provider, ProviderKind::Stepfun);
1311 assert_eq!(stepfun.resolved.id, "step-3.7-flash");
1312
1313 for (alias, expected) in [
1314 ("minimax", "MiniMax-M3"),
1315 ("minimax-m3", "MiniMax-M3"),
1316 ("minimax-m2.7", "MiniMax-M2.7"),
1317 ("minimax-m2-7-highspeed", "MiniMax-M2.7-highspeed"),
1318 ("minimax-m2.1", "MiniMax-M2.1"),
1319 ("minimax-m2", "MiniMax-M2"),
1320 ] {
1321 let resolved = registry.resolve_ok(Some(alias), Some(ProviderKind::Minimax));
1322
1323 assert_eq!(resolved.resolved.provider, ProviderKind::Minimax);
1324 assert_eq!(resolved.resolved.id, expected);
1325 assert!(!resolved.used_fallback);
1326 assert!(resolved.resolved.supports_tools);
1327 assert!(resolved.resolved.supports_reasoning);
1328 }
1329 }
1330
1331 #[test]
1332 fn minimax_anthropic_models_resolve_when_provider_hinted() {
1333 let registry = ModelRegistry::default();
1334
1335 for (alias, expected) in [
1336 ("minimax-anthropic", "MiniMax-M3"),
1337 ("minimax-m3", "MiniMax-M3"),
1338 ("minimax-m2.7", "MiniMax-M2.7"),
1339 ] {
1340 let resolved = registry.resolve_ok(Some(alias), Some(ProviderKind::MinimaxAnthropic));
1341
1342 assert_eq!(resolved.resolved.provider, ProviderKind::MinimaxAnthropic);
1343 assert_eq!(resolved.resolved.id, expected);
1344 assert!(!resolved.used_fallback);
1345 assert!(resolved.resolved.supports_tools);
1346 assert!(resolved.resolved.supports_reasoning);
1347 }
1348 }
1349
1350 #[test]
1351 fn deepseek_v4_flash_alias_resolves_to_openrouter_when_provider_hinted() {
1352 let registry = ModelRegistry::default();
1353 let resolved =
1354 registry.resolve_ok(Some("deepseek-v4-flash"), Some(ProviderKind::Openrouter));
1355
1356 assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
1357 assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
1358 }
1359
1360 #[test]
1361 fn recent_openrouter_large_model_aliases_resolve_when_provider_hinted() {
1362 let registry = ModelRegistry::default();
1363
1364 for (alias, expected) in [
1365 ("trinity-large-thinking", "arcee-ai/trinity-large-thinking"),
1366 ("qwen3.6-flash", "qwen/qwen3.6-flash"),
1367 ("qwen3.6-35b-a3b", "qwen/qwen3.6-35b-a3b"),
1368 ("qwen3.6-max-preview", "qwen/qwen3.6-max-preview"),
1369 ("qwen3.6-plus", "qwen/qwen3.6-plus"),
1370 ("gemma-4-31b-it", "google/gemma-4-31b-it"),
1371 ("glm-5.1", "z-ai/glm-5.1"),
1372 ("glm-5.2", "z-ai/glm-5.2"),
1373 ("glm-5.3", "z-ai/glm-5.3"),
1374 ("glm-5.3-flash", "z-ai/glm-5.3-flash"),
1375 ("minimax-m3", "minimax/minimax-m3"),
1376 ("minimax-2.7", "minimax/minimax-m2.7"),
1377 ("openrouter-mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro"),
1378 ("openrouter-kimi-k2.7-code", "moonshotai/kimi-k2.7-code"),
1379 ("openrouter-kimi-k2.6", "moonshotai/kimi-k2.6"),
1380 ("nemotron-3-ultra", "nvidia/nemotron-3-ultra-550b-a55b"),
1381 (
1382 "nvidia/nemotron-3-ultra",
1383 "nvidia/nemotron-3-ultra-550b-a55b",
1384 ),
1385 ] {
1386 let resolved = registry.resolve_ok(Some(alias), Some(ProviderKind::Openrouter));
1387
1388 assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
1389 assert_eq!(resolved.resolved.id, expected);
1390 assert!(resolved.resolved.supports_tools);
1391 assert!(resolved.resolved.supports_reasoning);
1392 }
1393 }
1394
1395 #[test]
1396 fn deepseek_v4_flash_alias_resolves_to_novita_when_provider_hinted() {
1397 let registry = ModelRegistry::default();
1398 let resolved = registry.resolve_ok(Some("deepseek-v4-flash"), Some(ProviderKind::Novita));
1399
1400 assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
1401 assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
1402 }
1403
1404 #[test]
1405 fn together_inkling_keeps_published_wire_identity() {
1406 let registry = ModelRegistry::default();
1407 for requested in ["thinkingmachines/inkling", "inkling", "together-inkling"] {
1408 let resolved = registry.resolve_ok(Some(requested), Some(ProviderKind::Together));
1409
1410 assert_eq!(resolved.resolved.provider, ProviderKind::Together);
1411 assert_eq!(resolved.resolved.id, "thinkingmachines/inkling");
1412 assert!(resolved.resolved.supports_tools);
1413 assert!(resolved.resolved.supports_reasoning);
1414 assert!(!resolved.used_fallback);
1415 }
1416
1417 assert!(matches!(
1418 registry.resolve(Some("inkling"), None),
1419 Err(ModelResolutionError::ProviderRequired { .. })
1420 ));
1421 }
1422
1423 #[test]
1424 fn registry_lists_and_resolves_every_v090_catalog_addition() {
1425 let registry = ModelRegistry::default();
1426 let advertised = [
1427 (ProviderKind::Anthropic, "claude-sonnet-5"),
1428 (ProviderKind::Anthropic, "claude-fable-5"),
1429 (ProviderKind::Openai, "gpt-5.3-codex"),
1430 (ProviderKind::Openai, "gpt-5.5"),
1431 (ProviderKind::Openai, "gpt-5.5-pro"),
1432 (ProviderKind::Openrouter, "qwen/qwen3.7-plus"),
1433 (ProviderKind::Arcee, "trinity-mini"),
1434 ];
1435
1436 let listed = registry.list();
1437 for (provider, model_id) in advertised {
1438 assert!(
1439 listed
1440 .iter()
1441 .any(|model| model.provider == provider && model.id == model_id),
1442 "missing {model_id} ({}) from model list",
1443 provider.as_str()
1444 );
1445 let resolved = registry.resolve_ok(Some(model_id), Some(provider));
1446 assert_eq!(resolved.resolved.provider, provider, "{model_id}");
1447 assert_eq!(resolved.resolved.id, model_id, "{model_id}");
1448 assert!(!resolved.used_fallback, "{model_id}");
1449 }
1450 }
1451
1452 #[test]
1453 fn gpt_55_stays_provider_scoped_between_openai_and_codex() {
1454 let registry = ModelRegistry::default();
1455
1456 assert!(matches!(
1457 registry.resolve(Some("gpt-5.5"), None),
1458 Err(ModelResolutionError::ProviderRequired { .. })
1459 ));
1460
1461 let codex = registry.resolve_ok(Some("gpt-5.5"), Some(ProviderKind::OpenaiCodex));
1462 assert_eq!(codex.resolved.provider, ProviderKind::OpenaiCodex);
1463 assert_eq!(codex.resolved.id, "gpt-5.5");
1464 assert!(!codex.used_fallback);
1465 }
1466
1467 #[test]
1468 fn deepseek_v4_flash_alias_resolves_to_sglang_when_provider_hinted() {
1469 let registry = ModelRegistry::default();
1470 let resolved = registry.resolve_ok(Some("deepseek-v4-flash"), Some(ProviderKind::Sglang));
1471
1472 assert_eq!(resolved.resolved.provider, ProviderKind::Sglang);
1473 assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
1474 }
1475
1476 #[test]
1477 fn vllm_default_uses_canonical_model_id() {
1478 let registry = ModelRegistry::default();
1479 let resolved = registry.resolve_ok(None, Some(ProviderKind::Vllm));
1480
1481 assert_eq!(resolved.resolved.provider, ProviderKind::Vllm);
1482 assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
1483 }
1484
1485 #[test]
1486 fn ollama_default_is_unavailable_until_the_local_catalog_answers() {
1487 // Y-2: `DEFAULT_OLLAMA_MODEL` is deliberately "unknown". The real
1488 // default comes from the live local catalog, so the header never names
1489 // a model the session cannot reach; without that catalog the registry
1490 // must say so instead of resolving a costume.
1491 let registry = ModelRegistry::default();
1492 let error = registry
1493 .resolve(None, Some(ProviderKind::Ollama))
1494 .expect_err("the placeholder default must not resolve");
1495
1496 assert!(matches!(
1497 error,
1498 ModelResolutionError::ProviderDefaultUnavailable {
1499 provider: ProviderKind::Ollama,
1500 ref default_model,
1501 } if default_model == "unknown"
1502 ));
1503 }
1504
1505 #[test]
1506 fn ollama_cloud_default_uses_the_hosted_catalog_model_id() {
1507 let registry = ModelRegistry::default();
1508 let resolved = registry.resolve_ok(None, Some(ProviderKind::OllamaCloud));
1509
1510 assert_eq!(resolved.resolved.provider, ProviderKind::OllamaCloud);
1511 assert_eq!(resolved.resolved.id, "gpt-oss:120b");
1512 assert!(resolved.resolved.supports_reasoning);
1513 }
1514
1515 #[test]
1516 fn ollama_requested_model_tag_is_preserved() {
1517 let registry = ModelRegistry::default();
1518 let resolved = registry.resolve_ok(Some("qwen2.5-coder:7b"), Some(ProviderKind::Ollama));
1519
1520 assert_eq!(resolved.resolved.provider, ProviderKind::Ollama);
1521 assert_eq!(resolved.resolved.id, "qwen2.5-coder:7b");
1522 assert!(!resolved.used_fallback);
1523 }
1524
1525 #[test]
1526 fn deepseek_v4_flash_alias_resolves_to_vllm_when_provider_hinted() {
1527 let registry = ModelRegistry::default();
1528 let resolved = registry.resolve_ok(Some("deepseek-v4-flash"), Some(ProviderKind::Vllm));
1529
1530 assert_eq!(resolved.resolved.provider, ProviderKind::Vllm);
1531 assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
1532 }
1533
1534 #[test]
1535 fn providerless_cased_model_text_does_not_authorize_deepseek() {
1536 let registry = ModelRegistry::default();
1537 assert!(matches!(
1538 registry.resolve(Some("DeepSeek-V4-Pro"), None),
1539 Err(ModelResolutionError::ProviderRequired { .. })
1540 ));
1541 }
1542
1543 #[test]
1544 fn registry_casing_takes_priority_over_requested_casing_with_provider_hint() {
1545 let registry = ModelRegistry::default();
1546 let resolved = registry.resolve_ok(Some("DeepSeek-V4-Pro"), Some(ProviderKind::Deepseek));
1547
1548 assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
1549 // Registry's canonical id is used even when user provides different casing
1550 assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
1551 }
1552
1553 #[test]
1554 fn providerless_whitespace_model_text_does_not_authorize_deepseek() {
1555 let registry = ModelRegistry::default();
1556 assert!(matches!(
1557 registry.resolve(Some(" DeepSeek-V4-Pro "), None),
1558 Err(ModelResolutionError::ProviderRequired { .. })
1559 ));
1560 }
1561
1562 #[test]
1563 fn alias_match_does_not_override_requested_casing() {
1564 let registry = ModelRegistry::default();
1565 let resolved = registry.resolve_ok(Some("deepseek-reasoner"), Some(ProviderKind::Deepseek));
1566
1567 assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
1568 assert_eq!(resolved.resolved.id, "deepseek-v4-flash");
1569 }
1570
1571 #[test]
1572 fn model_family_classifies_known_model_ids() {
1573 assert_eq!(model_family("deepseek-v4-pro"), ModelFamily::DeepSeek);
1574 assert_eq!(model_family("openai/gpt-5.4"), ModelFamily::OpenAI);
1575 assert_eq!(
1576 model_family("anthropic/claude-opus-4-7"),
1577 ModelFamily::Anthropic
1578 );
1579 assert_eq!(
1580 model_family("meta-llama/llama-3.3-70b-instruct"),
1581 ModelFamily::Meta
1582 );
1583 assert_eq!(model_family("Qwen/Qwen3-Coder"), ModelFamily::Qwen);
1584 }
1585
1586 #[test]
1587 fn model_family_uses_underlying_model_for_router_ids() {
1588 assert_eq!(
1589 model_family("groq/llama-3.3-70b-versatile"),
1590 ModelFamily::Meta
1591 );
1592 assert_eq!(
1593 model_family("openrouter/openai/gpt-5.4"),
1594 ModelFamily::OpenAI
1595 );
1596 assert_eq!(
1597 model_family("fireworks/accounts/fireworks/models/deepseek-v4-pro"),
1598 ModelFamily::DeepSeek
1599 );
1600 }
1601
1602 #[test]
1603 fn model_family_covers_prominent_google_and_mistral_model_names() {
1604 assert_eq!(model_family("google/gemma-3-27b-it"), ModelFamily::Google);
1605 assert_eq!(
1606 model_family("mistralai/mixtral-8x22b"),
1607 ModelFamily::Mistral
1608 );
1609 assert_eq!(model_family("codestral-latest"), ModelFamily::Mistral);
1610 }
1611
1612 #[test]
1613 fn model_family_falls_back_to_inferencer_for_unknown_models() {
1614 assert_eq!(
1615 model_family("custom-gateway/my-private-model"),
1616 ModelFamily::Inferencer
1617 );
1618 assert_eq!(model_family(""), ModelFamily::Inferencer);
1619 }
1620
1621 /// SHA-6443: a provider's declared default must be a model its own
1622 /// registry can resolve. A default the registry rejects fails a test
1623 /// here, not a founder's `model resolve`.
1624 #[test]
1625 fn every_provider_default_resolves_for_its_own_provider() {
1626 let registry = ModelRegistry::default();
1627 let mut failures = Vec::new();
1628 for kind in ProviderKind::all() {
1629 let default = kind.provider().default_model();
1630 if default.trim().is_empty() {
1631 continue;
1632 }
1633 if let Err(error) = registry.resolve(Some(default), Some(*kind)) {
1634 failures.push(format!("{} ({kind:?}): {error}", default));
1635 }
1636 }
1637 assert!(
1638 failures.is_empty(),
1639 "provider defaults must resolve for their own provider:\n{}",
1640 failures.join("\n")
1641 );
1642 }
1643 }
1644
1644 lines RUST