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 if let Some(metadata) = error.metadata() {
325 if let Some(code) = metadata.code.as_deref() {
326 message.push(' ');
327 message.push_str(code);
328 }
329 if let Some(provider_message) = metadata.message.as_deref() {
330 message.push(' ');
331 message.push_str(provider_message);
332 }
333 }
334
335 detect_misconfiguration(ErrorCategory::from(error), &message)
336}
337
338fn auth_guidance() -> ConfigGuidance {
341 ConfigGuidance {
342 kind: MisconfigurationKind::Authentication,
343 setting: "API key / credentials",
344 location: "secure storage (`vtcode secret` or `/secret`) or environment",
345 fix: Cow::Borrowed(
346 "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.",
347 ),
348 }
349}
350
351#[inline]
353#[must_use]
354pub fn is_misconfiguration(category: ErrorCategory, message: &str) -> bool {
355 detect_misconfiguration(category, message).is_some()
356}
357
358#[inline]
359fn contains_any(message: &str, markers: &[&str]) -> bool {
360 markers.iter().any(|marker| message.contains(marker))
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 #[test]
368 fn auth_category_is_always_misconfiguration() {
369 let guidance = detect_misconfiguration(ErrorCategory::Authentication, "anything").expect("auth must match");
370 assert_eq!(guidance.kind, MisconfigurationKind::Authentication);
371 assert!(guidance.user_message().contains("before retrying"));
372 }
373
374 #[test]
375 fn transient_without_markers_is_not_misconfiguration() {
376 assert!(detect_misconfiguration(ErrorCategory::Network, "connection reset by peer").is_none());
377 assert!(detect_misconfiguration(ErrorCategory::Timeout, "request timed out after 30s").is_none());
378 assert!(detect_misconfiguration(ErrorCategory::RateLimit, "429 too many requests").is_none());
379 assert!(detect_misconfiguration(ErrorCategory::ServiceUnavailable, "503 service unavailable").is_none());
380 assert!(
381 detect_misconfiguration(
382 ErrorCategory::Network,
383 "connection reset while requesting provider_overrides.openai.base_url",
384 )
385 .is_none()
386 );
387 assert!(detect_misconfiguration(ErrorCategory::Network, "mcp server connection reset by peer").is_none());
388 }
389
390 #[test]
391 fn llm_mistakes_are_not_misconfiguration() {
392 assert!(
395 detect_misconfiguration(
396 ErrorCategory::InvalidParameters,
397 "invalid patch format: missing '*** Begin Patch' marker"
398 )
399 .is_none()
400 );
401 assert!(
402 detect_misconfiguration(
403 ErrorCategory::InvalidParameters,
404 "failed to parse arguments for read_file handler: invalid type"
405 )
406 .is_none()
407 );
408 assert!(
409 detect_misconfiguration(ErrorCategory::ResourceNotFound, "no such file or directory: /tmp/missing")
410 .is_none()
411 );
412 assert!(
414 detect_misconfiguration(ErrorCategory::InvalidParameters, "unknown model 'gpt-99' in agent.model")
415 .is_some()
416 );
417 }
418
419 #[test]
420 fn model_vs_file_not_found_boundary() {
421 let model =
422 detect_misconfiguration(ErrorCategory::ResourceNotFound, "model not found: foo-bar").expect("model");
423 assert_eq!(model.kind, MisconfigurationKind::Model);
424 assert!(detect_misconfiguration(ErrorCategory::ResourceNotFound, "file not found: /tmp/x").is_none());
425
426 for message in [
427 "The 'gpt-5.4' model is not supported with this method.",
428 "The requested model does not exist",
429 "Model 'gpt-5.4' is not supported by OpenResponses provider.",
430 ] {
431 let guidance = detect_misconfiguration(ErrorCategory::InvalidParameters, message).expect("model");
432 assert_eq!(guidance.kind, MisconfigurationKind::Model);
433 }
434 }
435
436 #[test]
437 fn provider_and_config_markers() {
438 let provider = detect_misconfiguration(
439 ErrorCategory::InvalidParameters,
440 "unknown provider 'mycloud'; check agent.provider",
441 )
442 .expect("provider");
443 assert_eq!(provider.kind, MisconfigurationKind::Provider);
444
445 let config =
446 detect_misconfiguration(ErrorCategory::ExecutionError, "config invalid: vtcode.toml has unknown field")
447 .expect("config");
448 assert_eq!(config.kind, MisconfigurationKind::ConfigFile);
449 let invalid_provider = detect_misconfiguration(
450 ErrorCategory::InvalidParameters,
451 "Invalid provider_overrides configuration: provider key is empty",
452 )
453 .expect("invalid provider config");
454 assert_eq!(invalid_provider.kind, MisconfigurationKind::ConfigFile);
455 }
456
457 #[test]
458 fn base_url_and_key_env_markers() {
459 let base =
460 detect_misconfiguration(ErrorCategory::ExecutionError, "custom_providers[x]: `base_url` must not be empty")
461 .expect("base_url");
462 assert_eq!(base.kind, MisconfigurationKind::BaseUrl);
463
464 let key = detect_misconfiguration(
465 ErrorCategory::InvalidParameters,
466 "providers[openai]: invalid `api_key_env`: bad name",
467 )
468 .expect("api_key_env");
469 assert_eq!(key.kind, MisconfigurationKind::ApiKeyEnv);
470
471 let missing = detect_misconfiguration(
472 ErrorCategory::ExecutionError,
473 "API key not found for provider 'openai'. Set OPENAI_API_KEY or run /secret add openai.",
474 )
475 .expect("missing API key");
476 assert_eq!(missing.kind, MisconfigurationKind::ApiKeyEnv);
477
478 for message in [
479 "API key is required",
480 "API key environment variable must be set when auth is enabled",
481 "Missing MCP API key environment variable: MCP_TOKEN",
482 ] {
483 let guidance = detect_misconfiguration(ErrorCategory::ExecutionError, message).expect("missing API key");
484 assert_eq!(guidance.kind, MisconfigurationKind::ApiKeyEnv);
485 }
486 }
487
488 #[test]
489 fn mcp_and_sampling_boundaries() {
490 let mcp = detect_misconfiguration(
491 ErrorCategory::InvalidParameters,
492 "webmcp remote mcp url must be https when enabled",
493 )
494 .expect("mcp");
495 assert_eq!(mcp.kind, MisconfigurationKind::Mcp);
496 assert!(detect_misconfiguration(ErrorCategory::ExecutionError, "mcp tool finished").is_none());
498
499 let sampling =
500 detect_misconfiguration(ErrorCategory::InvalidParameters, "temperature out of range: must be 0..2")
501 .expect("sampling");
502 assert_eq!(sampling.kind, MisconfigurationKind::SamplingParams);
503 let max_tokens =
504 detect_misconfiguration(ErrorCategory::InvalidParameters, "max_tokens must be greater than zero")
505 .expect("max_tokens sampling");
506 assert_eq!(max_tokens.kind, MisconfigurationKind::SamplingParams);
507 assert!(
509 detect_misconfiguration(
510 ErrorCategory::ExecutionError,
511 "input token count exceeds the maximum number of tokens"
512 )
513 .is_none()
514 );
515 }
516
517 #[test]
518 fn auth_text_inside_generic_error_is_misconfiguration() {
519 let guidance = detect_misconfiguration(ErrorCategory::ExecutionError, "provider error: 401 unauthorized")
520 .expect("auth text");
521 assert_eq!(guidance.kind, MisconfigurationKind::Authentication);
522 }
523
524 #[test]
525 fn typed_llm_metadata_overrides_generic_provider_text() {
526 let error = anyhow::Error::new(crate::llm::LLMError::Provider {
527 message: "HTTP 503 Service Unavailable".to_string(),
528 metadata: Some(crate::llm::LLMErrorMetadata::new(
529 "openai",
530 Some(404),
531 Some("model_not_found".to_string()),
532 None,
533 None,
534 None,
535 Some("The requested model does not exist".to_string()),
536 )),
537 });
538
539 let guidance = detect_misconfiguration_in_anyhow(&error).expect("metadata model marker");
540 assert_eq!(guidance.kind, MisconfigurationKind::Model);
541 }
542
543 #[test]
544 fn guidance_never_echoes_secrets() {
545 let secret = concat!("sk-", "test1234567890abcdef");
546 let message = format!("Authentication failed: invalid api key {secret}");
547 let guidance = detect_misconfiguration(ErrorCategory::ExecutionError, &message).expect("auth text must match");
548 let rendered = guidance.user_message();
549 assert!(rendered.contains("before retrying"));
550 assert!(!rendered.contains(secret), "guidance must be static and never echo input secrets");
551 }
552
553 #[test]
554 fn misconfiguration_check_is_fail_closed() {
555 for category in [ErrorCategory::Authentication, ErrorCategory::InvalidParameters] {
558 let guidance = detect_misconfiguration(category, "unknown model 'x' in agent.model");
559 if category == ErrorCategory::Authentication {
560 assert!(guidance.is_some());
561 }
562 }
563 assert!(!ErrorCategory::Authentication.is_retryable());
564 }
565}