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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum LightweightFeature {
17 Memory,
19 PromptSuggestions,
21 PromptRefinement,
23 AutoPermissionReview,
25 AutoPermissionProbe,
27 LargeReadSummary,
29 WebSummary,
31 GitHistorySummary,
33 Subagent,
35 ToolFailureDiagnosis,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ModelRoute {
42 pub provider_name: String,
44 pub model: String,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum LightweightRouteSource {
51 FeatureOverride,
53 SharedConfigured,
55 SharedAutomatic,
58 MainModel,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct LightweightRouteResolution {
65 pub primary: ModelRoute,
67 pub fallback: Option<ModelRoute>,
69 pub source: LightweightRouteSource,
71 pub warning: Option<String>,
73}
74
75impl LightweightRouteResolution {
76 pub fn uses_lightweight_model(&self) -> bool {
78 !matches!(self.source, LightweightRouteSource::MainModel)
79 }
80
81 pub fn fallback_to_main_model(&self) -> Option<&ModelRoute> {
83 self.fallback.as_ref()
84 }
85}
86
87pub 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
163pub 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
176pub 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 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 trimmed_model.to_string()
203}
204
205pub 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
247pub 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
280pub 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
287pub 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
362fn 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 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 let route = resolve_lightweight_route(
470 &runtime,
471 Some(&vt_cfg),
472 LightweightFeature::PromptSuggestions,
473 Some(" gpt-5.6-luna "),
474 );
475
476 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 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 let route = resolve_lightweight_route(&runtime, Some(&vt_cfg), LightweightFeature::PromptSuggestions, None);
503
504 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}