Skip to main content

vtcode_commons/
misconfiguration.rs

1//! Misconfiguration-first failure classification.
2//!
3//! For every runtime failure, callers must check settings/config before
4//! retrying. This module is the shared detector: given an [`ErrorCategory`]
5//! plus the raw error text, it returns actionable [`ConfigGuidance`] when the
6//! failure is caused by user misconfiguration (bad credentials, unknown
7//! model/provider, invalid `base_url`, malformed `vtcode.toml`, bad MCP or
8//! sampling params).
9//!
10//! Transient failures (network, timeout, rate-limit, 5xx) return `None` so
11//! existing retry policy applies unchanged. LLM argument mistakes (bad patch,
12//! bad tool args) also return `None` — they lack config markers.
13
14use std::borrow::Cow;
15
16use crate::error_category::ErrorCategory;
17
18/// Kind of user misconfiguration detected.
19#[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/// Actionable guidance for fixing a misconfiguration.
32///
33/// All strings are static to avoid allocation on the failure path.
34/// Callers format them with [`ConfigGuidance::user_message`].
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ConfigGuidance {
37    /// What kind of misconfiguration was detected.
38    pub kind: MisconfigurationKind,
39    /// Settings key to check (e.g. `agent.model`).
40    pub setting: &'static str,
41    /// Where the setting lives (e.g. `vtcode.toml`).
42    pub location: &'static str,
43    /// Actionable fix steps.
44    pub fix: Cow<'static, str>,
45}
46
47impl ConfigGuidance {
48    /// Render a user-facing message that directs to settings/config first.
49    #[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/// Return guidance when `message` indicates user misconfiguration.
59///
60/// `category` is used to short-circuit obvious cases (e.g. `Authentication`
61/// is always misconfiguration) and to avoid false positives on transient
62/// categories without config markers.
63#[must_use]
64pub fn detect_misconfiguration(category: ErrorCategory, message: &str) -> Option<ConfigGuidance> {
65    // Authentication failures are always user-fixable credentials issues.
66    if matches!(category, ErrorCategory::Authentication) {
67        return Some(auth_guidance());
68    }
69
70    let msg = message.to_ascii_lowercase();
71
72    // Config-file markers take priority: explicit vtcode.toml problems.
73    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    // Endpoint / base_url problems. Checked before generic provider
102    // markers because `custom_providers[x]: base_url ...` contains both.
103    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    // API-key env problems (distinct from authentication rejections above).
138    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    // Provider identity problems.
167    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    // Model identity problems. Require model-specific markers so generic
193    // "not found" file errors do not match.
194    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    // MCP / WebMCP config problems.
241    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    // Sampling param range problems. Require a param name plus a range hint
259    // so generic "max_tokens" context-capacity messages do not match.
260    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    // Generic credential-adjacent markers that did not classify as
283    // Authentication (e.g. ExecutionError wrappers around 401 text).
284    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/// Detect configuration failures from an error that may still retain its
306/// typed provider cause. Provider metadata often contains the actionable model
307/// or credential code even when the `Display` message is only a generic HTTP
308/// failure.
309#[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/// Detect configuration failures using both the primary LLM error message and
320/// provider metadata such as `model_not_found` or `invalid_api_key`.
321#[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
338/// Single source for authentication guidance (used by both the typed
339/// `Authentication` branch and generic credential-text fallback).
340fn 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/// Convenience predicate for fail-fast checks.
352#[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        // Asymmetric pair: patch/arg mistakes share InvalidParameters with
393        // real config errors but must not trigger config guidance.
394        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        // Positive side of the same boundary: model markers do trigger.
413        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        // Bare "mcp" without config context stays out.
497        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        // Context-capacity max_tokens prose must not match sampling.
508        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        // Even with generous retry budgets, misconfiguration must not retry.
556        // This is the fail-closed property: fix config first, never blind-retry.
557        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}