Skip to main content

vtcode_llm/
reasoning_effort.rs

1//! Capability-driven reasoning validation before a provider request is sent.
2
3use anyhow::Result;
4use vtcode_config::types::ReasoningEffortLevel;
5
6use crate::provider::LLMProvider;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub struct ReasoningEffortMapping {
10    pub requested: ReasoningEffortLevel,
11    pub effective: ReasoningEffortLevel,
12}
13
14impl ReasoningEffortMapping {
15    pub fn degraded(self) -> bool {
16        self.requested != self.effective
17    }
18}
19
20/// A pre-request block, distinct from a retryable transport failure.
21#[derive(Debug)]
22pub struct ReasoningEffortUnsupported {
23    pub requested: ReasoningEffortLevel,
24    pub supported: compact_str::CompactString,
25}
26
27impl std::fmt::Display for ReasoningEffortUnsupported {
28    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        write!(
30            formatter,
31            "Requested reasoning effort `{}` is unsupported by this route (supported: {}). Select a supported effort or explicitly enable agent.allow_reasoning_effort_downgrade",
32            self.requested, self.supported
33        )
34    }
35}
36
37impl std::error::Error for ReasoningEffortUnsupported {}
38
39pub struct ReasoningEffortMapper;
40
41impl ReasoningEffortMapper {
42    fn supported_levels(provider: &dyn LLMProvider, model: &str) -> &'static [&'static str] {
43        if let Some(levels) = crate::provider::catalog_reasoning_efforts(provider.name(), model) {
44            levels
45        } else if provider.supports_reasoning_effort(model) {
46            provider.supported_reasoning_efforts(model)
47        } else {
48            &[]
49        }
50    }
51
52    pub fn resolve(
53        provider: &dyn LLMProvider,
54        model: &str,
55        requested: ReasoningEffortLevel,
56        allow_downgrade: bool,
57    ) -> Result<ReasoningEffortMapping> {
58        let supported = Self::supported_levels(provider, model);
59        Self::map(requested, supported, allow_downgrade)
60    }
61
62    /// Best-effort counterpart to [`Self::resolve`] for session-persistent
63    /// configuration.
64    ///
65    /// A configured effort can outlive the provider/model route that created
66    /// it. Omit an incompatible value for this request so the provider can use
67    /// its own default instead of blocking every subsequent turn.
68    #[must_use]
69    pub fn resolve_or_omit(
70        provider: &dyn LLMProvider,
71        model: &str,
72        requested: ReasoningEffortLevel,
73        allow_downgrade: bool,
74    ) -> Option<ReasoningEffortMapping> {
75        let supported = Self::supported_levels(provider, model);
76        match Self::map(requested, supported, allow_downgrade) {
77            Ok(mapping) => Some(mapping),
78            Err(error) => {
79                tracing::warn!(
80                    provider = provider.name(),
81                    model,
82                    requested = %requested,
83                    error = %error,
84                    "Configured reasoning effort is unsupported on this route; omitting it for this request"
85                );
86                None
87            }
88        }
89    }
90
91    /// Route-free variant for callers that already hold the supported levels.
92    #[must_use]
93    pub(crate) fn map_or_omit(
94        requested: ReasoningEffortLevel,
95        supported: &[&str],
96        allow_downgrade: bool,
97    ) -> Option<ReasoningEffortMapping> {
98        match Self::map(requested, supported, allow_downgrade) {
99            Ok(mapping) => Some(mapping),
100            Err(error) => {
101                tracing::debug!(
102                    requested = %requested,
103                    error = %error,
104                    "Configured reasoning effort is unsupported on this route; omitting it for this request"
105                );
106                None
107            }
108        }
109    }
110
111    pub fn map(
112        requested: ReasoningEffortLevel,
113        supported: &[&str],
114        allow_downgrade: bool,
115    ) -> Result<ReasoningEffortMapping> {
116        if requested == ReasoningEffortLevel::None || supported.contains(&requested.as_str()) {
117            return Ok(ReasoningEffortMapping { requested, effective: requested });
118        }
119        let ordered_levels = [
120            ReasoningEffortLevel::Minimal,
121            ReasoningEffortLevel::Low,
122            ReasoningEffortLevel::Medium,
123            ReasoningEffortLevel::High,
124            ReasoningEffortLevel::XHigh,
125            ReasoningEffortLevel::Max,
126        ];
127        if allow_downgrade
128            && let Some(position) = ordered_levels.iter().position(|level| *level == requested)
129            && let Some(effective) = ordered_levels
130                .iter()
131                .take(position)
132                .rev()
133                .find(|level| supported.contains(&level.as_str()))
134        {
135            return Ok(ReasoningEffortMapping { requested, effective: *effective });
136        }
137        Err(ReasoningEffortUnsupported { requested, supported: supported.join(", ").into() }.into())
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn actual_provider_reasoning_matrix_preserves_all_supported_levels() {
147        use crate::providers::{AnthropicProvider, GeminiProvider, OpenAIProvider};
148        use vtcode_config::constants::models;
149        let providers: [(Box<dyn LLMProvider>, &str, &[&str]); 3] = [
150            (
151                Box::new(OpenAIProvider::new("offline-fixture".into())),
152                models::openai::DEFAULT_MODEL,
153                &["low", "medium", "high", "xhigh", "max"],
154            ),
155            (
156                Box::new(AnthropicProvider::new("offline-fixture".into())),
157                models::anthropic::DEFAULT_MODEL,
158                &["low", "medium", "high", "xhigh", "max"],
159            ),
160            (
161                Box::new(GeminiProvider::new("offline-fixture".into())),
162                models::google::DEFAULT_MODEL,
163                &["low", "medium", "high"],
164            ),
165        ];
166        for (provider, model, expected_levels) in providers {
167            assert_eq!(provider.supported_reasoning_efforts(model), expected_levels);
168            for requested in [
169                ReasoningEffortLevel::None,
170                ReasoningEffortLevel::Minimal,
171                ReasoningEffortLevel::Low,
172                ReasoningEffortLevel::Medium,
173                ReasoningEffortLevel::High,
174                ReasoningEffortLevel::XHigh,
175                ReasoningEffortLevel::Max,
176                ReasoningEffortLevel::Unknown,
177            ] {
178                let result = ReasoningEffortMapper::resolve(provider.as_ref(), model, requested, false);
179                let expected = requested == ReasoningEffortLevel::None || expected_levels.contains(&requested.as_str());
180                assert_eq!(result.is_ok(), expected, "{} {model} {requested}", provider.name());
181                if let Ok(mapping) = result {
182                    assert_eq!(mapping.requested, mapping.effective);
183                } else {
184                    assert!(result.unwrap_err().downcast_ref::<ReasoningEffortUnsupported>().is_some());
185                }
186            }
187        }
188    }
189
190    #[test]
191    fn unknown_route_fails_closed_for_active_reasoning() {
192        let provider = crate::providers::OpenAIProvider::new("offline-fixture".into());
193        assert!(ReasoningEffortMapper::resolve(&provider, "unknown-route", ReasoningEffortLevel::High, false).is_err());
194    }
195
196    #[test]
197    fn known_structured_reasoning_route_does_not_inherit_generic_efforts() {
198        let provider = crate::providers::MinimaxProvider::from_config(
199            Some("offline-fixture".into()),
200            Some("MiniMax-M3".into()),
201            None,
202            None,
203            None,
204            None,
205            None,
206        );
207
208        assert!(provider.supports_reasoning("MiniMax-M3"));
209        assert!(provider.supports_reasoning_effort("MiniMax-M3"));
210        assert!(provider.supported_reasoning_efforts("MiniMax-M3").is_empty());
211        assert!(
212            ReasoningEffortMapper::resolve(&provider, "MiniMax-M3", ReasoningEffortLevel::High, false).is_err(),
213            "structured reasoning without catalog effort levels must block configurable effort"
214        );
215        assert_eq!(
216            ReasoningEffortMapper::resolve_or_omit(&provider, "MiniMax-M3", ReasoningEffortLevel::High, false),
217            None,
218            "session-persistent effort should be omitted instead of blocking the turn"
219        );
220    }
221
222    #[test]
223    fn minimax_m31_flash_exposes_catalog_efforts() {
224        let provider = crate::providers::MinimaxProvider::from_config(
225            Some("offline-fixture".into()),
226            Some("MiniMax-M3.1-Flash".into()),
227            None,
228            None,
229            None,
230            None,
231            None,
232        );
233
234        assert!(provider.supports_reasoning("MiniMax-M3.1-Flash"));
235        assert_eq!(
236            provider.supported_reasoning_efforts("MiniMax-M3.1-Flash"),
237            ["low", "medium", "high", "xhigh", "max"]
238        );
239        assert!(
240            ReasoningEffortMapper::resolve(&provider, "MiniMax-M3.1-Flash", ReasoningEffortLevel::High, false).is_ok()
241        );
242    }
243
244    #[test]
245    fn catalog_presence_wins_over_provider_generic_fallback() {
246        assert_eq!(crate::provider::catalog_reasoning_efforts("minimax", "MiniMax-M3"), Some(&[][..]));
247        assert!(crate::provider::catalog_or_generic_reasoning_efforts("minimax", "MiniMax-M3").is_empty());
248        assert!(crate::provider::catalog_or_explicit_reasoning_efforts("minimax", "MiniMax-M3", true).is_empty());
249    }
250
251    #[test]
252    fn custom_anthropic_effort_capability_uses_generic_levels() {
253        let mut model_behavior = vtcode_config::core::ModelConfig::default();
254        model_behavior.model_supports_reasoning_effort = Some(true);
255        let provider = crate::providers::AnthropicProvider::from_config(
256            Some("offline-fixture".into()),
257            Some("custom-anthropic-model".into()),
258            None,
259            None,
260            None,
261            None,
262            Some(model_behavior),
263        );
264        assert_eq!(provider.supported_reasoning_efforts("custom-anthropic-model"), &["low", "medium", "high"]);
265        assert!(
266            ReasoningEffortMapper::resolve(&provider, "custom-anthropic-model", ReasoningEffortLevel::High, false)
267                .is_ok()
268        );
269        assert!(
270            ReasoningEffortMapper::resolve(&provider, "custom-anthropic-model", ReasoningEffortLevel::Max, false)
271                .is_err()
272        );
273    }
274
275    #[test]
276    fn reasoning_matrix_never_silently_loses_fidelity() {
277        for supported in [
278            &["low", "medium", "high", "xhigh", "max"][..],
279            &["low", "medium", "high", "max"][..],
280            &["minimal", "low", "medium", "high"][..],
281        ] {
282            for requested in [
283                ReasoningEffortLevel::None,
284                ReasoningEffortLevel::Minimal,
285                ReasoningEffortLevel::Low,
286                ReasoningEffortLevel::Medium,
287                ReasoningEffortLevel::High,
288                ReasoningEffortLevel::XHigh,
289                ReasoningEffortLevel::Max,
290                ReasoningEffortLevel::Unknown,
291            ] {
292                let strict = ReasoningEffortMapper::map(requested, supported, false);
293                assert_eq!(
294                    strict.is_ok(),
295                    requested == ReasoningEffortLevel::None || supported.contains(&requested.as_str())
296                );
297                if let Ok(mapping) = strict {
298                    assert!(!mapping.degraded());
299                }
300            }
301        }
302        assert_eq!(
303            ReasoningEffortMapper::map(ReasoningEffortLevel::Max, &["high", "xhigh"], true)
304                .unwrap()
305                .effective,
306            ReasoningEffortLevel::XHigh
307        );
308        assert_eq!(
309            ReasoningEffortMapper::map(ReasoningEffortLevel::Max, &["high"], true)
310                .unwrap()
311                .effective,
312            ReasoningEffortLevel::High
313        );
314        assert!(ReasoningEffortMapper::map(ReasoningEffortLevel::Unknown, &["high"], true).is_err());
315    }
316
317    #[test]
318    fn lenient_resolution_omits_unsupported_effort_instead_of_failing() {
319        assert_eq!(ReasoningEffortMapper::map_or_omit(ReasoningEffortLevel::Max, &[], false), None);
320        assert_eq!(ReasoningEffortMapper::map_or_omit(ReasoningEffortLevel::Unknown, &["low", "high"], false), None);
321        assert_eq!(
322            ReasoningEffortMapper::map_or_omit(ReasoningEffortLevel::High, &["low", "high"], false)
323                .map(|mapping| mapping.effective),
324            Some(ReasoningEffortLevel::High)
325        );
326    }
327}