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 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
279pub 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
286pub 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
361fn 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 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 let route = resolve_lightweight_route(
469 &runtime,
470 Some(&vt_cfg),
471 LightweightFeature::PromptSuggestions,
472 Some(" gpt-5.6-luna "),
473 );
474
475 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 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 let route = resolve_lightweight_route(&runtime, Some(&vt_cfg), LightweightFeature::PromptSuggestions, None);
502
503 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}