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-") || model.starts_with("muse-wonder") {
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        || model.starts_with("meta-models/")
517    {
518        Some(Provider::HuggingFace)
519    } else if model.starts_with("mixtral-")
520        || model.starts_with("qwen-")
521        || model.starts_with("meta-")
522        || model.starts_with("llama-")
523        || model.starts_with("command-")
524        || model.contains('/')
525        || model.contains('@')
526    {
527        Some(Provider::OpenRouter)
528    } else {
529        None
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    #[test]
538    fn resolver_prefers_catalog_match_over_heuristic() {
539        let resolved = ModelResolver::resolve(None, "gpt-5.6-sol", &[], None).expect("model");
540
541        assert_eq!(resolved.provider, Provider::OpenAI);
542        assert!(resolved.known_model());
543        assert_eq!(resolved.display_name(), "GPT-5.6 Sol");
544    }
545
546    #[test]
547    fn resolver_uses_model_id_to_disambiguate_shared_opencode_slugs() {
548        let bare = ModelResolver::resolve(None, "glm-5.1", &[], None).expect("bare model");
549        assert_eq!(bare.provider, Provider::ZAI);
550
551        let zen = ModelResolver::resolve(None, "opencode/glm-5.1", &[], None).expect("opencode zen");
552        assert_eq!(zen.provider, Provider::OpenCodeZen);
553        assert!(!zen.known_model(), "OpenCode Zen models are not in the generated catalog");
554        assert_eq!(zen.display_name(), "opencode/glm-5.1");
555
556        let go = ModelResolver::resolve(None, "opencode-go/glm-5.1", &[], None).expect("opencode go");
557        assert_eq!(go.provider, Provider::OpenCodeGo);
558        assert!(!go.known_model(), "OpenCode Go models are not in the generated catalog");
559        assert_eq!(go.display_name(), "opencode-go/glm-5.1");
560    }
561
562    #[test]
563    fn resolver_routes_nvidia_namespace_to_nvidia_provider() {
564        let resolved = ModelResolver::resolve(None, "nvidia/nemotron-3-ultra-550b-a55b", &[], None).expect("model");
565
566        assert_eq!(resolved.provider, Provider::NVIDIA);
567        assert!(resolved.known_model());
568    }
569
570    #[test]
571    fn resolver_uses_catalog_metadata_for_namespaced_evolink_models() {
572        let resolved = ModelResolver::resolve(None, "evolink/MiniMax-M3", &[], None).expect("model");
573
574        assert_eq!(resolved.provider, Provider::Evolink);
575        assert!(resolved.known_model());
576        assert_eq!(resolved.context_window(), Some(1_000_000));
577        assert!(resolved.supported_reasoning_efforts().is_empty());
578    }
579
580    #[test]
581    fn resolver_uses_explicit_merge_gateway_provider_for_arbitrary_route() {
582        let resolved =
583            ModelResolver::resolve(Some("merge-gateway"), "deepseek/deepseek-v4-pro", &[], None).expect("route");
584
585        assert_eq!(resolved.provider, Provider::MergeGateway);
586        assert_eq!(resolved.model_id, "deepseek/deepseek-v4-pro");
587        assert!(!resolved.known_model());
588    }
589
590    #[test]
591    fn resolver_advertises_reasoning_only_for_known_merge_reasoning_routes() {
592        let reasoning =
593            ModelResolver::resolve(Some("merge-gateway"), "openai/gpt-5.6-sol", &[], None).expect("Merge route");
594        assert!(reasoning.known_model());
595        assert!(reasoning.reasoning_supported());
596
597        let routing = ModelResolver::resolve(Some("merge-gateway"), "default_routing", &[], None).expect("Merge route");
598        assert!(routing.known_model());
599        assert!(!routing.reasoning_supported());
600    }
601
602    #[test]
603    fn resolver_separates_structured_reasoning_from_effort_support() {
604        let resolved =
605            ModelResolver::resolve(Some("openrouter"), "meta/muse-spark-1.3", &[], None).expect("OpenRouter route");
606
607        assert!(resolved.reasoning_supported());
608        assert!(!resolved.reasoning_effort_supported());
609        assert!(resolved.supported_reasoning_efforts().is_empty());
610    }
611
612    #[test]
613    fn resolver_keeps_official_meta_and_openrouter_meta_models_distinct() {
614        let official = ModelResolver::resolve(None, "muse-spark-1.3", &[], None).expect("official Meta model");
615        assert_eq!(official.provider, Provider::Meta);
616        assert!(official.known_model());
617
618        let marketplace = ModelResolver::resolve(None, "meta/muse-spark-1.3", &[], None).expect("OpenRouter model");
619        assert_eq!(marketplace.provider, Provider::OpenRouter);
620        assert!(marketplace.known_model());
621    }
622
623    #[test]
624    fn resolver_uses_provider_override_for_dynamic_model() {
625        let dynamic_models = [DynamicModelRef {
626            provider: Provider::Ollama,
627            model_id: "custom-local-model",
628        }];
629        let resolved = ModelResolver::resolve(
630            Some("ollama"),
631            "custom-local-model",
632            &dynamic_models,
633            Some(DynamicModelMeta {
634                display_name: "Custom Local Model".to_string(),
635                description: Some("dynamic".to_string()),
636                context_window: Some(32_000),
637            }),
638        )
639        .expect("resolved model");
640
641        assert_eq!(resolved.provider, Provider::Ollama);
642        assert!(!resolved.known_model());
643        assert_eq!(resolved.context_window(), Some(32_000));
644    }
645
646    #[test]
647    fn resolver_carries_provider_api_key_override() {
648        let resolved = ModelResolver::resolve_with_mode_and_api_key_env(
649            Some("openai"),
650            "gpt-5.6-sol",
651            &[],
652            None,
653            Some("CORPORATE_OPENAI_KEY"),
654            AuthCredentialsStoreMode::File,
655        )
656        .expect("model");
657
658        assert_eq!(resolved.api_key_env, "CORPORATE_OPENAI_KEY");
659        assert_eq!(resolved.env_key(), "CORPORATE_OPENAI_KEY");
660    }
661}