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 catalog_presence_wins_over_provider_generic_fallback() {
224        assert_eq!(crate::provider::catalog_reasoning_efforts("minimax", "MiniMax-M3"), Some(&[][..]));
225        assert!(crate::provider::catalog_or_generic_reasoning_efforts("minimax", "MiniMax-M3").is_empty());
226        assert!(crate::provider::catalog_or_explicit_reasoning_efforts("minimax", "MiniMax-M3", true).is_empty());
227    }
228
229    #[test]
230    fn custom_anthropic_effort_capability_uses_generic_levels() {
231        let mut model_behavior = vtcode_config::core::ModelConfig::default();
232        model_behavior.model_supports_reasoning_effort = Some(true);
233        let provider = crate::providers::AnthropicProvider::from_config(
234            Some("offline-fixture".into()),
235            Some("custom-anthropic-model".into()),
236            None,
237            None,
238            None,
239            None,
240            Some(model_behavior),
241        );
242        assert_eq!(provider.supported_reasoning_efforts("custom-anthropic-model"), &["low", "medium", "high"]);
243        assert!(
244            ReasoningEffortMapper::resolve(&provider, "custom-anthropic-model", ReasoningEffortLevel::High, false)
245                .is_ok()
246        );
247        assert!(
248            ReasoningEffortMapper::resolve(&provider, "custom-anthropic-model", ReasoningEffortLevel::Max, false)
249                .is_err()
250        );
251    }
252
253    #[test]
254    fn reasoning_matrix_never_silently_loses_fidelity() {
255        for supported in [
256            &["low", "medium", "high", "xhigh", "max"][..],
257            &["low", "medium", "high", "max"][..],
258            &["minimal", "low", "medium", "high"][..],
259        ] {
260            for requested in [
261                ReasoningEffortLevel::None,
262                ReasoningEffortLevel::Minimal,
263                ReasoningEffortLevel::Low,
264                ReasoningEffortLevel::Medium,
265                ReasoningEffortLevel::High,
266                ReasoningEffortLevel::XHigh,
267                ReasoningEffortLevel::Max,
268                ReasoningEffortLevel::Unknown,
269            ] {
270                let strict = ReasoningEffortMapper::map(requested, supported, false);
271                assert_eq!(
272                    strict.is_ok(),
273                    requested == ReasoningEffortLevel::None || supported.contains(&requested.as_str())
274                );
275                if let Ok(mapping) = strict {
276                    assert!(!mapping.degraded());
277                }
278            }
279        }
280        assert_eq!(
281            ReasoningEffortMapper::map(ReasoningEffortLevel::Max, &["high", "xhigh"], true)
282                .unwrap()
283                .effective,
284            ReasoningEffortLevel::XHigh
285        );
286        assert_eq!(
287            ReasoningEffortMapper::map(ReasoningEffortLevel::Max, &["high"], true)
288                .unwrap()
289                .effective,
290            ReasoningEffortLevel::High
291        );
292        assert!(ReasoningEffortMapper::map(ReasoningEffortLevel::Unknown, &["high"], true).is_err());
293    }
294
295    #[test]
296    fn lenient_resolution_omits_unsupported_effort_instead_of_failing() {
297        assert_eq!(ReasoningEffortMapper::map_or_omit(ReasoningEffortLevel::Max, &[], false), None);
298        assert_eq!(ReasoningEffortMapper::map_or_omit(ReasoningEffortLevel::Unknown, &["low", "high"], false), None);
299        assert_eq!(
300            ReasoningEffortMapper::map_or_omit(ReasoningEffortLevel::High, &["low", "high"], false)
301                .map(|mapping| mapping.effective),
302            Some(ReasoningEffortLevel::High)
303        );
304    }
305}