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    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
345/// Single source for authentication guidance (used by both the typed
346/// `Authentication` branch and generic credential-text fallback).
347fn 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/// Convenience predicate for fail-fast checks.
359#[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        // Asymmetric pair: patch/arg mistakes share InvalidParameters with
400        // real config errors but must not trigger config guidance.
401        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        // Positive side of the same boundary: model markers do trigger.
420        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        // Bare "mcp" without config context stays out.
504        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        // Context-capacity max_tokens prose must not match sampling.
515        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        // Even with generous retry budgets, misconfiguration must not retry.
563        // This is the fail-closed property: fix config first, never blind-retry.
564        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}