1use anyhow::{Context, Result, bail};
6use hashbrown::HashMap;
7use serde_json::Value as JsonValue;
8use std::path::Path;
9
10use crate::config::api_keys::ApiKeySources;
11use crate::config::api_keys::get_api_key;
12use crate::config::constants::models::openai::RESPONSES_API_MODELS;
13use crate::config::loader::VTCodeConfig;
14use crate::config::models::{Provider, model_catalog_entry, supported_models_for_provider};
15use crate::utils::file_utils::read_file_with_context_sync;
16
17#[derive(Debug, Clone)]
19enum ModelsDatabase {
20 Generated,
21 File {
22 providers: HashMap<String, ProviderModels>,
23 },
24}
25
26#[derive(Debug, Clone)]
27struct ProviderModels {
28 models: HashMap<String, ModelInfo>,
29}
30
31#[derive(Debug, Clone)]
32struct ModelInfo {
33 context_window: usize,
34}
35
36impl ModelsDatabase {
37 pub fn generated() -> Self {
38 Self::Generated
39 }
40
41 #[must_use = "models database load failure is silently ignored"]
43 pub fn from_file(path: &Path) -> Result<Self> {
44 let content = read_file_with_context_sync(path, "models database")?;
45
46 let json: JsonValue =
47 serde_json::from_str(&content).context("Failed to parse models database JSON")?;
48
49 let mut providers = HashMap::new();
50
51 if let Some(obj) = json.as_object() {
52 for (provider_id, provider_data) in obj {
53 if let Some(provider_obj) = provider_data.as_object() {
54 let mut models = HashMap::new();
55
56 if let Some(models_obj) = provider_obj.get("models").and_then(|v| v.as_object())
57 {
58 for (model_id, model_data) in models_obj {
59 let context_window = model_data
60 .get("context")
61 .and_then(|v| v.as_u64())
62 .unwrap_or(0)
63 as usize;
64
65 models.insert(model_id.clone(), ModelInfo { context_window });
66 }
67 }
68
69 providers.insert(provider_id.clone(), ProviderModels { models });
70 }
71 }
72 }
73
74 Ok(Self::File { providers })
75 }
76
77 pub fn model_exists(&self, provider: &str, model: &str) -> bool {
79 match self {
80 Self::Generated => supported_models_for_provider(provider)
81 .map(|models| models.contains(&model))
82 .unwrap_or(false),
83 Self::File { providers } => providers
84 .get(provider)
85 .map(|p| p.models.contains_key(model))
86 .unwrap_or(false),
87 }
88 }
89
90 pub fn get_context_window(&self, provider: &str, model: &str) -> Option<usize> {
92 match self {
93 Self::Generated => model_catalog_entry(provider, model)
94 .map(|entry| entry.context_window)
95 .filter(|context_window| *context_window > 0),
96 Self::File { providers } => providers
97 .get(provider)
98 .and_then(|p| p.models.get(model))
99 .map(|m| m.context_window),
100 }
101 }
102}
103
104pub struct ConfigValidator {
106 models_db: ModelsDatabase,
107}
108
109impl ConfigValidator {
110 pub fn generated() -> Self {
112 Self {
113 models_db: ModelsDatabase::generated(),
114 }
115 }
116
117 pub fn new(models_db_path: &Path) -> Result<Self> {
119 Ok(Self {
120 models_db: ModelsDatabase::from_file(models_db_path)?,
121 })
122 }
123
124 #[must_use = "validation errors go unnoticed"]
126 pub fn validate(&self, config: &VTCodeConfig) -> Result<ValidationResult> {
127 let mut result = ValidationResult::default();
128 let managed_auth_provider = configured_managed_auth_provider(config);
129 let custom_provider = config.custom_provider(&config.agent.provider);
130 let is_custom_provider = custom_provider.is_some();
131 let is_codex_provider = config.agent.provider.eq_ignore_ascii_case("codex");
132
133 if !is_custom_provider
135 && !is_codex_provider
136 && !is_managed_auth_model(managed_auth_provider, &config.agent.default_model)
137 && !self
138 .models_db
139 .model_exists(&config.agent.provider, &config.agent.default_model)
140 {
141 result.errors.push(format!(
142 "Model '{}' not found for provider '{}'. Check docs/models.json.",
143 config.agent.default_model, config.agent.provider
144 ));
145 }
146
147 if !is_custom_provider
149 && !is_codex_provider
150 && managed_auth_provider.is_none()
151 && let Err(e) = get_api_key(&config.agent.provider, &ApiKeySources::default())
152 {
153 result.errors.push(format!(
154 "API key not found for provider '{}': {}. Set {} environment variable.",
155 config.agent.provider,
156 e,
157 config.agent.provider.to_uppercase()
158 ));
159 }
160
161 if !is_custom_provider
163 && let Some(max_tokens) = self
164 .models_db
165 .get_context_window(&config.agent.provider, &config.agent.default_model)
166 {
167 let configured_context = config.context.max_context_tokens;
168 if configured_context > 0 && configured_context > max_tokens {
169 result.warnings.push(format!(
170 "Configured context window ({} tokens) exceeds model limit ({} tokens) for {} on {}",
171 configured_context, max_tokens,
172 config.agent.default_model, config.agent.provider
173 ));
174 }
175 }
176
177 if let Some(message) = check_openai_hosted_shell_compat(
178 config,
179 &config.agent.default_model,
180 &config.agent.provider,
181 ) {
182 result.warnings.push(message);
183 }
184
185 if let Ok(cwd) = std::env::current_dir() {
187 if !cwd.exists() {
189 result
190 .warnings
191 .push("Current working directory does not exist".to_owned());
192 }
193 }
194
195 Ok(result)
196 }
197
198 pub fn quick_validate(&self, config: &VTCodeConfig) -> Result<()> {
200 let managed_auth_provider = configured_managed_auth_provider(config);
201 let is_custom_provider = config.custom_provider(&config.agent.provider).is_some();
202 let is_codex_provider = config.agent.provider.eq_ignore_ascii_case("codex");
203
204 if !is_custom_provider
206 && !is_codex_provider
207 && !is_managed_auth_model(managed_auth_provider, &config.agent.default_model)
208 && !self
209 .models_db
210 .model_exists(&config.agent.provider, &config.agent.default_model)
211 {
212 bail!(
213 "Model '{}' not found for provider '{}'. Check docs/models.json.",
214 config.agent.default_model,
215 config.agent.provider
216 );
217 }
218
219 if !is_custom_provider && !is_codex_provider && managed_auth_provider.is_none() {
221 get_api_key(&config.agent.provider, &ApiKeySources::default()).with_context(|| {
222 format!(
223 "API key not found for provider '{}'. Set {} environment variable.",
224 config.agent.provider,
225 config.agent.provider.to_uppercase()
226 )
227 })?;
228 }
229
230 Ok(())
231 }
232}
233
234fn configured_managed_auth_provider(config: &VTCodeConfig) -> Option<Provider> {
235 config
236 .agent
237 .provider
238 .parse::<Provider>()
239 .ok()
240 .filter(|provider| provider.uses_managed_auth())
241}
242
243fn is_managed_auth_model(provider: Option<Provider>, model: &str) -> bool {
244 matches!(provider, Some(Provider::Copilot)) && !model.trim().is_empty()
245}
246
247#[derive(Debug, Default, Clone)]
249pub struct ValidationResult {
250 pub errors: Vec<String>,
251 pub warnings: Vec<String>,
252}
253
254pub fn check_prompt_cache_retention_compat(
255 config: &VTCodeConfig,
256 model: &str,
257 provider: &str,
258) -> Option<String> {
259 if !provider.eq_ignore_ascii_case("openai") {
260 return None;
261 }
262
263 if let Some(ref retention) = config.prompt_cache.providers.openai.prompt_cache_retention {
264 if retention.trim().is_empty() {
265 return None;
266 }
267 if !RESPONSES_API_MODELS.contains(&model) {
268 return Some(format!(
269 "`prompt_cache_retention` is set but the selected model '{}' does not use the OpenAI Responses API. The setting will be ignored for this model. Run `vtcode models list --provider openai` to see supported Responses API models.",
270 model
271 ));
272 }
273 }
274
275 None
276}
277
278pub fn check_openai_hosted_shell_compat(
279 config: &VTCodeConfig,
280 model: &str,
281 provider: &str,
282) -> Option<String> {
283 if !provider.eq_ignore_ascii_case("openai") {
284 return None;
285 }
286
287 let hosted_shell = &config.provider.openai.hosted_shell;
288 if !hosted_shell.enabled {
289 return None;
290 }
291
292 if !RESPONSES_API_MODELS.contains(&model) {
293 return Some(format!(
294 "`provider.openai.hosted_shell.enabled` is set but the selected model '{}' does not use the OpenAI Responses API. VT Code will ignore hosted shell and keep the local shell tool for this model.",
295 model
296 ));
297 }
298
299 if !hosted_shell.has_valid_reference_target() {
300 return Some(
301 "`provider.openai.hosted_shell.environment = \"container_reference\"` requires a non-empty `provider.openai.hosted_shell.container_id`. VT Code will ignore hosted shell until a container ID is configured."
302 .to_string(),
303 );
304 }
305
306 if hosted_shell.uses_container_reference()
307 && (!hosted_shell.file_ids.is_empty()
308 || !hosted_shell.skills.is_empty()
309 || hosted_shell.network_policy.is_allowlist())
310 {
311 return Some(
312 "`provider.openai.hosted_shell.file_ids`, `provider.openai.hosted_shell.skills`, and allowlist `provider.openai.hosted_shell.network_policy` settings are only used with `container_auto`. VT Code will ignore those fields while `container_reference` is selected."
313 .to_string(),
314 );
315 }
316
317 if let Some(message) = hosted_shell.first_invalid_skill_message() {
318 return Some(format!(
319 "{} VT Code will ignore hosted shell until the mounted skills are corrected.",
320 message
321 ));
322 }
323
324 if let Some(message) = hosted_shell.first_invalid_network_policy_message() {
325 return Some(format!(
326 "{} VT Code will ignore hosted shell until the hosted shell network policy is corrected.",
327 message
328 ));
329 }
330
331 None
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use std::fs;
338 use tempfile::TempDir;
339
340 fn create_test_models_db() -> TempDir {
341 let dir = TempDir::new().unwrap();
342 let models_json = r#"{
343 "google": {
344 "id": "google",
345 "default_model": "gemini-3-flash-preview",
346 "models": {
347 "gemini-3-flash-preview": {
348 "context": 1048576
349 }
350 }
351 },
352 "openai": {
353 "id": "openai",
354 "default_model": "gpt-5",
355 "models": {
356 "gpt-5": {
357 "context": 128000
358 }
359 }
360 }
361}"#;
362 fs::write(dir.path().join("models.json"), models_json).unwrap();
363 dir
364 }
365
366 #[test]
367 fn loads_models_database() {
368 let dir = create_test_models_db();
369 let db = ModelsDatabase::from_file(&dir.path().join("models.json")).unwrap();
370
371 assert!(db.model_exists("google", "gemini-3-flash-preview"));
372 assert!(db.model_exists("openai", "gpt-5"));
373 assert!(!db.model_exists("google", "nonexistent"));
374 }
375
376 #[test]
377 fn gets_context_window() {
378 let dir = create_test_models_db();
379 let db = ModelsDatabase::from_file(&dir.path().join("models.json")).unwrap();
380
381 assert_eq!(
382 db.get_context_window("google", "gemini-3-flash-preview"),
383 Some(1048576)
384 );
385 assert_eq!(db.get_context_window("openai", "gpt-5"), Some(128000));
386 assert_eq!(db.get_context_window("google", "nonexistent"), None);
387 }
388
389 #[test]
390 fn validates_model_exists() {
391 let dir = create_test_models_db();
392 let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
393 let mut config = VTCodeConfig::default();
394 config.agent.provider = "google".to_owned();
395 config.agent.default_model = "gemini-3-flash-preview".to_owned();
396
397 let result = validator.validate(&config).unwrap();
398 assert!(!result.errors.iter().any(|e| {
400 e.contains("Model 'gemini-3-flash-preview' not found for provider 'google'")
401 }));
402 }
403
404 #[test]
405 fn custom_provider_skips_builtin_model_catalog_checks() {
406 let dir = create_test_models_db();
407 let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
408 let mut config = VTCodeConfig::default();
409 config.agent.provider = "mycorp".to_owned();
410 config.agent.default_model = "totally-custom-model".to_owned();
411 config
412 .custom_providers
413 .push(vtcode_config::core::CustomProviderConfig {
414 name: "mycorp".to_string(),
415 display_name: "MyCorporateName".to_string(),
416 base_url: "https://llm.example/v1".to_string(),
417 api_key_env: "MYCORP_API_KEY".to_string(),
418 auth: None,
419 model: "totally-custom-model".to_string(),
420 models: Vec::new(),
421 });
422
423 let result = validator.validate(&config).unwrap();
424
425 assert!(result.errors.is_empty());
426 }
427
428 #[test]
429 fn codex_provider_skips_builtin_model_and_api_key_checks() {
430 let dir = create_test_models_db();
431 let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
432 let mut config = VTCodeConfig::default();
433 config.agent.provider = "codex".to_owned();
434 config.agent.default_model = "managed-by-codex".to_owned();
435
436 let result = validator.validate(&config).unwrap();
437
438 assert!(result.errors.is_empty());
439 }
440
441 #[test]
442 fn copilot_managed_auth_model_accepts_live_raw_ids() {
443 assert!(is_managed_auth_model(
444 Some(Provider::Copilot),
445 "gpt-5.3-codex"
446 ));
447 assert!(!is_managed_auth_model(Some(Provider::Copilot), " "));
448 }
449
450 #[test]
451 fn detects_missing_model() {
452 let dir = create_test_models_db();
453 let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
454 let mut config = VTCodeConfig::default();
455 config.agent.provider = "google".to_owned();
456 config.agent.default_model = "nonexistent-model".to_owned();
457
458 let result = validator.validate(&config).unwrap();
459 assert!(result.errors.iter().any(|e| e.contains("not found")));
460 }
461
462 #[test]
463 fn detects_context_window_exceeded() {
464 let dir = create_test_models_db();
465 let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
466 let mut config = VTCodeConfig::default();
467 config.agent.provider = "google".to_owned();
468 config.agent.default_model = "gemini-3-flash-preview".to_owned();
469 config.context.max_context_tokens = 2000000; let result = validator.validate(&config).unwrap();
472 assert!(result.warnings.iter().any(|w| w.contains("exceeds")));
473 }
474
475 #[test]
476 fn retention_warning_for_non_responses_model() {
477 let mut cfg = VTCodeConfig::default();
478 cfg.prompt_cache.providers.openai.prompt_cache_retention = Some("24h".to_owned());
479 let msg = check_prompt_cache_retention_compat(&cfg, "gpt-oss-20b", "openai");
480 assert!(msg.is_some());
481 }
482
483 #[test]
484 fn retention_ok_for_responses_model() {
485 let mut cfg = VTCodeConfig::default();
486 cfg.prompt_cache.providers.openai.prompt_cache_retention = Some("24h".to_owned());
487 let msg = check_prompt_cache_retention_compat(
488 &cfg,
489 crate::config::constants::models::openai::GPT_5,
490 "openai",
491 );
492 assert!(msg.is_none());
493 }
494
495 #[test]
496 fn retention_ok_for_gpt_alias() {
497 let mut cfg = VTCodeConfig::default();
498 cfg.prompt_cache.providers.openai.prompt_cache_retention = Some("24h".to_owned());
499 let msg = check_prompt_cache_retention_compat(
500 &cfg,
501 crate::config::constants::models::openai::GPT,
502 "openai",
503 );
504 assert!(msg.is_none());
505 }
506
507 #[test]
508 fn hosted_shell_warning_for_non_responses_model() {
509 let mut cfg = VTCodeConfig::default();
510 cfg.provider.openai.hosted_shell.enabled = true;
511
512 let msg = check_openai_hosted_shell_compat(&cfg, "gpt-oss-20b", "openai");
513 assert!(msg.is_some());
514 }
515
516 #[test]
517 fn hosted_shell_warning_for_missing_container_reference_id() {
518 let mut cfg = VTCodeConfig::default();
519 cfg.provider.openai.hosted_shell.enabled = true;
520 cfg.provider.openai.hosted_shell.environment =
521 crate::config::core::OpenAIHostedShellEnvironment::ContainerReference;
522
523 let msg = check_openai_hosted_shell_compat(
524 &cfg,
525 crate::config::constants::models::openai::GPT_5,
526 "openai",
527 );
528 assert!(msg.is_some());
529 }
530
531 #[test]
532 fn hosted_shell_warning_for_auto_only_fields_on_container_reference() {
533 let mut cfg = VTCodeConfig::default();
534 cfg.provider.openai.hosted_shell.enabled = true;
535 cfg.provider.openai.hosted_shell.environment =
536 crate::config::core::OpenAIHostedShellEnvironment::ContainerReference;
537 cfg.provider.openai.hosted_shell.container_id = Some("cntr_123".to_string());
538 cfg.provider.openai.hosted_shell.file_ids = vec!["file_123".to_string()];
539 cfg.provider.openai.hosted_shell.network_policy.policy_type =
540 vtcode_config::core::OpenAIHostedShellNetworkPolicyType::Allowlist;
541 cfg.provider
542 .openai
543 .hosted_shell
544 .network_policy
545 .allowed_domains = vec!["httpbin.org".to_string()];
546
547 let msg = check_openai_hosted_shell_compat(
548 &cfg,
549 crate::config::constants::models::openai::GPT_5,
550 "openai",
551 );
552 assert!(msg.is_some());
553 }
554
555 #[test]
556 fn hosted_shell_ok_for_valid_responses_config() {
557 let mut cfg = VTCodeConfig::default();
558 cfg.provider.openai.hosted_shell.enabled = true;
559
560 let msg = check_openai_hosted_shell_compat(
561 &cfg,
562 crate::config::constants::models::openai::GPT_5,
563 "openai",
564 );
565 assert!(msg.is_none());
566 }
567
568 #[test]
569 fn hosted_shell_ok_for_gpt_alias() {
570 let mut cfg = VTCodeConfig::default();
571 cfg.provider.openai.hosted_shell.enabled = true;
572
573 let msg = check_openai_hosted_shell_compat(
574 &cfg,
575 crate::config::constants::models::openai::GPT,
576 "openai",
577 );
578 assert!(msg.is_none());
579 }
580
581 #[test]
582 fn hosted_shell_warning_for_empty_skill_reference_id() {
583 let mut cfg = VTCodeConfig::default();
584 cfg.provider.openai.hosted_shell.enabled = true;
585 cfg.provider.openai.hosted_shell.skills =
586 vec![vtcode_config::core::OpenAIHostedSkill::SkillReference {
587 skill_id: " ".to_string(),
588 version: vtcode_config::core::OpenAIHostedSkillVersion::default(),
589 }];
590
591 let msg = check_openai_hosted_shell_compat(&cfg, "gpt-5", "openai");
592
593 assert!(
594 msg.as_deref()
595 .unwrap_or_default()
596 .contains("provider.openai.hosted_shell.skills[0].skill_id")
597 );
598 }
599
600 #[test]
601 fn hosted_shell_warning_for_empty_inline_bundle() {
602 let mut cfg = VTCodeConfig::default();
603 cfg.provider.openai.hosted_shell.enabled = true;
604 cfg.provider.openai.hosted_shell.skills =
605 vec![vtcode_config::core::OpenAIHostedSkill::Inline {
606 bundle_b64: " ".to_string(),
607 sha256: None,
608 }];
609
610 let msg = check_openai_hosted_shell_compat(&cfg, "gpt-5", "openai");
611
612 assert!(
613 msg.as_deref()
614 .unwrap_or_default()
615 .contains("provider.openai.hosted_shell.skills[0].bundle_b64")
616 );
617 }
618
619 #[test]
620 fn hosted_shell_warning_for_empty_allowlist_domains() {
621 let mut cfg = VTCodeConfig::default();
622 cfg.provider.openai.hosted_shell.enabled = true;
623 cfg.provider.openai.hosted_shell.network_policy.policy_type =
624 vtcode_config::core::OpenAIHostedShellNetworkPolicyType::Allowlist;
625
626 let msg = check_openai_hosted_shell_compat(&cfg, "gpt-5", "openai");
627
628 assert!(
629 msg.as_deref()
630 .unwrap_or_default()
631 .contains("network_policy.allowed_domains")
632 );
633 }
634
635 #[test]
636 fn hosted_shell_warning_for_secret_domain_outside_allowlist() {
637 let mut cfg = VTCodeConfig::default();
638 cfg.provider.openai.hosted_shell.enabled = true;
639 cfg.provider.openai.hosted_shell.network_policy.policy_type =
640 vtcode_config::core::OpenAIHostedShellNetworkPolicyType::Allowlist;
641 cfg.provider
642 .openai
643 .hosted_shell
644 .network_policy
645 .allowed_domains = vec!["pypi.org".to_string()];
646 cfg.provider
647 .openai
648 .hosted_shell
649 .network_policy
650 .domain_secrets = vec![vtcode_config::core::OpenAIHostedShellDomainSecret {
651 domain: "httpbin.org".to_string(),
652 name: "API_KEY".to_string(),
653 value: "secret".to_string(),
654 }];
655
656 let msg = check_openai_hosted_shell_compat(&cfg, "gpt-5", "openai");
657
658 assert!(
659 msg.as_deref()
660 .unwrap_or_default()
661 .contains("domain_secrets[0].domain")
662 );
663 }
664
665 #[test]
666 fn validate_surfaces_hosted_shell_warning() {
667 let dir = create_test_models_db();
668 let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
669 let mut config = VTCodeConfig::default();
670 config.agent.provider = "openai".to_owned();
671 config.agent.default_model = "gpt-5".to_owned();
672 config.provider.openai.hosted_shell.enabled = true;
673 config.provider.openai.hosted_shell.skills =
674 vec![vtcode_config::core::OpenAIHostedSkill::SkillReference {
675 skill_id: " ".to_string(),
676 version: vtcode_config::core::OpenAIHostedSkillVersion::default(),
677 }];
678
679 let result = validator.validate(&config).unwrap();
680
681 assert!(result.warnings.iter().any(|warning| {
682 warning.contains("provider.openai.hosted_shell.skills[0].skill_id")
683 }));
684 }
685}