1use std::borrow::Cow;
15
16use crate::error_category::ErrorCategory;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum MisconfigurationKind {
21 Authentication,
22 Model,
23 Provider,
24 BaseUrl,
25 ApiKeyEnv,
26 ConfigFile,
27 Mcp,
28 SamplingParams,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ConfigGuidance {
37 pub kind: MisconfigurationKind,
39 pub setting: &'static str,
41 pub location: &'static str,
43 pub fix: Cow<'static, str>,
45}
46
47impl ConfigGuidance {
48 #[must_use]
50 pub fn user_message(&self) -> String {
51 format!(
52 "Check settings/config first ({} in {}): {} Correct the configuration before retrying.",
53 self.setting, self.location, self.fix
54 )
55 }
56}
57
58#[must_use]
64pub fn detect_misconfiguration(category: ErrorCategory, message: &str) -> Option<ConfigGuidance> {
65 if matches!(category, ErrorCategory::Authentication) {
67 return Some(auth_guidance());
68 }
69
70 let msg = message.to_ascii_lowercase();
71
72 if contains_any(
74 &msg,
75 &[
76 "vtcode.toml",
77 "config invalid",
78 "config missing",
79 "config parse",
80 "configparse",
81 "failed to parse config",
82 "malformed config",
83 "invalid custom_providers",
84 "invalid provider_overrides",
85 "repository-controlled",
86 "protected field",
87 ],
88 ) || (msg.contains("workspace_lifecycle_hooks")
89 && contains_any(&msg, &["invalid", "missing", "not allowed", "config"]))
90 {
91 return Some(ConfigGuidance {
92 kind: MisconfigurationKind::ConfigFile,
93 setting: "vtcode.toml",
94 location: "workspace / user / system config layers",
95 fix: Cow::Borrowed(
96 "Invalid config file. Validate vtcode.toml syntax and fields, keep `custom_providers` and `provider_overrides.*.base_url`/`api_key_env` out of repository layers, then retry.",
97 ),
98 });
99 }
100
101 if contains_any(
104 &msg,
105 &[
106 "invalid endpoint",
107 "invalid base url",
108 "unsupported protocol",
109 "endpoint must",
110 "endpoint is invalid",
111 ],
112 ) || (msg.contains("base_url")
113 && contains_any(
114 &msg,
115 &[
116 "invalid",
117 "empty",
118 "missing",
119 "must",
120 "cannot",
121 "malformed",
122 "parse",
123 "scheme",
124 ],
125 ))
126 {
127 return Some(ConfigGuidance {
128 kind: MisconfigurationKind::BaseUrl,
129 setting: "provider_overrides.*.base_url / custom_providers.*.base_url",
130 location: "system / user / explicit config file only",
131 fix: Cow::Borrowed(
132 "Invalid endpoint. Check `base_url` is non-empty, uses a supported URL scheme, and is reachable, then retry.",
133 ),
134 });
135 }
136
137 if contains_any(
139 &msg,
140 &[
141 "invalid `api_key_env`",
142 "invalid api_key_env",
143 "api key env",
144 "api_key_env must",
145 "missing api key",
146 "no api key",
147 "api key not found",
148 "api key is not set",
149 "api key not configured",
150 "api key is required",
151 "api key environment variable must",
152 "missing mcp api key",
153 "api-key env",
154 ],
155 ) {
156 return Some(ConfigGuidance {
157 kind: MisconfigurationKind::ApiKeyEnv,
158 setting: "api_key_env",
159 location: "vtcode.toml + environment",
160 fix: Cow::Borrowed(
161 "Invalid API-key config. Check `api_key_env` names a valid exported env var (or use `/secret`), then retry.",
162 ),
163 });
164 }
165
166 if contains_any(
168 &msg,
169 &[
170 "unknown provider",
171 "unknown_provider",
172 "invalid provider",
173 "invalid_provider",
174 "unsupported provider",
175 "unsupported_provider",
176 "provider not found",
177 "provider_not_found",
178 ],
179 ) || (contains_any(&msg, &["agent.provider", "custom_providers", "provider_overrides"])
180 && contains_any(&msg, &["invalid", "unknown", "unsupported", "not found", "missing", "empty"]))
181 {
182 return Some(ConfigGuidance {
183 kind: MisconfigurationKind::Provider,
184 setting: "agent.provider",
185 location: "vtcode.toml",
186 fix: Cow::Borrowed(
187 "Unknown/invalid provider. Check `agent.provider` and `custom_providers` in vtcode.toml, then retry.",
188 ),
189 });
190 }
191
192 if contains_any(
195 &msg,
196 &[
197 "unknown model",
198 "unknown_model",
199 "invalid model",
200 "invalid_model",
201 "model not found",
202 "model_not_found",
203 "model not available",
204 "model_not_available",
205 "unsupported model",
206 "unsupported_model",
207 "model does not exist",
208 "model doesn't exist",
209 "model is not supported",
210 "model not supported",
211 "catalog-missing",
212 "catalog missing",
213 ],
214 ) || (msg.contains("model ")
215 && contains_any(&msg, &[" is not supported", " not supported by", " not supported with"]))
216 || (contains_any(&msg, &["model id", "model_id", "agent.model", "agent.default_model"])
217 && contains_any(
218 &msg,
219 &[
220 "invalid",
221 "unknown",
222 "not found",
223 "not available",
224 "unsupported",
225 "missing",
226 "empty",
227 ],
228 ))
229 {
230 return Some(ConfigGuidance {
231 kind: MisconfigurationKind::Model,
232 setting: "agent.default_model",
233 location: "vtcode.toml / `/model` picker",
234 fix: Cow::Borrowed(
235 "Unknown/invalid model. Check `agent.default_model` in vtcode.toml or run `/model` to pick a supported ModelId, then retry.",
236 ),
237 });
238 }
239
240 if contains_any(&msg, &["mcp config", "https when enabled"])
242 || (contains_any(&msg, &["mcp server", "mcp url", "webmcp"])
243 && contains_any(&msg, &["invalid", "missing", "not allowed", "must", "unsupported"]))
244 || (msg.contains("mcp")
245 && contains_any(&msg, &["origin", "roots", "https", "url", "config"])
246 && contains_any(&msg, &["invalid", "missing", "not allowed", "must", "unsupported"]))
247 {
248 return Some(ConfigGuidance {
249 kind: MisconfigurationKind::Mcp,
250 setting: "mcp / webmcp",
251 location: "vtcode.toml",
252 fix: Cow::Borrowed(
253 "MCP misconfiguration. Check `mcp`/`webmcp` URLs are HTTPS, origins and roots are allowed, then retry.",
254 ),
255 });
256 }
257
258 if contains_any(
261 &msg,
262 &[
263 "temperature",
264 "top_p",
265 "top_k",
266 "reasoning_effort",
267 "max_tokens",
268 "penalt",
269 ],
270 ) && contains_any(&msg, &["range", "out of range", "invalid", "must be", "validation"])
271 {
272 return Some(ConfigGuidance {
273 kind: MisconfigurationKind::SamplingParams,
274 setting: "sampling params",
275 location: "vtcode.toml custom_providers / profile",
276 fix: Cow::Borrowed(
277 "Invalid sampling param. Check temperature/top_p/top_k/penalties/max_tokens/reasoning_effort ranges in the provider profile, then retry.",
278 ),
279 });
280 }
281
282 if contains_any(
285 &msg,
286 &[
287 "invalid api key",
288 "invalid_api_key",
289 "authentication failed",
290 "authentication_failed",
291 "unauthorized",
292 "invalid credentials",
293 "key was rejected",
294 "re-authenticate",
295 "/secret add",
296 "/login ",
297 ],
298 ) {
299 return Some(auth_guidance());
300 }
301
302 None
303}
304
305#[must_use]
310pub fn detect_misconfiguration_in_anyhow(error: &anyhow::Error) -> Option<ConfigGuidance> {
311 let message = format!("{error:#}");
312 detect_misconfiguration(crate::error_category::classify_anyhow_error(error), &message).or_else(|| {
313 error
314 .downcast_ref::<crate::llm::LLMError>()
315 .and_then(detect_misconfiguration_in_llm_error)
316 })
317}
318
319#[must_use]
322pub fn detect_misconfiguration_in_llm_error(error: &crate::llm::LLMError) -> Option<ConfigGuidance> {
323 let mut message = error.to_string();
324 let metadata = match error {
325 crate::llm::LLMError::Authentication { metadata, .. }
326 | crate::llm::LLMError::RateLimit { metadata }
327 | crate::llm::LLMError::InvalidRequest { metadata, .. }
328 | crate::llm::LLMError::Network { metadata, .. }
329 | crate::llm::LLMError::Provider { metadata, .. } => metadata.as_deref(),
330 };
331 if let Some(metadata) = metadata {
332 if let Some(code) = metadata.code.as_deref() {
333 message.push(' ');
334 message.push_str(code);
335 }
336 if let Some(provider_message) = metadata.message.as_deref() {
337 message.push(' ');
338 message.push_str(provider_message);
339 }
340 }
341
342 detect_misconfiguration(ErrorCategory::from(error), &message)
343}
344
345fn auth_guidance() -> ConfigGuidance {
348 ConfigGuidance {
349 kind: MisconfigurationKind::Authentication,
350 setting: "API key / credentials",
351 location: "secure storage (`vtcode secret` or `/secret`) or environment",
352 fix: Cow::Borrowed(
353 "Authentication failed. Run `vtcode secret add <provider>` (or `/secret add <provider>` in TUI) for API-key providers or `vtcode login <provider>` (or `/login <provider>` in TUI) for managed auth, verify the env var is exported, then retry.",
354 ),
355 }
356}
357
358#[inline]
360#[must_use]
361pub fn is_misconfiguration(category: ErrorCategory, message: &str) -> bool {
362 detect_misconfiguration(category, message).is_some()
363}
364
365#[inline]
366fn contains_any(message: &str, markers: &[&str]) -> bool {
367 markers.iter().any(|marker| message.contains(marker))
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn auth_category_is_always_misconfiguration() {
376 let guidance = detect_misconfiguration(ErrorCategory::Authentication, "anything").expect("auth must match");
377 assert_eq!(guidance.kind, MisconfigurationKind::Authentication);
378 assert!(guidance.user_message().contains("before retrying"));
379 }
380
381 #[test]
382 fn transient_without_markers_is_not_misconfiguration() {
383 assert!(detect_misconfiguration(ErrorCategory::Network, "connection reset by peer").is_none());
384 assert!(detect_misconfiguration(ErrorCategory::Timeout, "request timed out after 30s").is_none());
385 assert!(detect_misconfiguration(ErrorCategory::RateLimit, "429 too many requests").is_none());
386 assert!(detect_misconfiguration(ErrorCategory::ServiceUnavailable, "503 service unavailable").is_none());
387 assert!(
388 detect_misconfiguration(
389 ErrorCategory::Network,
390 "connection reset while requesting provider_overrides.openai.base_url",
391 )
392 .is_none()
393 );
394 assert!(detect_misconfiguration(ErrorCategory::Network, "mcp server connection reset by peer").is_none());
395 }
396
397 #[test]
398 fn llm_mistakes_are_not_misconfiguration() {
399 assert!(
402 detect_misconfiguration(
403 ErrorCategory::InvalidParameters,
404 "invalid patch format: missing '*** Begin Patch' marker"
405 )
406 .is_none()
407 );
408 assert!(
409 detect_misconfiguration(
410 ErrorCategory::InvalidParameters,
411 "failed to parse arguments for read_file handler: invalid type"
412 )
413 .is_none()
414 );
415 assert!(
416 detect_misconfiguration(ErrorCategory::ResourceNotFound, "no such file or directory: /tmp/missing")
417 .is_none()
418 );
419 assert!(
421 detect_misconfiguration(ErrorCategory::InvalidParameters, "unknown model 'gpt-99' in agent.model")
422 .is_some()
423 );
424 }
425
426 #[test]
427 fn model_vs_file_not_found_boundary() {
428 let model =
429 detect_misconfiguration(ErrorCategory::ResourceNotFound, "model not found: foo-bar").expect("model");
430 assert_eq!(model.kind, MisconfigurationKind::Model);
431 assert!(detect_misconfiguration(ErrorCategory::ResourceNotFound, "file not found: /tmp/x").is_none());
432
433 for message in [
434 "The 'gpt-5.4' model is not supported with this method.",
435 "The requested model does not exist",
436 "Model 'gpt-5.4' is not supported by OpenResponses provider.",
437 ] {
438 let guidance = detect_misconfiguration(ErrorCategory::InvalidParameters, message).expect("model");
439 assert_eq!(guidance.kind, MisconfigurationKind::Model);
440 }
441 }
442
443 #[test]
444 fn provider_and_config_markers() {
445 let provider = detect_misconfiguration(
446 ErrorCategory::InvalidParameters,
447 "unknown provider 'mycloud'; check agent.provider",
448 )
449 .expect("provider");
450 assert_eq!(provider.kind, MisconfigurationKind::Provider);
451
452 let config =
453 detect_misconfiguration(ErrorCategory::ExecutionError, "config invalid: vtcode.toml has unknown field")
454 .expect("config");
455 assert_eq!(config.kind, MisconfigurationKind::ConfigFile);
456 let invalid_provider = detect_misconfiguration(
457 ErrorCategory::InvalidParameters,
458 "Invalid provider_overrides configuration: provider key is empty",
459 )
460 .expect("invalid provider config");
461 assert_eq!(invalid_provider.kind, MisconfigurationKind::ConfigFile);
462 }
463
464 #[test]
465 fn base_url_and_key_env_markers() {
466 let base =
467 detect_misconfiguration(ErrorCategory::ExecutionError, "custom_providers[x]: `base_url` must not be empty")
468 .expect("base_url");
469 assert_eq!(base.kind, MisconfigurationKind::BaseUrl);
470
471 let key = detect_misconfiguration(
472 ErrorCategory::InvalidParameters,
473 "providers[openai]: invalid `api_key_env`: bad name",
474 )
475 .expect("api_key_env");
476 assert_eq!(key.kind, MisconfigurationKind::ApiKeyEnv);
477
478 let missing = detect_misconfiguration(
479 ErrorCategory::ExecutionError,
480 "API key not found for provider 'openai'. Set OPENAI_API_KEY or run /secret add openai.",
481 )
482 .expect("missing API key");
483 assert_eq!(missing.kind, MisconfigurationKind::ApiKeyEnv);
484
485 for message in [
486 "API key is required",
487 "API key environment variable must be set when auth is enabled",
488 "Missing MCP API key environment variable: MCP_TOKEN",
489 ] {
490 let guidance = detect_misconfiguration(ErrorCategory::ExecutionError, message).expect("missing API key");
491 assert_eq!(guidance.kind, MisconfigurationKind::ApiKeyEnv);
492 }
493 }
494
495 #[test]
496 fn mcp_and_sampling_boundaries() {
497 let mcp = detect_misconfiguration(
498 ErrorCategory::InvalidParameters,
499 "webmcp remote mcp url must be https when enabled",
500 )
501 .expect("mcp");
502 assert_eq!(mcp.kind, MisconfigurationKind::Mcp);
503 assert!(detect_misconfiguration(ErrorCategory::ExecutionError, "mcp tool finished").is_none());
505
506 let sampling =
507 detect_misconfiguration(ErrorCategory::InvalidParameters, "temperature out of range: must be 0..2")
508 .expect("sampling");
509 assert_eq!(sampling.kind, MisconfigurationKind::SamplingParams);
510 let max_tokens =
511 detect_misconfiguration(ErrorCategory::InvalidParameters, "max_tokens must be greater than zero")
512 .expect("max_tokens sampling");
513 assert_eq!(max_tokens.kind, MisconfigurationKind::SamplingParams);
514 assert!(
516 detect_misconfiguration(
517 ErrorCategory::ExecutionError,
518 "input token count exceeds the maximum number of tokens"
519 )
520 .is_none()
521 );
522 }
523
524 #[test]
525 fn auth_text_inside_generic_error_is_misconfiguration() {
526 let guidance = detect_misconfiguration(ErrorCategory::ExecutionError, "provider error: 401 unauthorized")
527 .expect("auth text");
528 assert_eq!(guidance.kind, MisconfigurationKind::Authentication);
529 }
530
531 #[test]
532 fn typed_llm_metadata_overrides_generic_provider_text() {
533 let error = anyhow::Error::new(crate::llm::LLMError::Provider {
534 message: "HTTP 503 Service Unavailable".to_string(),
535 metadata: Some(crate::llm::LLMErrorMetadata::new(
536 "openai",
537 Some(404),
538 Some("model_not_found".to_string()),
539 None,
540 None,
541 None,
542 Some("The requested model does not exist".to_string()),
543 )),
544 });
545
546 let guidance = detect_misconfiguration_in_anyhow(&error).expect("metadata model marker");
547 assert_eq!(guidance.kind, MisconfigurationKind::Model);
548 }
549
550 #[test]
551 fn guidance_never_echoes_secrets() {
552 let secret = concat!("sk-", "test1234567890abcdef");
553 let message = format!("Authentication failed: invalid api key {secret}");
554 let guidance = detect_misconfiguration(ErrorCategory::ExecutionError, &message).expect("auth text must match");
555 let rendered = guidance.user_message();
556 assert!(rendered.contains("before retrying"));
557 assert!(!rendered.contains(secret), "guidance must be static and never echo input secrets");
558 }
559
560 #[test]
561 fn misconfiguration_check_is_fail_closed() {
562 for category in [ErrorCategory::Authentication, ErrorCategory::InvalidParameters] {
565 let guidance = detect_misconfiguration(category, "unknown model 'x' in agent.model");
566 if category == ErrorCategory::Authentication {
567 assert!(guidance.is_some());
568 }
569 }
570 assert!(!ErrorCategory::Authentication.is_retryable());
571 }
572}