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_with_mode;
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;
16use vtcode_config::PromptCacheRetention;
17
18#[derive(Debug, Clone)]
20enum ModelsDatabase {
21 Generated,
22 File { providers: HashMap<String, ProviderModels> },
23}
24
25#[derive(Debug, Clone)]
26struct ProviderModels {
27 models: HashMap<String, ModelInfo>,
28}
29
30#[derive(Debug, Clone)]
31struct ModelInfo {
32 context_window: usize,
33}
34
35impl ModelsDatabase {
36 pub fn generated() -> Self {
37 Self::Generated
38 }
39
40 #[must_use = "models database load failure is silently ignored"]
42 pub fn from_file(path: &Path) -> Result<Self> {
43 let content = read_file_with_context_sync(path, "models database")?;
44
45 let json: JsonValue = serde_json::from_str(&content).context("Failed to parse models database JSON")?;
46
47 let mut providers = HashMap::new();
48
49 if let Some(obj) = json.as_object() {
50 for (provider_id, provider_data) in obj {
51 if let Some(provider_obj) = provider_data.as_object() {
52 let mut models = HashMap::new();
53
54 if let Some(models_obj) = provider_obj.get("models").and_then(|v| v.as_object()) {
55 for (model_id, model_data) in models_obj {
56 let context_window =
57 model_data.get("context").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
58
59 models.insert(model_id.clone(), ModelInfo { context_window });
60 }
61 }
62
63 providers.insert(provider_id.clone(), ProviderModels { models });
64 }
65 }
66 }
67
68 Ok(Self::File { providers })
69 }
70
71 pub fn model_exists(&self, provider: &str, model: &str) -> bool {
73 match self {
74 Self::Generated => supported_models_for_provider(provider)
75 .map(|models| models.contains(&model))
76 .unwrap_or(false),
77 Self::File { providers } => providers.get(provider).map(|p| p.models.contains_key(model)).unwrap_or(false),
78 }
79 }
80
81 pub fn get_context_window(&self, provider: &str, model: &str) -> Option<usize> {
83 match self {
84 Self::Generated => model_catalog_entry(provider, model)
85 .map(|entry| entry.context_window)
86 .filter(|context_window| *context_window > 0),
87 Self::File { providers } => providers
88 .get(provider)
89 .and_then(|p| p.models.get(model))
90 .map(|m| m.context_window),
91 }
92 }
93}
94
95pub struct ConfigValidator {
97 models_db: ModelsDatabase,
98}
99
100impl ConfigValidator {
101 pub fn generated() -> Self {
103 Self { models_db: ModelsDatabase::generated() }
104 }
105
106 pub fn new(models_db_path: &Path) -> Result<Self> {
108 Ok(Self {
109 models_db: ModelsDatabase::from_file(models_db_path)?,
110 })
111 }
112
113 #[must_use = "validation errors go unnoticed"]
115 pub fn validate(&self, config: &VTCodeConfig) -> Result<ValidationResult> {
116 let mut result = ValidationResult::default();
117 let managed_auth_provider = configured_managed_auth_provider(config);
118 let custom_provider = config.custom_provider(&config.agent.provider);
119 let is_custom_provider = custom_provider.is_some();
120 let is_codex_provider = config.agent.provider.eq_ignore_ascii_case("codex");
121 let is_openai_provider = config.agent.provider.eq_ignore_ascii_case("openai");
125
126 if let Some(custom) = custom_provider {
128 if !custom
129 .effective_models()
130 .iter()
131 .any(|model| model == &config.agent.default_model)
132 {
133 result.errors.push(format!(
134 "Model '{}' not found for custom provider '{}'.",
135 config.agent.default_model, custom.display_name
136 ));
137 }
138 } else if !is_codex_provider
139 && !is_managed_auth_model(managed_auth_provider, &config.agent.default_model)
140 && !self.models_db.model_exists(&config.agent.provider, &config.agent.default_model)
141 {
142 if config.agent.provider.eq_ignore_ascii_case("openai")
144 && let Some((replacement, reason)) =
145 vtcode_config::constants::models::openai::deprecated_model_replacement(&config.agent.default_model)
146 {
147 result.errors.push(format!(
148 "{reason}. Update your config to use '{replacement}' or run /model to pick a current model."
149 ));
150 } else {
151 result.errors.push(format!(
152 "Model '{}' not found for provider '{}'. Check docs/models.json.",
153 config.agent.default_model, config.agent.provider
154 ));
155 }
156 }
157
158 if !is_custom_provider
162 && !is_codex_provider
163 && !is_openai_provider
164 && managed_auth_provider.is_none()
165 && let Err(e) = get_api_key_with_mode(
166 &config.agent.provider,
167 &ApiKeySources::default(),
168 config.agent.credential_storage_mode,
169 )
170 {
171 result.errors.push(format!(
172 "API key not found for provider '{}': {}. Set {} environment variable.",
173 config.agent.provider,
174 e,
175 config.agent.provider.to_uppercase()
176 ));
177 }
178
179 let model_context_window = if let Some(custom) = custom_provider {
181 custom
182 .effective_models()
183 .iter()
184 .any(|model| model == &config.agent.default_model)
185 .then(|| custom.resolved_profile(&config.agent.default_model).context_window)
186 .flatten()
187 } else {
188 self.models_db
189 .get_context_window(&config.agent.provider, &config.agent.default_model)
190 };
191 if let Some(max_tokens) = model_context_window {
192 let configured_context = config.context.max_context_tokens;
193 if configured_context > 0 && configured_context > max_tokens {
194 result.warnings.push(format!(
195 "Configured session context safety budget ({} tokens) exceeds provider limit ({} tokens) for {} on {}",
196 configured_context, max_tokens, config.agent.default_model, config.agent.provider
197 ));
198 }
199 }
200
201 if let Some(message) =
202 check_openai_hosted_shell_compat(config, &config.agent.default_model, &config.agent.provider)
203 {
204 result.warnings.push(message);
205 }
206
207 if let Some(message) = config
208 .provider
209 .anthropic
210 .fallbacks
211 .validation_error("provider.anthropic.fallbacks")
212 {
213 result
214 .warnings
215 .push(format!("{message} VT Code will not request server-side fallbacks until this is corrected."));
216 }
217
218 if let Ok(cwd) = std::env::current_dir() {
220 if !cwd.exists() {
222 result.warnings.push("Current working directory does not exist".to_owned());
223 }
224 }
225
226 Ok(result)
227 }
228
229 pub fn quick_validate(&self, config: &VTCodeConfig) -> Result<()> {
231 let managed_auth_provider = configured_managed_auth_provider(config);
232 let custom_provider = config.custom_provider(&config.agent.provider);
233 let is_custom_provider = custom_provider.is_some();
234 let is_codex_provider = config.agent.provider.eq_ignore_ascii_case("codex");
235 let is_openai_provider = config.agent.provider.eq_ignore_ascii_case("openai");
236
237 let model_is_valid = custom_provider.is_some_and(|custom| {
239 custom
240 .effective_models()
241 .iter()
242 .any(|model| model == &config.agent.default_model)
243 }) || (!is_custom_provider
244 && (is_codex_provider
245 || is_managed_auth_model(managed_auth_provider, &config.agent.default_model)
246 || self.models_db.model_exists(&config.agent.provider, &config.agent.default_model)));
247 if !model_is_valid {
248 bail!(
249 "Model '{}' not found for provider '{}'. Check docs/models.json.",
250 config.agent.default_model,
251 config.agent.provider
252 );
253 }
254
255 if !is_custom_provider && !is_codex_provider && !is_openai_provider && managed_auth_provider.is_none() {
257 get_api_key_with_mode(
258 &config.agent.provider,
259 &ApiKeySources::default(),
260 config.agent.credential_storage_mode,
261 )
262 .with_context(|| {
263 format!(
264 "API key not found for provider '{}'. Set {} environment variable.",
265 config.agent.provider,
266 config.agent.provider.to_uppercase()
267 )
268 })?;
269 }
270
271 Ok(())
272 }
273}
274
275fn configured_managed_auth_provider(config: &VTCodeConfig) -> Option<Provider> {
276 config
277 .agent
278 .provider
279 .parse::<Provider>()
280 .ok()
281 .filter(|provider| provider.uses_managed_auth())
282}
283
284fn is_managed_auth_model(provider: Option<Provider>, model: &str) -> bool {
285 matches!(provider, Some(Provider::Copilot)) && !model.trim().is_empty()
286}
287
288#[derive(Debug, Default, Clone)]
290pub struct ValidationResult {
291 pub errors: Vec<String>,
292 pub warnings: Vec<String>,
293}
294
295pub fn check_prompt_cache_retention_compat(config: &VTCodeConfig, model: &str, provider: &str) -> Option<String> {
296 if !provider.eq_ignore_ascii_case("openai") {
297 return None;
298 }
299
300 if let Some(retention) = config.prompt_cache.providers.openai.prompt_cache_retention {
301 if retention == PromptCacheRetention::Unknown {
302 return Some("prompt_cache_retention must be one of: in_memory, 24h".to_string());
303 }
304 if !RESPONSES_API_MODELS.contains(&model) {
305 return Some(format!(
306 "`prompt_cache_retention` is set but the selected model '{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."
307 ));
308 }
309 }
310
311 None
312}
313
314pub fn check_openai_hosted_shell_compat(config: &VTCodeConfig, model: &str, provider: &str) -> Option<String> {
315 if !provider.eq_ignore_ascii_case("openai") {
316 return None;
317 }
318
319 let hosted_shell = &config.provider.openai.hosted_shell;
320 if !hosted_shell.enabled {
321 return None;
322 }
323
324 if !RESPONSES_API_MODELS.contains(&model) {
325 return Some(format!(
326 "`provider.openai.hosted_shell.enabled` is set but the selected model '{model}' does not use the OpenAI Responses API. VT Code will ignore hosted shell and keep the local shell tool for this model."
327 ));
328 }
329
330 if !hosted_shell.has_valid_reference_target() {
331 return Some(
332 "`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."
333 .to_string(),
334 );
335 }
336
337 if hosted_shell.uses_container_reference()
338 && (!hosted_shell.file_ids.is_empty()
339 || !hosted_shell.skills.is_empty()
340 || hosted_shell.network_policy.is_allowlist())
341 {
342 return Some(
343 "`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."
344 .to_string(),
345 );
346 }
347
348 if let Some(message) = hosted_shell.first_invalid_skill_message() {
349 return Some(format!("{message} VT Code will ignore hosted shell until the mounted skills are corrected."));
350 }
351
352 if let Some(message) = hosted_shell.first_invalid_network_policy_message() {
353 return Some(format!(
354 "{message} VT Code will ignore hosted shell until the hosted shell network policy is corrected."
355 ));
356 }
357
358 None
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use serial_test::serial;
365 use std::fs;
366 use tempfile::TempDir;
367
368 fn create_test_models_db() -> TempDir {
369 let dir = TempDir::new().unwrap();
370 let models_json = r#"{
371 "google": {
372 "id": "google",
373 "default_model": "gemini-3-flash-preview",
374 "models": {
375 "gemini-3-flash-preview": {
376 "context": 1048576
377 }
378 }
379 },
380 "openai": {
381 "id": "openai",
382 "default_model": "gpt-5",
383 "models": {
384 "gpt-5": {
385 "context": 128000
386 }
387 }
388 }
389}"#;
390 fs::write(dir.path().join("models.json"), models_json).unwrap();
391 dir
392 }
393
394 #[test]
395 fn loads_models_database() {
396 let dir = create_test_models_db();
397 let db = ModelsDatabase::from_file(&dir.path().join("models.json")).unwrap();
398
399 assert!(db.model_exists("google", "gemini-3-flash-preview"));
400 assert!(db.model_exists("openai", "gpt-5"));
401 assert!(!db.model_exists("google", "nonexistent"));
402 }
403
404 #[test]
405 fn gets_context_window() {
406 let dir = create_test_models_db();
407 let db = ModelsDatabase::from_file(&dir.path().join("models.json")).unwrap();
408
409 assert_eq!(db.get_context_window("google", "gemini-3-flash-preview"), Some(1048576));
410 assert_eq!(db.get_context_window("openai", "gpt-5"), Some(128000));
411 assert_eq!(db.get_context_window("google", "nonexistent"), None);
412 }
413
414 #[test]
415 fn validates_model_exists() {
416 let dir = create_test_models_db();
417 let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
418 let mut config = VTCodeConfig::default();
419 config.agent.provider = "google".to_owned();
420 config.agent.default_model = "gemini-3-flash-preview".to_owned();
421
422 let result = validator.validate(&config).unwrap();
423 assert!(
425 !result
426 .errors
427 .iter()
428 .any(|e| { e.contains("Model 'gemini-3-flash-preview' not found for provider 'google'") })
429 );
430 }
431
432 #[test]
433 fn custom_provider_skips_builtin_model_catalog_checks() {
434 let dir = create_test_models_db();
435 let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
436 let mut config = VTCodeConfig::default();
437 config.agent.provider = "mycorp".to_owned();
438 config.agent.default_model = "totally-custom-model".to_owned();
439 config.custom_providers.push(vtcode_config::core::CustomProviderConfig {
440 name: "mycorp".to_string(),
441 display_name: "MyCorporateName".to_string(),
442 base_url: "https://llm.example/v1".to_string(),
443 context_window: None,
444 api_key_env: "MYCORP_API_KEY".to_string(),
445 auth: None,
446 model: "totally-custom-model".to_string(),
447 models: Vec::new(),
448 ..vtcode_config::core::CustomProviderConfig::default()
449 });
450
451 let result = validator.validate(&config).unwrap();
452
453 assert!(result.errors.is_empty());
454 }
455
456 #[test]
457 fn codex_provider_skips_builtin_model_and_api_key_checks() {
458 let dir = create_test_models_db();
459 let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
460 let mut config = VTCodeConfig::default();
461 config.agent.provider = "codex".to_owned();
462 config.agent.default_model = "managed-by-codex".to_owned();
463
464 let result = validator.validate(&config).unwrap();
465
466 assert!(result.errors.is_empty());
467 }
468
469 #[test]
470 fn copilot_managed_auth_model_accepts_live_raw_ids() {
471 assert!(is_managed_auth_model(Some(Provider::Copilot), "gpt-5-codex"));
472 assert!(!is_managed_auth_model(Some(Provider::Copilot), " "));
473 }
474
475 #[test]
476 fn detects_missing_model() {
477 let dir = create_test_models_db();
478 let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
479 let mut config = VTCodeConfig::default();
480 config.agent.provider = "google".to_owned();
481 config.agent.default_model = "nonexistent-model".to_owned();
482
483 let result = validator.validate(&config).unwrap();
484 assert!(result.errors.iter().any(|e| e.contains("not found")));
485 }
486
487 #[test]
488 fn detects_context_window_exceeded() {
489 let dir = create_test_models_db();
490 let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
491 let mut config = VTCodeConfig::default();
492 config.agent.provider = "google".to_owned();
493 config.agent.default_model = "gemini-3-flash-preview".to_owned();
494 config.context.max_context_tokens = 2000000; let result = validator.validate(&config).unwrap();
497 assert!(result.warnings.iter().any(|w| w.contains("exceeds")));
498 }
499
500 #[test]
501 fn retention_warning_for_non_responses_model() {
502 let mut cfg = VTCodeConfig::default();
503 cfg.prompt_cache.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::H24);
504 let msg = check_prompt_cache_retention_compat(&cfg, "gpt-oss-20b", "openai");
505 assert!(msg.is_some());
506 }
507
508 #[test]
509 fn retention_ok_for_responses_model() {
510 let mut cfg = VTCodeConfig::default();
511 cfg.prompt_cache.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::H24);
512 let msg =
513 check_prompt_cache_retention_compat(&cfg, crate::config::constants::models::openai::GPT_5_6, "openai");
514 assert!(msg.is_none());
515 }
516
517 #[test]
518 fn retention_ok_for_gpt_alias() {
519 let mut cfg = VTCodeConfig::default();
520 cfg.prompt_cache.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::H24);
521 let msg = check_prompt_cache_retention_compat(&cfg, crate::config::constants::models::openai::GPT, "openai");
522 assert!(msg.is_none());
523 }
524
525 #[test]
526 fn hosted_shell_warning_for_non_responses_model() {
527 let mut cfg = VTCodeConfig::default();
528 cfg.provider.openai.hosted_shell.enabled = true;
529
530 let msg = check_openai_hosted_shell_compat(&cfg, "gpt-oss-20b", "openai");
531 assert!(msg.is_some());
532 }
533
534 #[test]
535 fn hosted_shell_warning_for_missing_container_reference_id() {
536 let mut cfg = VTCodeConfig::default();
537 cfg.provider.openai.hosted_shell.enabled = true;
538 cfg.provider.openai.hosted_shell.environment =
539 crate::config::core::OpenAIHostedShellEnvironment::ContainerReference;
540
541 let msg = check_openai_hosted_shell_compat(&cfg, crate::config::constants::models::openai::GPT_5, "openai");
542 assert!(msg.is_some());
543 }
544
545 #[test]
546 fn hosted_shell_warning_for_auto_only_fields_on_container_reference() {
547 let mut cfg = VTCodeConfig::default();
548 cfg.provider.openai.hosted_shell.enabled = true;
549 cfg.provider.openai.hosted_shell.environment =
550 crate::config::core::OpenAIHostedShellEnvironment::ContainerReference;
551 cfg.provider.openai.hosted_shell.container_id = Some("cntr_123".to_string());
552 cfg.provider.openai.hosted_shell.file_ids = vec!["file_123".to_string()];
553 cfg.provider.openai.hosted_shell.network_policy.policy_type =
554 vtcode_config::core::OpenAIHostedShellNetworkPolicyType::Allowlist;
555 cfg.provider.openai.hosted_shell.network_policy.allowed_domains = vec!["httpbin.org".to_string()];
556
557 let msg = check_openai_hosted_shell_compat(&cfg, crate::config::constants::models::openai::GPT_5, "openai");
558 assert!(msg.is_some());
559 }
560
561 #[test]
562 fn hosted_shell_ok_for_valid_responses_config() {
563 let mut cfg = VTCodeConfig::default();
564 cfg.provider.openai.hosted_shell.enabled = true;
565
566 let msg = check_openai_hosted_shell_compat(&cfg, crate::config::constants::models::openai::GPT_5_6, "openai");
567 assert!(msg.is_none());
568 }
569
570 #[test]
571 fn hosted_shell_ok_for_gpt_alias() {
572 let mut cfg = VTCodeConfig::default();
573 cfg.provider.openai.hosted_shell.enabled = true;
574
575 let msg = check_openai_hosted_shell_compat(&cfg, crate::config::constants::models::openai::GPT, "openai");
576 assert!(msg.is_none());
577 }
578
579 #[test]
580 fn hosted_shell_warning_for_empty_skill_reference_id() {
581 let mut cfg = VTCodeConfig::default();
582 cfg.provider.openai.hosted_shell.enabled = true;
583 cfg.provider.openai.hosted_shell.skills = vec![vtcode_config::core::OpenAIHostedSkill::SkillReference {
584 skill_id: " ".to_string(),
585 version: vtcode_config::core::OpenAIHostedSkillVersion::default(),
586 }];
587
588 let msg = check_openai_hosted_shell_compat(&cfg, "gpt-5.6-sol", "openai");
589
590 assert!(
591 msg.as_deref()
592 .unwrap_or_default()
593 .contains("provider.openai.hosted_shell.skills[0].skill_id")
594 );
595 }
596
597 #[test]
598 fn hosted_shell_warning_for_empty_inline_bundle() {
599 let mut cfg = VTCodeConfig::default();
600 cfg.provider.openai.hosted_shell.enabled = true;
601 cfg.provider.openai.hosted_shell.skills =
602 vec![vtcode_config::core::OpenAIHostedSkill::Inline { bundle_b64: " ".to_string(), sha256: None }];
603
604 let msg = check_openai_hosted_shell_compat(&cfg, "gpt-5.6-sol", "openai");
605
606 assert!(
607 msg.as_deref()
608 .unwrap_or_default()
609 .contains("provider.openai.hosted_shell.skills[0].bundle_b64")
610 );
611 }
612
613 #[test]
614 fn hosted_shell_warning_for_empty_allowlist_domains() {
615 let mut cfg = VTCodeConfig::default();
616 cfg.provider.openai.hosted_shell.enabled = true;
617 cfg.provider.openai.hosted_shell.network_policy.policy_type =
618 vtcode_config::core::OpenAIHostedShellNetworkPolicyType::Allowlist;
619
620 let msg = check_openai_hosted_shell_compat(&cfg, "gpt-5.6-sol", "openai");
621
622 assert!(msg.as_deref().unwrap_or_default().contains("network_policy.allowed_domains"));
623 }
624
625 #[test]
626 fn hosted_shell_warning_for_secret_domain_outside_allowlist() {
627 let mut cfg = VTCodeConfig::default();
628 cfg.provider.openai.hosted_shell.enabled = true;
629 cfg.provider.openai.hosted_shell.network_policy.policy_type =
630 vtcode_config::core::OpenAIHostedShellNetworkPolicyType::Allowlist;
631 cfg.provider.openai.hosted_shell.network_policy.allowed_domains = vec!["pypi.org".to_string()];
632 cfg.provider.openai.hosted_shell.network_policy.domain_secrets =
633 vec![vtcode_config::core::OpenAIHostedShellDomainSecret {
634 domain: "httpbin.org".to_string(),
635 name: "API_KEY".to_string(),
636 value: "secret".to_string(),
637 }];
638
639 let msg = check_openai_hosted_shell_compat(&cfg, "gpt-5.6-sol", "openai");
640
641 assert!(msg.as_deref().unwrap_or_default().contains("domain_secrets[0].domain"));
642 }
643
644 #[test]
645 fn validate_surfaces_hosted_shell_warning() {
646 let dir = create_test_models_db();
647 let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
648 let mut config = VTCodeConfig::default();
649 config.agent.provider = "openai".to_owned();
650 config.agent.default_model = "gpt-5.6".to_owned();
651 config.provider.openai.hosted_shell.enabled = true;
652 config.provider.openai.hosted_shell.skills = vec![vtcode_config::core::OpenAIHostedSkill::SkillReference {
653 skill_id: " ".to_string(),
654 version: vtcode_config::core::OpenAIHostedSkillVersion::default(),
655 }];
656
657 let result = validator.validate(&config).unwrap();
658
659 assert!(
660 result
661 .warnings
662 .iter()
663 .any(|warning| { warning.contains("provider.openai.hosted_shell.skills[0].skill_id") })
664 );
665 }
666
667 #[test]
668 fn validate_surfaces_invalid_anthropic_fallbacks_warning() {
669 let dir = create_test_models_db();
670 let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
671 let mut config = VTCodeConfig::default();
672 let target = vtcode_config::core::AnthropicFallbackTarget {
673 model: "claude-opus-4-8".to_string(),
674 max_tokens: None,
675 };
676 config.provider.anthropic.fallbacks =
677 vtcode_config::core::AnthropicFallbacks::Models(vec![target.clone(), target]);
678
679 let result = validator.validate(&config).unwrap();
680
681 assert!(
682 result
683 .warnings
684 .iter()
685 .any(|warning| warning.contains("provider.anthropic.fallbacks") && warning.contains("distinct"))
686 );
687 }
688
689 #[test]
692 fn openai_without_api_key_does_not_emit_static_warning() {
693 let dir = create_test_models_db();
697 let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
698 let mut config = VTCodeConfig::default();
699 config.agent.provider = "openai".to_owned();
700 config.agent.default_model = "gpt-5".to_owned();
701
702 let result = validator.validate(&config).unwrap();
703 assert!(
704 !result
705 .errors
706 .iter()
707 .any(|e| e.contains("API key not found for provider 'openai'")),
708 "OpenAI should not emit a static API-key warning: errors={:?}",
709 result.errors
710 );
711 }
712
713 #[test]
714 fn openai_quick_validate_without_api_key_does_not_fail() {
715 let dir = create_test_models_db();
717 let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
718 let mut config = VTCodeConfig::default();
719 config.agent.provider = "openai".to_owned();
720 config.agent.default_model = "gpt-5".to_owned();
721
722 let result = validator.quick_validate(&config);
724 assert!(result.is_ok(), "quick_validate should not fail for OpenAI without API key: {result:?}");
725 }
726
727 #[test]
728 #[serial]
729 fn non_openai_provider_still_requires_api_key() {
730 let dir = TempDir::new().unwrap();
734 let models_json = r#"{
735 "ztestprov": {
736 "id": "ztestprov",
737 "default_model": "zmodel",
738 "models": { "zmodel": { "context": 128000 } }
739 }
740 }"#;
741 fs::write(dir.path().join("models.json"), models_json).unwrap();
742 let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
743 let mut config = VTCodeConfig::default();
744 config.agent.provider = "ztestprov".to_owned();
745 config.agent.default_model = "zmodel".to_owned();
746 let prev = std::env::var("ZTESTPROV_API_KEY").ok();
748 vtcode_commons::env_lock::set_var("ZTESTPROV_API_KEY", "");
749
750 let result = validator.validate(&config).unwrap();
751 vtcode_commons::env_lock::lock().restore_var("ZTESTPROV_API_KEY", prev.as_deref());
752
753 assert!(
754 result
755 .errors
756 .iter()
757 .any(|e| e.contains("API key not found for provider 'ztestprov'")),
758 "non-OpenAI provider should still require an API key: errors={:?}",
759 result.errors
760 );
761 }
762
763 #[test]
764 fn deprecated_openai_model_diagnostic_is_present() {
765 let dir = create_test_models_db();
768 let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
769 let mut config = VTCodeConfig::default();
770 config.agent.provider = "openai".to_owned();
771 config.agent.default_model = "o3".to_owned(); let result = validator.validate(&config).unwrap();
774 assert!(
775 result
776 .errors
777 .iter()
778 .any(|e| e.contains("gpt-5.6-sol") || e.contains("deprecated") || e.contains("Update your config")),
779 "deprecated OpenAI model should produce a replacement diagnostic: errors={:?}",
780 result.errors
781 );
782 }
783}