1use anyhow::{Result, bail};
6use std::path::Path;
7use vtcode_commons::MultiErrors;
8
9use crate::config::FullAutoConfig;
10use crate::config::loader::VTCodeConfig;
11use crate::config::models::{catalog_provider_keys, model_catalog_entry, supported_models_for_provider};
12use vtcode_config::core::CustomProviderConfig;
13
14#[derive(Debug, Clone)]
16pub struct ValidationResult {
17 pub is_valid: bool,
18 pub errors: MultiErrors<String>,
19 pub warnings: Vec<String>,
20}
21
22impl ValidationResult {
23 pub fn new() -> Self {
24 Self {
25 is_valid: true,
26 errors: MultiErrors::new(),
27 warnings: Vec::new(),
28 }
29 }
30
31 pub fn add_error(&mut self, error: String) {
32 self.is_valid = false;
33 self.errors.push(error);
34 }
35
36 pub fn add_warning(&mut self, warning: String) {
37 self.warnings.push(warning);
38 }
39}
40
41impl Default for ValidationResult {
42 fn default() -> Self {
43 Self::new()
44 }
45}
46
47pub fn validate_model_exists(provider: &str, model: &str) -> Result<()> {
49 if provider.eq_ignore_ascii_case("copilot") || provider.eq_ignore_ascii_case("merge-gateway") {
50 if model.trim().is_empty() {
51 bail!("Model must not be empty for provider '{provider}'");
52 }
53 return Ok(());
54 }
55
56 if let Some(models) = supported_models_for_provider(provider) {
57 if !models.contains(&model) {
58 if provider.eq_ignore_ascii_case("openai")
60 && let Some((replacement, reason)) =
61 vtcode_config::constants::models::openai::deprecated_model_replacement(model)
62 {
63 bail!("{reason}. Update your config to use '{replacement}' or run /model to pick a current model.");
64 }
65 bail!("Model '{}' not found for provider '{}'. Available models: {}", model, provider, models.join(", "));
66 }
67 Ok(())
68 } else {
69 bail!(
70 "Provider '{}' not recognized. Available providers: {}",
71 provider,
72 catalog_provider_keys().join(", ")
73 );
74 }
75}
76
77fn catalog_model_context_window(provider: &str, model: &str) -> Result<Option<usize>> {
79 Ok(model_catalog_entry(provider, model)
80 .map(|entry| entry.context_window)
81 .filter(|context_window| *context_window > 0))
82}
83
84pub fn effective_model_context_window(provider: &str, model: &str) -> Result<Option<usize>> {
86 if provider.eq_ignore_ascii_case("anthropic") {
87 return Ok(Some(crate::llm::providers::anthropic::capabilities::effective_context_size(model)));
88 }
89
90 catalog_model_context_window(provider, model)
91}
92
93fn custom_provider_for_model<'a>(
94 config: &'a VTCodeConfig,
95 provider: &str,
96 model: &str,
97) -> Option<&'a CustomProviderConfig> {
98 config
99 .custom_providers
100 .iter()
101 .find(|custom| custom.name.eq_ignore_ascii_case(provider))
102 .and_then(|custom| {
103 custom.effective_models().into_iter().find(|candidate| candidate == model)?;
104 Some(custom)
105 })
106}
107
108fn effective_model_context_window_for_config(
109 config: &VTCodeConfig,
110 provider: &str,
111 model: &str,
112) -> Result<Option<usize>> {
113 if let Some(custom) = custom_provider_for_model(config, provider, model) {
114 let profile = custom.resolved_profile(model);
115 if let Some(context_window) = profile.context_window {
116 return Ok(Some(context_window));
117 }
118
119 return match profile.api_format {
120 Some(vtcode_config::core::CustomProviderApiFormat::AnthropicMessages) => {
121 effective_model_context_window("anthropic", model)
122 }
123 _ => Ok(effective_model_context_window("openai", model)?.or(Some(128_000))),
124 };
125 }
126
127 effective_model_context_window(provider, model)
128}
129
130pub fn validate_config(config: &VTCodeConfig, workspace: &Path) -> Result<ValidationResult> {
132 let mut result = ValidationResult::new();
133
134 validate_agent_model(config, &mut result);
136
137 if !config.providers_whitelist.is_empty()
139 && !config
140 .providers_whitelist
141 .iter()
142 .any(|w| w.eq_ignore_ascii_case(&config.agent.provider))
143 {
144 result.add_error(format!(
145 "Provider '{}' is not in providers_whitelist: {:?}",
146 config.agent.provider, config.providers_whitelist
147 ));
148 }
149
150 validate_context_window(config, &mut result);
152
153 if config.agent.checkpointing.enabled
155 && let Some(storage_dir) = &config.agent.checkpointing.storage_dir
156 {
157 validate_checkpointing_dir(storage_dir, workspace, &mut result);
158 }
159
160 if config.automation.full_auto.enabled {
162 validate_full_auto_config(&config.automation.full_auto, workspace, &mut result);
163 }
164
165 Ok(result)
166}
167
168fn validate_agent_model(config: &VTCodeConfig, result: &mut ValidationResult) {
169 let provider = &config.agent.provider;
170 let model = &config.agent.default_model;
171 if provider.eq_ignore_ascii_case("codex") {
172 return;
173 }
174
175 let validation = if let Some(custom) = config
176 .custom_providers
177 .iter()
178 .find(|custom| custom.name.eq_ignore_ascii_case(provider))
179 {
180 if custom.effective_models().iter().any(|candidate| candidate == model) {
181 Ok(())
182 } else {
183 Err(anyhow::anyhow!(
184 "Model '{}' not found for custom provider '{}'. Available models: {}",
185 model,
186 custom.display_name,
187 custom.effective_models().join(", ")
188 ))
189 }
190 } else {
191 validate_model_exists(provider, model)
192 };
193
194 match validation {
195 Ok(_) => {
196 if let Ok(Some(context_size)) = effective_model_context_window_for_config(config, provider, model) {
198 let display_size = if context_size >= 1_000_000 {
199 format!("{}M", context_size / 1_000_000)
200 } else if context_size >= 1_000 {
201 format!("{}K", context_size / 1_000)
202 } else {
203 context_size.to_string()
204 };
205 tracing::debug!("Agent model '{}' context window: {}", model, display_size);
206 }
207 }
208 Err(e) => {
209 result.add_error(format!("Agent model configuration invalid: {e}"));
210 }
211 }
212}
213
214fn validate_context_window(config: &VTCodeConfig, result: &mut ValidationResult) {
215 if config.agent.provider.eq_ignore_ascii_case("codex") {
216 return;
217 }
218
219 let context_window = config.context.max_context_tokens;
220 if context_window > 0
221 && let Ok(Some(model_context)) =
222 effective_model_context_window_for_config(config, &config.agent.provider, &config.agent.default_model)
223 && context_window > model_context
224 {
225 result.add_warning(format!(
226 "Configured session context safety budget {context_window} exceeds provider capacity {model_context}. \
227 The provider capacity remains the hard upper bound for compaction and request validation."
228 ));
229 }
230}
231
232fn validate_checkpointing_dir(storage_dir: &str, workspace: &Path, result: &mut ValidationResult) {
233 let path = if Path::new(storage_dir).is_absolute() {
234 std::path::PathBuf::from(storage_dir)
235 } else {
236 workspace.join(storage_dir)
237 };
238
239 if let Some(parent) = path.parent()
241 && !parent.exists()
242 {
243 result.add_warning(format!(
244 "Checkpointing storage directory parent '{}' does not exist. \
245 It will be created when checkpointing is first used.",
246 parent.display()
247 ));
248 }
249}
250
251fn validate_full_auto_config(full_auto_cfg: &FullAutoConfig, workspace: &Path, result: &mut ValidationResult) {
252 if full_auto_cfg.require_profile_ack {
253 if let Some(profile_path) = &full_auto_cfg.profile_path {
254 let resolved = if Path::new(profile_path).is_absolute() {
255 std::path::PathBuf::from(profile_path)
256 } else {
257 workspace.join(profile_path)
258 };
259
260 if !resolved.exists() {
261 result.add_error(format!(
262 "Full-auto profile '{}' required but not found. \
263 Create the acknowledgement file before using --full-auto.",
264 resolved.display()
265 ));
266 }
267 } else {
268 result.add_error("Full-auto profile_path is required when require_profile_ack = true".to_owned());
269 }
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn generated_catalog_contains_providers() {
279 let providers = catalog_provider_keys();
280 assert!(!providers.is_empty(), "Should expose generated providers");
281 assert!(
282 providers.contains(&"gemini") || providers.contains(&"openai"),
283 "Should have at least one major provider"
284 );
285 }
286
287 #[test]
288 fn validates_known_model() {
289 let result = validate_model_exists("google", "gemini-3.7-flash");
290 assert!(result.is_ok(), "Should validate gemini-3.7-flash for google provider");
291 }
292
293 #[test]
294 fn rejects_unknown_model() {
295 let result = validate_model_exists("google", "model-does-not-exist");
296 assert!(result.is_err(), "Should reject unknown model");
297 }
298
299 #[test]
300 fn accepts_live_copilot_model_id() {
301 let result = validate_model_exists("copilot", "gpt-5-codex");
302 assert!(result.is_ok(), "Should accept live Copilot model ids");
303 }
304
305 #[test]
306 fn accepts_arbitrary_merge_gateway_model_id() {
307 let result = validate_model_exists("merge-gateway", "deepseek/deepseek-v4-pro");
308 assert!(result.is_ok(), "Merge Gateway should accept valid explicit route ids");
309 }
310
311 #[test]
312 fn validate_config_skips_codex_model_catalog_checks() {
313 let mut config = VTCodeConfig::default();
314 config.agent.provider = "codex".to_string();
315 config.agent.default_model = "upstream-managed-model".to_string();
316
317 let result = validate_config(&config, Path::new(".")).expect("config validation should run");
318
319 assert!(result.errors.is_empty());
320 }
321
322 #[test]
323 fn rejects_unknown_provider() {
324 let result = validate_model_exists("provider-does-not-exist", "some-model");
325 assert!(result.is_err(), "Should reject unknown provider");
326 }
327
328 #[test]
329 fn gets_context_window() {
330 let result = effective_model_context_window("google", "gemini-3.7-flash");
331 assert!(result.is_ok(), "Should get context window");
332
333 let context = result.unwrap();
334 assert!(context.is_some() && context.unwrap() > 0, "Should have positive context window");
335 }
336
337 #[test]
338 fn anthropic_46_uses_effective_context_window() {
339 let result = effective_model_context_window("anthropic", "claude-sonnet-5");
340 assert_eq!(result.unwrap(), Some(1_000_000));
341 }
342
343 #[test]
344 fn validation_result_collects_errors() {
345 let mut result = ValidationResult::new();
346 assert!(result.is_valid);
347
348 result.add_error("Error 1".to_owned());
349 assert!(!result.is_valid);
350
351 result.add_error("Error 2".to_owned());
352 assert_eq!(result.errors.len(), 2);
353 }
354
355 #[test]
356 fn validation_result_collects_warnings() {
357 let mut result = ValidationResult::new();
358 result.add_warning("Warning 1".to_owned());
359 assert_eq!(result.warnings.len(), 1);
360 assert!(result.is_valid); }
362
363 #[test]
364 fn whitelist_allows_configured_provider() {
365 let mut config = VTCodeConfig::default();
366 config.agent.provider = "openai".to_string();
367 config.agent.default_model = "gpt-5.6-sol".to_string();
368 config.providers_whitelist = vec!["openai".to_string()];
369
370 let result = validate_config(&config, Path::new(".")).expect("validation should run");
371 assert!(result.errors.is_empty(), "openai should pass when whitelisted: {:?}", result.errors);
372 }
373
374 #[test]
375 fn whitelist_blocks_non_whitelisted_provider() {
376 let mut config = VTCodeConfig::default();
377 config.agent.provider = "openai".to_string();
378 config.agent.default_model = "gpt-5.6-sol".to_string();
379 config.providers_whitelist = vec!["anthropic".to_string()];
380
381 let result = validate_config(&config, Path::new(".")).expect("validation should run");
382 assert!(
383 result.errors.iter().any(|e| e.contains("not in providers_whitelist")),
384 "expected whitelist error, got: {:?}",
385 result.errors
386 );
387 }
388
389 #[test]
390 fn empty_whitelist_allows_all_providers() {
391 let mut config = VTCodeConfig::default();
392 config.agent.provider = "openai".to_string();
393 config.agent.default_model = "gpt-5.6-sol".to_string();
394 config.providers_whitelist.clear();
395
396 let result = validate_config(&config, Path::new(".")).expect("validation should run");
397 assert!(result.errors.is_empty(), "empty whitelist should allow all: {:?}", result.errors);
398 }
399
400 #[test]
401 fn deprecated_openai_model_produces_actionable_error() {
402 let mut config = VTCodeConfig::default();
403 config.agent.provider = "openai".to_string();
404 config.agent.default_model = "gpt-5".to_string();
405 config.providers_whitelist.clear();
406
407 let result = validate_config(&config, Path::new(".")).expect("validation should run");
408 assert!(
409 result.errors.iter().any(|e| e.contains("gpt-5.6-sol")),
410 "expected actionable deprecation replacement, got: {:?}",
411 result.errors
412 );
413 }
414
415 #[test]
416 fn non_openai_provider_with_deprecated_id_gets_generic_error() {
417 let mut config = VTCodeConfig::default();
420 config.agent.provider = "custom-provider".to_string();
421 config.agent.default_model = "gpt-5".to_string();
422 config.providers_whitelist.clear();
423 config.custom_providers.push(CustomProviderConfig {
426 name: "custom-provider".to_string(),
427 models: vec!["gpt-5".to_string()],
428 ..Default::default()
429 });
430
431 let result = validate_config(&config, Path::new(".")).expect("validation should run");
432 assert!(
433 !result.errors.iter().any(|e| e.contains("gpt-5.6-sol")),
434 "non-OpenAI provider should not get OpenAI migration advice: {:?}",
435 result.errors
436 );
437 }
438}