Skip to main content

vtcode_core/llm/
lightweight_routing.rs

1use anyhow::{Context, Result};
2use std::str::FromStr;
3
4use crate::config::api_keys::{ApiKeySources, get_api_key_with_mode};
5use crate::config::constants::model_helpers;
6use crate::config::loader::VTCodeConfig;
7use crate::config::models::{ModelId, Provider, model_catalog_entry};
8use crate::config::types::AgentConfig as RuntimeAgentConfig;
9use crate::llm::factory::{ProviderConfig, create_provider_with_config, infer_provider_from_model};
10use crate::llm::provider::LLMProvider;
11use vtcode_config::auth::AuthCredentialsStoreMode;
12
13/// Features that may use a lightweight (cheaper, faster) model instead of the
14/// primary model to reduce cost and latency.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum LightweightFeature {
17    /// Conversation memory summarization.
18    Memory,
19    /// Generating prompt suggestions for the user.
20    PromptSuggestions,
21    /// Refining user-provided prompts before execution.
22    PromptRefinement,
23    /// Reviewing auto-permission decisions.
24    AutoPermissionReview,
25    /// Probing auto-permission suitability.
26    AutoPermissionProbe,
27    /// Summarizing large file reads.
28    LargeReadSummary,
29    /// Summarizing web-fetched content.
30    WebSummary,
31    /// Summarizing git history.
32    GitHistorySummary,
33    /// Running as a subagent delegate.
34    Subagent,
35    /// Diagnosing a failed or non-zero tool execution from bounded evidence.
36    ToolFailureDiagnosis,
37}
38
39/// A resolved provider-and-model pair that identifies a specific LLM endpoint.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ModelRoute {
42    /// Lowercase provider registry key (e.g. `"openai"`, `"anthropic"`).
43    pub provider_name: String,
44    /// Model identifier string (e.g. `"gpt-5"`, `"claude-4-sonnet"`).
45    pub model: String,
46}
47
48/// Indicates how a lightweight route was resolved.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum LightweightRouteSource {
51    /// An explicit per-feature model override was provided.
52    FeatureOverride,
53    /// The shared `[agent.small_model]` config provided a model name.
54    SharedConfigured,
55    /// The shared small-model config is enabled but no model was named, so one
56    /// was selected automatically.
57    SharedAutomatic,
58    /// No lightweight model applies; the main model is used directly.
59    MainModel,
60}
61
62/// Result of resolving which model a lightweight feature should use.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct LightweightRouteResolution {
65    /// The model route that should be used for the feature.
66    pub primary: ModelRoute,
67    /// The original main-model route, if the primary differs from it.
68    pub fallback: Option<ModelRoute>,
69    /// How this resolution was determined.
70    pub source: LightweightRouteSource,
71    /// Non-fatal warning generated during resolution (e.g. a cross-provider override).
72    pub warning: Option<String>,
73}
74
75impl LightweightRouteResolution {
76    /// Return `true` when a model other than the main model was selected.
77    pub fn uses_lightweight_model(&self) -> bool {
78        !matches!(self.source, LightweightRouteSource::MainModel)
79    }
80
81    /// Return a reference to the main-model fallback route, if one exists.
82    pub fn fallback_to_main_model(&self) -> Option<&ModelRoute> {
83        self.fallback.as_ref()
84    }
85}
86
87/// Resolve the best model route for a given lightweight feature.
88///
89/// Checks, in priority order: explicit per-feature override, shared small-model
90/// config, and automatic model selection for the active provider.
91pub fn resolve_lightweight_route(
92    runtime_config: &RuntimeAgentConfig,
93    vt_cfg: Option<&VTCodeConfig>,
94    feature: LightweightFeature,
95    explicit_override_model: Option<&str>,
96) -> LightweightRouteResolution {
97    let main_route = main_model_route(runtime_config);
98    let main_provider = main_route.provider_name.as_str();
99
100    let mut warning = None;
101    if let Some(configured_model) = explicit_override_model.map(str::trim).filter(|value| !value.is_empty()) {
102        if let Some(route) = route_for_candidate(main_provider, configured_model, vt_cfg) {
103            return LightweightRouteResolution {
104                fallback: (route != main_route).then_some(main_route),
105                primary: route,
106                source: LightweightRouteSource::FeatureOverride,
107                warning: None,
108            };
109        }
110
111        warning = Some(format!(
112            "ignored lightweight override model '{configured_model}' because it does not match the active provider '{main_provider}'"
113        ));
114    }
115
116    let Some(vt_cfg) = vt_cfg else {
117        return LightweightRouteResolution {
118            primary: main_route,
119            fallback: None,
120            source: LightweightRouteSource::MainModel,
121            warning,
122        };
123    };
124
125    let shared_cfg = &vt_cfg.agent.small_model;
126    if !shared_cfg.enabled || !feature_uses_shared_model(shared_cfg, feature) {
127        return LightweightRouteResolution {
128            primary: main_route,
129            fallback: None,
130            source: LightweightRouteSource::MainModel,
131            warning,
132        };
133    }
134
135    let configured_model = shared_cfg.model.trim();
136    if !configured_model.is_empty() {
137        if let Some(route) = route_for_candidate(main_provider, configured_model, Some(vt_cfg)) {
138            return LightweightRouteResolution {
139                fallback: (route != main_route).then_some(main_route),
140                primary: route,
141                source: LightweightRouteSource::SharedConfigured,
142                warning,
143            };
144        }
145
146        warning = Some(format!(
147            "ignored lightweight model '{configured_model}' because it does not match the active provider '{main_provider}'"
148        ));
149    }
150
151    let primary = ModelRoute {
152        provider_name: main_route.provider_name.clone(),
153        model: auto_lightweight_model(main_provider, &main_route.model),
154    };
155    LightweightRouteResolution {
156        fallback: (primary != main_route).then_some(main_route),
157        primary,
158        source: LightweightRouteSource::SharedAutomatic,
159        warning,
160    }
161}
162
163/// Build a [`ModelRoute`] for the primary model from runtime configuration.
164pub fn main_model_route(runtime_config: &RuntimeAgentConfig) -> ModelRoute {
165    let provider_name = if runtime_config.provider.trim().is_empty() {
166        infer_provider_from_model(&runtime_config.model)
167            .map(|provider| provider.to_string().to_lowercase())
168            .unwrap_or_else(|| "gemini".to_string())
169    } else {
170        runtime_config.provider.to_lowercase()
171    };
172
173    ModelRoute { provider_name, model: runtime_config.model.clone() }
174}
175
176/// Automatically select a lightweight model for the given provider and active model.
177///
178/// Prefers a same-generation efficient variant when available, then falls back
179/// to provider defaults.
180pub fn auto_lightweight_model(provider_name: &str, active_model: &str) -> String {
181    let trimmed_model = active_model.trim();
182    let Some(provider) = known_provider_from_name(provider_name).or_else(|| infer_provider_from_model(trimmed_model))
183    else {
184        // A custom endpoint without catalog metadata cannot safely receive a
185        // built-in provider's fallback model. Keep its active model on the
186        // route until the endpoint declares lightweight metadata.
187        return trimmed_model.to_string();
188    };
189
190    if let Some(lightweight_model) = catalog_lightweight_model(provider, trimmed_model) {
191        return lightweight_model;
192    }
193
194    if trimmed_model.is_empty() {
195        return provider_default_lightweight_model(provider)
196            .map(str::to_string)
197            .unwrap_or_default();
198    }
199
200    // An unmapped model may be a user-managed endpoint. Never replace it with
201    // an unrelated provider default just because the provider is known.
202    trimmed_model.to_string()
203}
204
205/// Return the list of available lightweight model choices for the given provider.
206///
207/// The automatically selected model is always first in the list.
208pub fn lightweight_model_choices(provider_name: &str, active_model: &str) -> Vec<String> {
209    let provider = resolve_provider_for_model(provider_name, active_model);
210    let auto_model = auto_lightweight_model(provider_name, active_model);
211    let mut choices = Vec::new();
212
213    if !auto_model.trim().is_empty() {
214        choices.push(auto_model.clone());
215    }
216    if !active_model.trim().is_empty() {
217        choices.push(active_model.trim().to_string());
218    }
219
220    if let Some(provider) = provider
221        && let Some(models) = model_helpers::supported_for(provider.as_ref())
222    {
223        for model in models {
224            let include = model
225                .parse::<ModelId>()
226                .map(|model_id| model_id.is_efficient_variant())
227                .unwrap_or(false)
228                || model.eq_ignore_ascii_case(active_model.trim());
229            if include {
230                choices.push((*model).to_string());
231            }
232        }
233    }
234
235    choices.sort();
236    choices.dedup();
237    if let Some(auto_index) = choices
238        .iter()
239        .position(|candidate| candidate.eq_ignore_ascii_case(auto_model.as_str()))
240    {
241        let auto = choices.remove(auto_index);
242        choices.insert(0, auto);
243    }
244    choices
245}
246
247/// Instantiate an [`LLMProvider`] for the given model route.
248///
249/// Resolves the API key from the runtime config or environment and delegates
250/// to the global factory.
251pub fn create_provider_for_model_route(
252    route: &ModelRoute,
253    runtime_config: &RuntimeAgentConfig,
254    vt_cfg: Option<&VTCodeConfig>,
255) -> Result<Box<dyn LLMProvider>> {
256    let storage_mode = vt_cfg.map(|cfg| cfg.agent.credential_storage_mode).unwrap_or_default();
257    let api_key = resolve_api_key_for_model_route_with_mode(route, runtime_config, storage_mode);
258    create_provider_with_config(
259        &route.provider_name,
260        ProviderConfig {
261            api_key,
262            openai_chatgpt_auth: runtime_config.openai_chatgpt_auth.clone(),
263            copilot_auth: vt_cfg.map(|cfg| cfg.auth.copilot.clone()),
264            base_url: None,
265            model: Some(route.model.clone()),
266            prompt_cache: Some(runtime_config.prompt_cache.clone()),
267            timeouts: None,
268            openai: vt_cfg.map(|cfg| cfg.provider.openai.clone()),
269            anthropic: vt_cfg.map(|cfg| cfg.provider.anthropic.clone()),
270            model_behavior: runtime_config.model_behavior.clone(),
271            workspace_root: Some(runtime_config.workspace.clone()),
272        },
273    )
274    .with_context(|| {
275        format!("Failed to initialize lightweight provider '{}' for model '{}'", route.provider_name, route.model)
276    })
277}
278
279/// Resolve the API key for a model route, using the runtime key when the route
280/// targets the same provider as the main model, or falling back to environment
281/// variables otherwise.
282pub fn resolve_api_key_for_model_route(route: &ModelRoute, runtime_config: &RuntimeAgentConfig) -> Option<String> {
283    resolve_api_key_for_model_route_with_mode(route, runtime_config, AuthCredentialsStoreMode::default())
284}
285
286/// Resolve a lightweight route key using the configured secure-storage mode.
287pub fn resolve_api_key_for_model_route_with_mode(
288    route: &ModelRoute,
289    runtime_config: &RuntimeAgentConfig,
290    storage_mode: AuthCredentialsStoreMode,
291) -> Option<String> {
292    if route
293        .provider_name
294        .eq_ignore_ascii_case(main_model_route(runtime_config).provider_name.as_str())
295        && !runtime_config.api_key.trim().is_empty()
296    {
297        return Some(runtime_config.api_key.clone());
298    }
299
300    get_api_key_with_mode(&route.provider_name, &ApiKeySources::default(), storage_mode).ok()
301}
302
303fn feature_uses_shared_model(
304    shared_cfg: &vtcode_config::core::agent::AgentSmallModelConfig,
305    feature: LightweightFeature,
306) -> bool {
307    match feature {
308        LightweightFeature::Memory => shared_cfg.use_for_memory,
309        LightweightFeature::LargeReadSummary => shared_cfg.use_for_large_reads,
310        LightweightFeature::WebSummary => shared_cfg.use_for_web_summary,
311        LightweightFeature::GitHistorySummary => shared_cfg.use_for_git_history,
312        LightweightFeature::PromptSuggestions
313        | LightweightFeature::PromptRefinement
314        | LightweightFeature::AutoPermissionReview
315        | LightweightFeature::AutoPermissionProbe
316        | LightweightFeature::Subagent
317        | LightweightFeature::ToolFailureDiagnosis => true,
318    }
319}
320
321fn route_for_candidate(
322    main_provider: &str,
323    candidate_model: &str,
324    vt_cfg: Option<&VTCodeConfig>,
325) -> Option<ModelRoute> {
326    let is_declared_custom_model = vt_cfg
327        .and_then(|cfg| cfg.custom_provider(main_provider))
328        .is_some_and(|provider| provider.effective_models().iter().any(|model| model == candidate_model));
329
330    if infer_provider_from_model(candidate_model)
331        .map(|provider| !provider.as_ref().eq_ignore_ascii_case(main_provider))
332        .unwrap_or(false)
333        && !is_declared_custom_model
334    {
335        return None;
336    }
337
338    Some(ModelRoute {
339        provider_name: main_provider.to_string(),
340        model: candidate_model.to_string(),
341    })
342}
343
344fn resolve_provider_for_model(provider_name: &str, active_model: &str) -> Option<Provider> {
345    known_provider_from_name(provider_name).or_else(|| infer_provider_from_model(active_model))
346}
347
348fn known_provider_from_name(provider_name: &str) -> Option<Provider> {
349    Provider::from_str(provider_name.trim()).ok()
350}
351
352fn provider_default_lightweight_model(provider: Provider) -> Option<&'static str> {
353    let provider_name = provider.as_ref();
354    let default_model = model_helpers::default_for(provider_name)?;
355    let catalog_lightweight = model_catalog_entry(provider_name, default_model)
356        .and_then(|entry| entry.lightweight_model)
357        .filter(|model| !model.trim().is_empty());
358    Some(catalog_lightweight.unwrap_or(default_model))
359}
360
361/// Resolve the catalog-backed lightweight sibling for a provider/model pair.
362///
363/// `ModelId` and generated catalog entries are two views of the same catalog;
364/// keeping their precedence in one helper prevents the automatic route and
365/// provider-default route from drifting apart.
366fn catalog_lightweight_model(provider: Provider, active_model: &str) -> Option<String> {
367    if let Some(entry) = model_catalog_entry(provider.as_ref(), active_model)
368        && let Some(target) = entry.lightweight_model
369        && !target.trim().is_empty()
370    {
371        return Some(normalize_catalog_target(provider, target));
372    }
373
374    if let Ok(model_id) = active_model.parse::<ModelId>() {
375        if model_id.provider() != provider {
376            return None;
377        }
378        if model_id.is_efficient_variant() {
379            return Some(model_id.as_str().to_string());
380        }
381        if let Some(lightweight_model) = model_id.preferred_lightweight_variant() {
382            return Some(lightweight_model.as_str().to_string());
383        }
384    }
385
386    None
387}
388
389fn normalize_catalog_target(provider: Provider, target: &str) -> String {
390    let target = target.trim();
391    if provider == Provider::Evolink && !target.contains('/') {
392        return format!("evolink/{target}");
393    }
394    target.to_string()
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    fn runtime_config() -> RuntimeAgentConfig {
402        RuntimeAgentConfig {
403            model: ModelId::GPT56Sol.as_str().to_string(),
404            api_key: "test-key".to_string(),
405            provider: "openai".to_string(),
406            openai_chatgpt_auth: None,
407            api_key_env: "OPENAI_API_KEY".to_string(),
408            workspace: std::env::temp_dir().join("vtcode-lightweight-routing-tests"),
409            verbose: false,
410            quiet: false,
411            theme: "default".to_string(),
412            reasoning_effort: Default::default(),
413            ui_surface: Default::default(),
414            prompt_cache: Default::default(),
415            model_source: Default::default(),
416            custom_api_keys: Default::default(),
417            checkpointing_enabled: false,
418            checkpointing_storage_dir: None,
419            checkpointing_max_snapshots: 0,
420            checkpointing_max_age_days: None,
421            max_conversation_turns: 0,
422            model_behavior: None,
423        }
424    }
425
426    #[test]
427    fn explicit_override_uses_active_provider() {
428        let runtime = runtime_config();
429        let route = resolve_lightweight_route(
430            &runtime,
431            Some(&VTCodeConfig::default()),
432            LightweightFeature::Memory,
433            Some("gpt-5-mini"),
434        );
435
436        assert_eq!(route.primary.provider_name, "openai");
437        assert_eq!(route.primary.model, "gpt-5-mini");
438        assert_eq!(route.source, LightweightRouteSource::FeatureOverride);
439    }
440
441    #[test]
442    fn cross_provider_shared_model_falls_back_to_auto_same_provider() {
443        let runtime = runtime_config();
444        let mut vt_cfg = VTCodeConfig::default();
445        vt_cfg.agent.small_model.model = "claude-4-5-haiku".to_string();
446
447        let route = resolve_lightweight_route(&runtime, Some(&vt_cfg), LightweightFeature::PromptSuggestions, None);
448
449        assert_eq!(route.primary.provider_name, "openai");
450        assert_eq!(route.primary.model, ModelId::GPT56Terra.as_str());
451        assert_eq!(route.source, LightweightRouteSource::SharedAutomatic);
452        assert!(route.warning.is_some());
453    }
454
455    #[test]
456    fn explicit_override_accepts_declared_custom_provider_model_that_infers_builtin_provider() {
457        // Arrange: the custom provider explicitly declares a GPT-slugged model.
458        let mut runtime = runtime_config();
459        runtime.provider = "my-gateway".to_string();
460        let mut vt_cfg = VTCodeConfig::default();
461        vt_cfg.custom_providers.push(vtcode_config::core::CustomProviderConfig {
462            name: "my-gateway".to_string(),
463            models: vec![" gpt-5.6-luna ".to_string()],
464            ..Default::default()
465        });
466
467        // Act: resolve an explicitly overridden model with surrounding whitespace.
468        let route = resolve_lightweight_route(
469            &runtime,
470            Some(&vt_cfg),
471            LightweightFeature::PromptSuggestions,
472            Some("  gpt-5.6-luna  "),
473        );
474
475        // Assert: declaration wins over built-in provider inference and is normalized.
476        assert_eq!(
477            route.primary,
478            ModelRoute {
479                provider_name: "my-gateway".to_string(),
480                model: "gpt-5.6-luna".to_string(),
481            }
482        );
483        assert_eq!(route.source, LightweightRouteSource::FeatureOverride);
484        assert!(route.warning.is_none());
485    }
486
487    #[test]
488    fn shared_small_model_accepts_declared_custom_provider_model_that_infers_builtin_provider() {
489        // Arrange: shared small-model config selects a model declared by the active custom provider.
490        let mut runtime = runtime_config();
491        runtime.provider = "my-gateway".to_string();
492        let mut vt_cfg = VTCodeConfig::default();
493        vt_cfg.agent.small_model.model = "  gpt-5.6-luna  ".to_string();
494        vt_cfg.custom_providers.push(vtcode_config::core::CustomProviderConfig {
495            name: "my-gateway".to_string(),
496            models: vec!["gpt-5.6-luna".to_string()],
497            ..Default::default()
498        });
499
500        // Act: resolve the shared configured lightweight model.
501        let route = resolve_lightweight_route(&runtime, Some(&vt_cfg), LightweightFeature::PromptSuggestions, None);
502
503        // Assert: the declared model is selected through the active custom provider.
504        assert_eq!(
505            route.primary,
506            ModelRoute {
507                provider_name: "my-gateway".to_string(),
508                model: "gpt-5.6-luna".to_string(),
509            }
510        );
511        assert_eq!(route.source, LightweightRouteSource::SharedConfigured);
512        assert!(route.warning.is_none());
513    }
514
515    #[test]
516    fn auto_lightweight_model_prefers_same_generation_openai_sibling() {
517        assert_eq!(auto_lightweight_model("openai", &ModelId::GPT56Sol.as_str()), ModelId::GPT56Terra.as_str());
518    }
519
520    #[test]
521    fn auto_lightweight_model_uses_catalog_anthropic_pair() {
522        assert_eq!(
523            auto_lightweight_model("anthropic", &ModelId::ClaudeSonnet5.as_str()),
524            ModelId::ClaudeSonnet5.as_str()
525        );
526        assert_eq!(auto_lightweight_model("anthropic", "claude-sonnet-4.5"), "claude-sonnet-4.5");
527    }
528
529    #[test]
530    fn auto_lightweight_model_uses_lower_generation_glm_pair() {
531        assert_eq!(auto_lightweight_model("zai", &ModelId::ZaiGlm53.as_str()), ModelId::ZaiGlm53.as_str());
532    }
533
534    #[test]
535    fn auto_lightweight_model_prefers_same_generation_gemini_flash_lite() {
536        assert_eq!(auto_lightweight_model("gemini", &ModelId::Gemini38Flash.as_str()), ModelId::Gemini38Flash.as_str());
537    }
538
539    #[test]
540    fn auto_lightweight_model_infers_family_for_custom_provider() {
541        assert_eq!(auto_lightweight_model("mycorp", &ModelId::GPT56Sol.as_str()), ModelId::GPT56Terra.as_str());
542    }
543
544    #[test]
545    fn auto_lightweight_model_keeps_unmapped_custom_route() {
546        assert_eq!(auto_lightweight_model("mycorp", "custom-model-v1"), "custom-model-v1");
547    }
548
549    #[test]
550    fn auto_lightweight_model_keeps_unmapped_model_for_known_provider() {
551        assert_eq!(auto_lightweight_model("openai", "custom-model-v1"), "custom-model-v1");
552    }
553
554    #[test]
555    fn auto_lightweight_model_keeps_unmapped_evolink_route() {
556        assert_eq!(auto_lightweight_model("evolink", "evolink/deepseek-v4-pro"), "evolink/deepseek-v4-pro");
557    }
558
559    #[test]
560    fn lightweight_model_choices_do_not_invent_provider_for_unknown_route() {
561        assert_eq!(lightweight_model_choices("mycorp", "custom-model-v1"), vec!["custom-model-v1"]);
562    }
563
564    #[test]
565    fn disabled_feature_uses_main_model() {
566        let runtime = runtime_config();
567        let mut vt_cfg = VTCodeConfig::default();
568        vt_cfg.agent.small_model.use_for_memory = false;
569
570        let route = resolve_lightweight_route(&runtime, Some(&vt_cfg), LightweightFeature::Memory, None);
571
572        assert_eq!(route.primary.model, ModelId::GPT56Sol.as_str());
573        assert_eq!(route.source, LightweightRouteSource::MainModel);
574        assert!(route.fallback.is_none());
575    }
576}