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            openrouter: vt_cfg.map(|cfg| cfg.provider.openrouter.clone()),
271            model_behavior: runtime_config.model_behavior.clone(),
272            workspace_root: Some(runtime_config.workspace.clone()),
273        },
274    )
275    .with_context(|| {
276        format!("Failed to initialize lightweight provider '{}' for model '{}'", route.provider_name, route.model)
277    })
278}
279
280/// Resolve the API key for a model route, using the runtime key when the route
281/// targets the same provider as the main model, or falling back to environment
282/// variables otherwise.
283pub fn resolve_api_key_for_model_route(route: &ModelRoute, runtime_config: &RuntimeAgentConfig) -> Option<String> {
284    resolve_api_key_for_model_route_with_mode(route, runtime_config, AuthCredentialsStoreMode::default())
285}
286
287/// Resolve a lightweight route key using the configured secure-storage mode.
288pub fn resolve_api_key_for_model_route_with_mode(
289    route: &ModelRoute,
290    runtime_config: &RuntimeAgentConfig,
291    storage_mode: AuthCredentialsStoreMode,
292) -> Option<String> {
293    if route
294        .provider_name
295        .eq_ignore_ascii_case(main_model_route(runtime_config).provider_name.as_str())
296        && !runtime_config.api_key.trim().is_empty()
297    {
298        return Some(runtime_config.api_key.clone());
299    }
300
301    get_api_key_with_mode(&route.provider_name, &ApiKeySources::default(), storage_mode).ok()
302}
303
304fn feature_uses_shared_model(
305    shared_cfg: &vtcode_config::core::agent::AgentSmallModelConfig,
306    feature: LightweightFeature,
307) -> bool {
308    match feature {
309        LightweightFeature::Memory => shared_cfg.use_for_memory,
310        LightweightFeature::LargeReadSummary => shared_cfg.use_for_large_reads,
311        LightweightFeature::WebSummary => shared_cfg.use_for_web_summary,
312        LightweightFeature::GitHistorySummary => shared_cfg.use_for_git_history,
313        LightweightFeature::PromptSuggestions
314        | LightweightFeature::PromptRefinement
315        | LightweightFeature::AutoPermissionReview
316        | LightweightFeature::AutoPermissionProbe
317        | LightweightFeature::Subagent
318        | LightweightFeature::ToolFailureDiagnosis => true,
319    }
320}
321
322fn route_for_candidate(
323    main_provider: &str,
324    candidate_model: &str,
325    vt_cfg: Option<&VTCodeConfig>,
326) -> Option<ModelRoute> {
327    let is_declared_custom_model = vt_cfg
328        .and_then(|cfg| cfg.custom_provider(main_provider))
329        .is_some_and(|provider| provider.effective_models().iter().any(|model| model == candidate_model));
330
331    if infer_provider_from_model(candidate_model)
332        .map(|provider| !provider.as_ref().eq_ignore_ascii_case(main_provider))
333        .unwrap_or(false)
334        && !is_declared_custom_model
335    {
336        return None;
337    }
338
339    Some(ModelRoute {
340        provider_name: main_provider.to_string(),
341        model: candidate_model.to_string(),
342    })
343}
344
345fn resolve_provider_for_model(provider_name: &str, active_model: &str) -> Option<Provider> {
346    known_provider_from_name(provider_name).or_else(|| infer_provider_from_model(active_model))
347}
348
349fn known_provider_from_name(provider_name: &str) -> Option<Provider> {
350    Provider::from_str(provider_name.trim()).ok()
351}
352
353fn provider_default_lightweight_model(provider: Provider) -> Option<&'static str> {
354    let provider_name = provider.as_ref();
355    let default_model = model_helpers::default_for(provider_name)?;
356    let catalog_lightweight = model_catalog_entry(provider_name, default_model)
357        .and_then(|entry| entry.lightweight_model)
358        .filter(|model| !model.trim().is_empty());
359    Some(catalog_lightweight.unwrap_or(default_model))
360}
361
362/// Resolve the catalog-backed lightweight sibling for a provider/model pair.
363///
364/// `ModelId` and generated catalog entries are two views of the same catalog;
365/// keeping their precedence in one helper prevents the automatic route and
366/// provider-default route from drifting apart.
367fn catalog_lightweight_model(provider: Provider, active_model: &str) -> Option<String> {
368    if let Some(entry) = model_catalog_entry(provider.as_ref(), active_model)
369        && let Some(target) = entry.lightweight_model
370        && !target.trim().is_empty()
371    {
372        return Some(normalize_catalog_target(provider, target));
373    }
374
375    if let Ok(model_id) = active_model.parse::<ModelId>() {
376        if model_id.provider() != provider {
377            return None;
378        }
379        if model_id.is_efficient_variant() {
380            return Some(model_id.as_str().to_string());
381        }
382        if let Some(lightweight_model) = model_id.preferred_lightweight_variant() {
383            return Some(lightweight_model.as_str().to_string());
384        }
385    }
386
387    None
388}
389
390fn normalize_catalog_target(provider: Provider, target: &str) -> String {
391    let target = target.trim();
392    if provider == Provider::Evolink && !target.contains('/') {
393        return format!("evolink/{target}");
394    }
395    target.to_string()
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    fn runtime_config() -> RuntimeAgentConfig {
403        RuntimeAgentConfig {
404            model: ModelId::GPT56Sol.as_str().to_string(),
405            api_key: "test-key".to_string(),
406            provider: "openai".to_string(),
407            openai_chatgpt_auth: None,
408            api_key_env: "OPENAI_API_KEY".to_string(),
409            workspace: std::env::temp_dir().join("vtcode-lightweight-routing-tests"),
410            verbose: false,
411            quiet: false,
412            theme: "default".to_string(),
413            reasoning_effort: Default::default(),
414            ui_surface: Default::default(),
415            prompt_cache: Default::default(),
416            model_source: Default::default(),
417            custom_api_keys: Default::default(),
418            checkpointing_enabled: false,
419            checkpointing_storage_dir: None,
420            checkpointing_max_snapshots: 0,
421            checkpointing_max_age_days: None,
422            max_conversation_turns: 0,
423            model_behavior: None,
424        }
425    }
426
427    #[test]
428    fn explicit_override_uses_active_provider() {
429        let runtime = runtime_config();
430        let route = resolve_lightweight_route(
431            &runtime,
432            Some(&VTCodeConfig::default()),
433            LightweightFeature::Memory,
434            Some("gpt-5-mini"),
435        );
436
437        assert_eq!(route.primary.provider_name, "openai");
438        assert_eq!(route.primary.model, "gpt-5-mini");
439        assert_eq!(route.source, LightweightRouteSource::FeatureOverride);
440    }
441
442    #[test]
443    fn cross_provider_shared_model_falls_back_to_auto_same_provider() {
444        let runtime = runtime_config();
445        let mut vt_cfg = VTCodeConfig::default();
446        vt_cfg.agent.small_model.model = "claude-4-5-haiku".to_string();
447
448        let route = resolve_lightweight_route(&runtime, Some(&vt_cfg), LightweightFeature::PromptSuggestions, None);
449
450        assert_eq!(route.primary.provider_name, "openai");
451        assert_eq!(route.primary.model, ModelId::GPT56Terra.as_str());
452        assert_eq!(route.source, LightweightRouteSource::SharedAutomatic);
453        assert!(route.warning.is_some());
454    }
455
456    #[test]
457    fn explicit_override_accepts_declared_custom_provider_model_that_infers_builtin_provider() {
458        // Arrange: the custom provider explicitly declares a GPT-slugged model.
459        let mut runtime = runtime_config();
460        runtime.provider = "my-gateway".to_string();
461        let mut vt_cfg = VTCodeConfig::default();
462        vt_cfg.custom_providers.push(vtcode_config::core::CustomProviderConfig {
463            name: "my-gateway".to_string(),
464            models: vec![" gpt-5.6-luna ".to_string()],
465            ..Default::default()
466        });
467
468        // Act: resolve an explicitly overridden model with surrounding whitespace.
469        let route = resolve_lightweight_route(
470            &runtime,
471            Some(&vt_cfg),
472            LightweightFeature::PromptSuggestions,
473            Some("  gpt-5.6-luna  "),
474        );
475
476        // Assert: declaration wins over built-in provider inference and is normalized.
477        assert_eq!(
478            route.primary,
479            ModelRoute {
480                provider_name: "my-gateway".to_string(),
481                model: "gpt-5.6-luna".to_string(),
482            }
483        );
484        assert_eq!(route.source, LightweightRouteSource::FeatureOverride);
485        assert!(route.warning.is_none());
486    }
487
488    #[test]
489    fn shared_small_model_accepts_declared_custom_provider_model_that_infers_builtin_provider() {
490        // Arrange: shared small-model config selects a model declared by the active custom provider.
491        let mut runtime = runtime_config();
492        runtime.provider = "my-gateway".to_string();
493        let mut vt_cfg = VTCodeConfig::default();
494        vt_cfg.agent.small_model.model = "  gpt-5.6-luna  ".to_string();
495        vt_cfg.custom_providers.push(vtcode_config::core::CustomProviderConfig {
496            name: "my-gateway".to_string(),
497            models: vec!["gpt-5.6-luna".to_string()],
498            ..Default::default()
499        });
500
501        // Act: resolve the shared configured lightweight model.
502        let route = resolve_lightweight_route(&runtime, Some(&vt_cfg), LightweightFeature::PromptSuggestions, None);
503
504        // Assert: the declared model is selected through the active custom provider.
505        assert_eq!(
506            route.primary,
507            ModelRoute {
508                provider_name: "my-gateway".to_string(),
509                model: "gpt-5.6-luna".to_string(),
510            }
511        );
512        assert_eq!(route.source, LightweightRouteSource::SharedConfigured);
513        assert!(route.warning.is_none());
514    }
515
516    #[test]
517    fn auto_lightweight_model_prefers_same_generation_openai_sibling() {
518        assert_eq!(auto_lightweight_model("openai", &ModelId::GPT56Sol.as_str()), ModelId::GPT56Terra.as_str());
519    }
520
521    #[test]
522    fn auto_lightweight_model_uses_catalog_anthropic_pair() {
523        assert_eq!(
524            auto_lightweight_model("anthropic", &ModelId::ClaudeSonnet5.as_str()),
525            ModelId::ClaudeSonnet5.as_str()
526        );
527        assert_eq!(auto_lightweight_model("anthropic", "claude-sonnet-4.5"), "claude-sonnet-4.5");
528    }
529
530    #[test]
531    fn auto_lightweight_model_uses_lower_generation_glm_pair() {
532        assert_eq!(auto_lightweight_model("zai", &ModelId::ZaiGlm53.as_str()), ModelId::ZaiGlm53.as_str());
533    }
534
535    #[test]
536    fn auto_lightweight_model_prefers_same_generation_gemini_flash_lite() {
537        assert_eq!(auto_lightweight_model("gemini", &ModelId::Gemini38Flash.as_str()), ModelId::Gemini38Flash.as_str());
538    }
539
540    #[test]
541    fn auto_lightweight_model_infers_family_for_custom_provider() {
542        assert_eq!(auto_lightweight_model("mycorp", &ModelId::GPT56Sol.as_str()), ModelId::GPT56Terra.as_str());
543    }
544
545    #[test]
546    fn auto_lightweight_model_keeps_unmapped_custom_route() {
547        assert_eq!(auto_lightweight_model("mycorp", "custom-model-v1"), "custom-model-v1");
548    }
549
550    #[test]
551    fn auto_lightweight_model_keeps_unmapped_model_for_known_provider() {
552        assert_eq!(auto_lightweight_model("openai", "custom-model-v1"), "custom-model-v1");
553    }
554
555    #[test]
556    fn auto_lightweight_model_keeps_unmapped_evolink_route() {
557        assert_eq!(auto_lightweight_model("evolink", "evolink/deepseek-v4-pro"), "evolink/deepseek-v4-pro");
558    }
559
560    #[test]
561    fn lightweight_model_choices_do_not_invent_provider_for_unknown_route() {
562        assert_eq!(lightweight_model_choices("mycorp", "custom-model-v1"), vec!["custom-model-v1"]);
563    }
564
565    #[test]
566    fn disabled_feature_uses_main_model() {
567        let runtime = runtime_config();
568        let mut vt_cfg = VTCodeConfig::default();
569        vt_cfg.agent.small_model.use_for_memory = false;
570
571        let route = resolve_lightweight_route(&runtime, Some(&vt_cfg), LightweightFeature::Memory, None);
572
573        assert_eq!(route.primary.model, ModelId::GPT56Sol.as_str());
574        assert_eq!(route.source, LightweightRouteSource::MainModel);
575        assert!(route.fallback.is_none());
576    }
577}