Skip to main content

vtcode_llm/
model_resolver.rs

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    /// Whether this route accepts a configurable reasoning effort.
65    ///
66    /// Curated catalog entries are authoritative: a model can expose
67    /// structured reasoning while intentionally leaving the effort list empty.
68    /// Provider trait metadata remains the fallback for explicitly configured
69    /// custom routes.
70    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    /// Exact effort levels accepted by this resolved route.
80    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    /// Resolve a model using the caller's configured credential backend.
161    ///
162    /// Keeping the storage mode explicit at this boundary prevents model
163    /// availability from disagreeing with runtime authentication when a
164    /// workspace overrides the platform default.
165    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    /// Resolve a model while carrying an optional provider-specific API-key
183    /// environment override through availability and picker metadata.
184    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    /// Determine model availability using an explicit credential backend.
258    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    /// Determine availability using a provider-specific credential key name.
267    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}