1use std::borrow::Cow;
2use std::str::FromStr;
3
4use vtcode_config::api_keys::api_key_env_var;
5use vtcode_config::auth::AuthCredentialsStoreMode;
6use vtcode_config::models::{
7 ModelCatalogEntry, ModelId, ModelPricing, Provider, ProviderModelSupport, catalog_provider_keys,
8 model_catalog_entry,
9};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ModelAvailability {
13 Available,
14 MissingCredential,
15 ManagedAuthAvailable,
16 Misconfigured,
17 LocalOnly,
18}
19
20impl ModelAvailability {
21 pub fn requires_api_key(&self) -> bool {
22 matches!(self, Self::MissingCredential | Self::Misconfigured)
23 }
24
25 pub fn uses_managed_auth(&self) -> bool {
26 matches!(self, Self::ManagedAuthAvailable)
27 }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct DynamicModelMeta {
32 pub display_name: String,
33 pub description: Option<String>,
34 pub context_window: Option<usize>,
35}
36
37#[derive(Debug, Clone, Copy)]
38pub struct DynamicModelRef<'a> {
39 pub provider: Provider,
40 pub model_id: &'a str,
41}
42
43#[derive(Debug, Clone)]
44pub struct ResolvedModel {
45 pub provider: Provider,
46 pub model_id: String,
47 pub api_key_env: String,
48 pub catalog: Option<ModelCatalogEntry>,
49 pub dynamic: Option<DynamicModelMeta>,
50 pub availability: ModelAvailability,
51}
52
53impl ResolvedModel {
54 pub fn known_model(&self) -> bool {
55 self.catalog.is_some()
56 }
57
58 pub fn reasoning_supported(&self) -> bool {
59 self.catalog
60 .map(|entry| entry.reasoning)
61 .unwrap_or_else(|| self.provider.supports_reasoning(&self.model_id))
62 }
63
64 pub fn reasoning_effort_supported(&self) -> bool {
71 self.catalog
72 .map(|entry| !entry.reasoning_efforts.is_empty())
73 .unwrap_or_else(|| {
74 self.provider.supports_reasoning_effort(&self.model_id)
75 && !self.provider.supported_reasoning_efforts(&self.model_id).is_empty()
76 })
77 }
78
79 pub fn supported_reasoning_efforts(&self) -> &'static [&'static str] {
81 self.catalog
82 .map(|entry| entry.reasoning_efforts)
83 .unwrap_or_else(|| self.provider.supported_reasoning_efforts(&self.model_id))
84 }
85
86 pub fn service_tier_supported(&self) -> bool {
87 self.provider.supports_service_tier(&self.model_id)
88 }
89
90 pub fn supports_tool_calls(&self) -> bool {
91 self.catalog.map(|entry| entry.tool_call).unwrap_or(true)
92 }
93
94 pub fn context_window(&self) -> Option<usize> {
95 self.catalog
96 .map(|entry| entry.context_window)
97 .filter(|value| *value > 0)
98 .or_else(|| self.dynamic.as_ref().and_then(|dynamic| dynamic.context_window))
99 }
100
101 pub fn input_modalities(&self) -> &'static [&'static str] {
102 self.catalog.map(|entry| entry.input_modalities).unwrap_or(&[])
103 }
104
105 pub fn display_name(&self) -> Cow<'_, str> {
106 if let Some(catalog) = self.catalog {
107 return Cow::Borrowed(catalog.display_name);
108 }
109 if let Some(dynamic) = &self.dynamic {
110 return Cow::Borrowed(dynamic.display_name.as_str());
111 }
112 Cow::Borrowed(self.model_id.as_str())
113 }
114
115 pub fn description(&self) -> Option<Cow<'_, str>> {
116 if let Some(catalog) = self.catalog {
117 return (!catalog.description.is_empty()).then_some(Cow::Borrowed(catalog.description));
118 }
119 self.dynamic.as_ref().and_then(|dynamic| {
120 dynamic
121 .description
122 .as_deref()
123 .filter(|value| !value.is_empty())
124 .map(Cow::Borrowed)
125 })
126 }
127
128 pub fn pricing(&self) -> Option<ModelPricing> {
129 self.catalog.map(|entry| entry.pricing).filter(|pricing| {
130 pricing.input.is_some()
131 || pricing.output.is_some()
132 || pricing.cache_read.is_some()
133 || pricing.cache_write.is_some()
134 })
135 }
136
137 pub fn env_key(&self) -> String {
138 self.api_key_env.clone()
139 }
140}
141
142pub struct ModelResolver;
143
144impl ModelResolver {
145 pub fn resolve(
146 provider_override: Option<&str>,
147 model: &str,
148 dynamic_models: &[DynamicModelRef<'_>],
149 dynamic_meta: Option<DynamicModelMeta>,
150 ) -> Option<ResolvedModel> {
151 Self::resolve_with_mode(
152 provider_override,
153 model,
154 dynamic_models,
155 dynamic_meta,
156 AuthCredentialsStoreMode::default(),
157 )
158 }
159
160 pub fn resolve_with_mode(
166 provider_override: Option<&str>,
167 model: &str,
168 dynamic_models: &[DynamicModelRef<'_>],
169 dynamic_meta: Option<DynamicModelMeta>,
170 storage_mode: AuthCredentialsStoreMode,
171 ) -> Option<ResolvedModel> {
172 Self::resolve_with_mode_and_api_key_env(
173 provider_override,
174 model,
175 dynamic_models,
176 dynamic_meta,
177 None,
178 storage_mode,
179 )
180 }
181
182 pub fn resolve_with_mode_and_api_key_env(
185 provider_override: Option<&str>,
186 model: &str,
187 dynamic_models: &[DynamicModelRef<'_>],
188 dynamic_meta: Option<DynamicModelMeta>,
189 api_key_env: Option<&str>,
190 storage_mode: AuthCredentialsStoreMode,
191 ) -> Option<ResolvedModel> {
192 let model = model.trim();
193 if model.is_empty() {
194 return None;
195 }
196
197 if let Some(provider) = provider_override.and_then(parse_provider_override) {
198 return Some(Self::resolve_for_provider(
199 provider,
200 model,
201 dynamic_models,
202 dynamic_meta,
203 api_key_env,
204 storage_mode,
205 ));
206 }
207
208 if let Ok(model_id) = ModelId::from_str(model) {
209 return Some(Self::resolve_for_model_id(
210 model,
211 model_id,
212 dynamic_models,
213 dynamic_meta,
214 api_key_env,
215 storage_mode,
216 ));
217 }
218
219 if let Some((provider, entry)) = find_catalog_provider(model) {
220 return Some(ResolvedModel {
221 provider,
222 model_id: model.to_string(),
223 api_key_env: resolved_api_key_env(provider, api_key_env),
224 catalog: Some(entry),
225 dynamic: dynamic_meta,
226 availability: Self::availability_with_key(provider, model, api_key_env, storage_mode),
227 });
228 }
229
230 if let Some(provider) = find_dynamic_provider(model, dynamic_models) {
231 return Some(Self::resolve_for_provider(
232 provider,
233 model,
234 dynamic_models,
235 dynamic_meta,
236 api_key_env,
237 storage_mode,
238 ));
239 }
240
241 let provider = heuristic_provider_from_model(model)?;
242 Some(Self::resolve_for_provider(provider, model, dynamic_models, dynamic_meta, api_key_env, storage_mode))
243 }
244
245 pub fn resolve_provider(
246 provider_override: Option<&str>,
247 model: &str,
248 dynamic_models: &[DynamicModelRef<'_>],
249 ) -> Option<Provider> {
250 Self::resolve(provider_override, model, dynamic_models, None).map(|resolved| resolved.provider)
251 }
252
253 pub fn availability(provider: Provider, model: &str) -> ModelAvailability {
254 Self::availability_with_mode(provider, model, AuthCredentialsStoreMode::default())
255 }
256
257 pub fn availability_with_mode(
259 provider: Provider,
260 model: &str,
261 storage_mode: AuthCredentialsStoreMode,
262 ) -> ModelAvailability {
263 Self::availability_with_key(provider, model, None, storage_mode)
264 }
265
266 pub fn availability_with_key(
268 provider: Provider,
269 model: &str,
270 api_key_env: Option<&str>,
271 storage_mode: AuthCredentialsStoreMode,
272 ) -> ModelAvailability {
273 if provider.is_local() && !local_model_requires_remote_auth(provider, model) {
274 return ModelAvailability::LocalOnly;
275 }
276
277 if provider.uses_managed_auth() {
278 return ModelAvailability::ManagedAuthAvailable;
279 }
280
281 let env_key = resolved_api_key_env(provider, api_key_env);
282
283 if env_key.trim().is_empty() {
284 return ModelAvailability::ManagedAuthAvailable;
285 }
286
287 match vtcode_config::api_keys::resolve_credential_with_mode(provider.as_ref(), &env_key, None, storage_mode) {
288 Ok(Some(resolved)) => {
289 if matches!(resolved.source, vtcode_config::api_keys::CredentialSource::OAuth) {
290 return ModelAvailability::ManagedAuthAvailable;
291 }
292 if resolved.secret.is_some() {
293 return ModelAvailability::Available;
294 }
295 }
296 Ok(None) => {}
297 Err(_) => return ModelAvailability::Misconfigured,
298 }
299
300 if std::env::var(&env_key).is_ok() {
301 return ModelAvailability::Misconfigured;
302 }
303
304 ModelAvailability::MissingCredential
305 }
306
307 fn resolve_for_provider(
308 provider: Provider,
309 model: &str,
310 dynamic_models: &[DynamicModelRef<'_>],
311 dynamic_meta: Option<DynamicModelMeta>,
312 api_key_env: Option<&str>,
313 storage_mode: AuthCredentialsStoreMode,
314 ) -> ResolvedModel {
315 let catalog = model_catalog_entry(provider.as_ref(), model);
316 let dynamic = if catalog.is_some() {
317 None
318 } else if dynamic_meta.is_some() {
319 dynamic_meta
320 } else if has_dynamic_model(provider, model, dynamic_models) {
321 Some(DynamicModelMeta {
322 display_name: model.to_string(),
323 description: None,
324 context_window: None,
325 })
326 } else {
327 None
328 };
329
330 ResolvedModel {
331 provider,
332 model_id: model.to_string(),
333 api_key_env: resolved_api_key_env(provider, api_key_env),
334 catalog,
335 dynamic,
336 availability: Self::availability_with_key(provider, model, api_key_env, storage_mode),
337 }
338 }
339
340 fn resolve_for_model_id(
341 requested_model: &str,
342 model_id: ModelId,
343 dynamic_models: &[DynamicModelRef<'_>],
344 dynamic_meta: Option<DynamicModelMeta>,
345 api_key_env: Option<&str>,
346 storage_mode: AuthCredentialsStoreMode,
347 ) -> ResolvedModel {
348 let provider = model_id.provider();
349 let catalog = model_catalog_entry(provider.as_ref(), &model_id.as_str());
350 let dynamic = if catalog.is_some() {
351 None
352 } else if dynamic_meta.is_some() {
353 dynamic_meta
354 } else if has_dynamic_model(provider, requested_model, dynamic_models) {
355 Some(DynamicModelMeta {
356 display_name: requested_model.to_string(),
357 description: None,
358 context_window: None,
359 })
360 } else {
361 None
362 };
363
364 ResolvedModel {
365 provider,
366 model_id: requested_model.to_string(),
367 api_key_env: resolved_api_key_env(provider, api_key_env),
368 catalog,
369 dynamic,
370 availability: Self::availability_with_key(provider, requested_model, api_key_env, storage_mode),
371 }
372 }
373}
374
375fn parse_provider_override(value: &str) -> Option<Provider> {
376 let trimmed = value.trim();
377 if trimmed.is_empty() {
378 None
379 } else {
380 Provider::from_str(trimmed).ok()
381 }
382}
383
384fn find_catalog_provider(model: &str) -> Option<(Provider, ModelCatalogEntry)> {
385 let mut matches: Vec<(Provider, ModelCatalogEntry)> = catalog_provider_keys()
386 .iter()
387 .filter_map(|provider_key| {
388 let provider = Provider::from_str(provider_key).ok()?;
389 model_catalog_entry(provider_key, model).map(|entry| (provider, entry))
390 })
391 .collect();
392 matches.sort_by_key(|(provider, _)| provider_precedence(*provider));
393 matches.into_iter().next()
394}
395
396fn find_dynamic_provider(model: &str, dynamic_models: &[DynamicModelRef<'_>]) -> Option<Provider> {
397 let mut matches = dynamic_models
398 .iter()
399 .filter(|candidate| candidate.model_id.eq_ignore_ascii_case(model))
400 .map(|candidate| candidate.provider);
401 let first = matches.next()?;
402 if matches.all(|provider| provider == first) {
403 Some(first)
404 } else {
405 None
406 }
407}
408
409fn has_dynamic_model(provider: Provider, model: &str, dynamic_models: &[DynamicModelRef<'_>]) -> bool {
410 dynamic_models
411 .iter()
412 .any(|candidate| candidate.provider == provider && candidate.model_id.eq_ignore_ascii_case(model))
413}
414
415fn provider_precedence(provider: Provider) -> usize {
416 match provider {
417 Provider::OpenAI => 0,
418 Provider::Anthropic => 1,
419 Provider::Gemini => 2,
420 Provider::DeepSeek => 3,
421 Provider::ZAI => 4,
422 Provider::Minimax => 5,
423 Provider::Mistral => 6,
424 Provider::Moonshot => 7,
425 Provider::Meta => 8,
426 Provider::OpenRouter => 9,
427 Provider::HuggingFace => 10,
428 Provider::Copilot => 11,
429 Provider::Ollama => 12,
430 Provider::OllamaCloud => 13,
431 Provider::LmStudio => 14,
432 Provider::LlamaCpp => 15,
433 Provider::OpenCodeZen => 16,
434 Provider::OpenCodeGo => 17,
435 Provider::MiMo => 18,
436 Provider::Qwen => 19,
437 Provider::StepFun => 20,
438 Provider::Evolink => 21,
439 Provider::Poolside => 22,
440 Provider::XAI => 23,
441 Provider::NVIDIA => 24,
442 Provider::MergeGateway => 25,
443 Provider::Vercel => 26,
444 }
445}
446
447fn local_model_requires_remote_auth(provider: Provider, model: &str) -> bool {
448 provider == Provider::OllamaCloud
449 || (provider == Provider::Ollama && (model.contains(":cloud") || model.contains("-cloud")))
450}
451
452fn resolved_api_key_env(provider: Provider, api_key_env: Option<&str>) -> String {
453 api_key_env
454 .map(str::trim)
455 .filter(|env_key| !env_key.is_empty())
456 .map(ToOwned::to_owned)
457 .unwrap_or_else(|| api_key_env_var(provider.as_ref()))
458}
459
460pub fn heuristic_provider_from_model(model: &str) -> Option<Provider> {
461 let trimmed = model.trim();
462 if trimmed.is_empty() {
463 return None;
464 }
465
466 if trimmed.contains(':') && !trimmed.contains('/') && !trimmed.contains('@') {
467 return Some(Provider::Ollama);
468 }
469
470 let model = trimmed.to_ascii_lowercase();
471 if model.starts_with("muse-spark-") {
472 Some(Provider::Meta)
473 } else if model.starts_with("gpt-oss-")
474 || model.starts_with("gpt-")
475 || model.starts_with("o1")
476 || model.starts_with("o3")
477 || model.starts_with("o4")
478 || model.starts_with("codex")
479 {
480 Some(Provider::OpenAI)
481 } else if model == "copilot" || model.starts_with("copilot-") {
482 Some(Provider::Copilot)
483 } else if model.starts_with("claude-") {
484 Some(Provider::Anthropic)
485 } else if model.starts_with("deepseek-") {
486 Some(Provider::DeepSeek)
487 } else if model.starts_with("mistral-") || model.starts_with("ministral-") || model.starts_with("codestral-") {
488 Some(Provider::Mistral)
489 } else if model.contains("gemini") || model.starts_with("palm") {
490 Some(Provider::Gemini)
491 } else if model.starts_with("glm-") {
492 Some(Provider::ZAI)
493 } else if model.starts_with("lmstudio-community/") {
494 Some(Provider::LmStudio)
495 } else if model.starts_with("mimo-") {
496 Some(Provider::MiMo)
497 } else if model.starts_with("qwen3.") || model.starts_with("qwen-") {
498 Some(Provider::Qwen)
499 } else if model.starts_with("step-") {
500 Some(Provider::StepFun)
501 } else if model.starts_with("moonshot-") || model.starts_with("kimi-") {
502 Some(Provider::Moonshot)
503 } else if model.starts_with("opencode/") || model.starts_with("opencode-zen/") {
504 Some(Provider::OpenCodeZen)
505 } else if model.starts_with("opencode-go/") {
506 Some(Provider::OpenCodeGo)
507 } else if model.starts_with("poolside/") {
508 Some(Provider::Poolside)
509 } else if model.starts_with("nvidia/") {
510 Some(Provider::NVIDIA)
511 } else if model.starts_with("deepseek-ai/")
512 || model.starts_with("openai/gpt-oss-")
513 || model.starts_with("zai-org/")
514 || model.starts_with("moonshotai/")
515 || model.starts_with("minimaxai/")
516 {
517 Some(Provider::HuggingFace)
518 } else if model.starts_with("mixtral-")
519 || model.starts_with("qwen-")
520 || model.starts_with("meta-")
521 || model.starts_with("llama-")
522 || model.starts_with("command-")
523 || model.contains('/')
524 || model.contains('@')
525 {
526 Some(Provider::OpenRouter)
527 } else {
528 None
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
537 fn resolver_prefers_catalog_match_over_heuristic() {
538 let resolved = ModelResolver::resolve(None, "gpt-5.6-sol", &[], None).expect("model");
539
540 assert_eq!(resolved.provider, Provider::OpenAI);
541 assert!(resolved.known_model());
542 assert_eq!(resolved.display_name(), "GPT-5.6 Sol");
543 }
544
545 #[test]
546 fn resolver_uses_model_id_to_disambiguate_shared_opencode_slugs() {
547 let bare = ModelResolver::resolve(None, "glm-5.1", &[], None).expect("bare model");
548 assert_eq!(bare.provider, Provider::ZAI);
549
550 let zen = ModelResolver::resolve(None, "opencode/glm-5.1", &[], None).expect("opencode zen");
551 assert_eq!(zen.provider, Provider::OpenCodeZen);
552 assert!(!zen.known_model(), "OpenCode Zen models are not in the generated catalog");
553 assert_eq!(zen.display_name(), "opencode/glm-5.1");
554
555 let go = ModelResolver::resolve(None, "opencode-go/glm-5.1", &[], None).expect("opencode go");
556 assert_eq!(go.provider, Provider::OpenCodeGo);
557 assert!(!go.known_model(), "OpenCode Go models are not in the generated catalog");
558 assert_eq!(go.display_name(), "opencode-go/glm-5.1");
559 }
560
561 #[test]
562 fn resolver_routes_nvidia_namespace_to_nvidia_provider() {
563 let resolved = ModelResolver::resolve(None, "nvidia/nemotron-3-ultra-550b-a55b", &[], None).expect("model");
564
565 assert_eq!(resolved.provider, Provider::NVIDIA);
566 assert!(resolved.known_model());
567 }
568
569 #[test]
570 fn resolver_uses_catalog_metadata_for_namespaced_evolink_models() {
571 let resolved = ModelResolver::resolve(None, "evolink/MiniMax-M3", &[], None).expect("model");
572
573 assert_eq!(resolved.provider, Provider::Evolink);
574 assert!(resolved.known_model());
575 assert_eq!(resolved.context_window(), Some(1_000_000));
576 assert!(resolved.supported_reasoning_efforts().is_empty());
577 }
578
579 #[test]
580 fn resolver_uses_explicit_merge_gateway_provider_for_arbitrary_route() {
581 let resolved =
582 ModelResolver::resolve(Some("merge-gateway"), "deepseek/deepseek-v4-pro", &[], None).expect("route");
583
584 assert_eq!(resolved.provider, Provider::MergeGateway);
585 assert_eq!(resolved.model_id, "deepseek/deepseek-v4-pro");
586 assert!(!resolved.known_model());
587 }
588
589 #[test]
590 fn resolver_advertises_reasoning_only_for_known_merge_reasoning_routes() {
591 let reasoning =
592 ModelResolver::resolve(Some("merge-gateway"), "openai/gpt-5.6-sol", &[], None).expect("Merge route");
593 assert!(reasoning.known_model());
594 assert!(reasoning.reasoning_supported());
595
596 let routing = ModelResolver::resolve(Some("merge-gateway"), "default_routing", &[], None).expect("Merge route");
597 assert!(routing.known_model());
598 assert!(!routing.reasoning_supported());
599 }
600
601 #[test]
602 fn resolver_separates_structured_reasoning_from_effort_support() {
603 let resolved =
604 ModelResolver::resolve(Some("openrouter"), "meta/muse-spark-1.3", &[], None).expect("OpenRouter route");
605
606 assert!(resolved.reasoning_supported());
607 assert!(!resolved.reasoning_effort_supported());
608 assert!(resolved.supported_reasoning_efforts().is_empty());
609 }
610
611 #[test]
612 fn resolver_keeps_official_meta_and_openrouter_meta_models_distinct() {
613 let official = ModelResolver::resolve(None, "muse-spark-1.3", &[], None).expect("official Meta model");
614 assert_eq!(official.provider, Provider::Meta);
615 assert!(official.known_model());
616
617 let marketplace = ModelResolver::resolve(None, "meta/muse-spark-1.3", &[], None).expect("OpenRouter model");
618 assert_eq!(marketplace.provider, Provider::OpenRouter);
619 assert!(marketplace.known_model());
620 }
621
622 #[test]
623 fn resolver_uses_provider_override_for_dynamic_model() {
624 let dynamic_models = [DynamicModelRef {
625 provider: Provider::Ollama,
626 model_id: "custom-local-model",
627 }];
628 let resolved = ModelResolver::resolve(
629 Some("ollama"),
630 "custom-local-model",
631 &dynamic_models,
632 Some(DynamicModelMeta {
633 display_name: "Custom Local Model".to_string(),
634 description: Some("dynamic".to_string()),
635 context_window: Some(32_000),
636 }),
637 )
638 .expect("resolved model");
639
640 assert_eq!(resolved.provider, Provider::Ollama);
641 assert!(!resolved.known_model());
642 assert_eq!(resolved.context_window(), Some(32_000));
643 }
644
645 #[test]
646 fn resolver_carries_provider_api_key_override() {
647 let resolved = ModelResolver::resolve_with_mode_and_api_key_env(
648 Some("openai"),
649 "gpt-5.6-sol",
650 &[],
651 None,
652 Some("CORPORATE_OPENAI_KEY"),
653 AuthCredentialsStoreMode::File,
654 )
655 .expect("model");
656
657 assert_eq!(resolved.api_key_env, "CORPORATE_OPENAI_KEY");
658 assert_eq!(resolved.env_key(), "CORPORATE_OPENAI_KEY");
659 }
660}