1use 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#[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 #[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 #[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}