Skip to main content

llm_trait/
reasoning.rs

1//! Reasoning/thinking configuration types.
2
3/// How a model expresses reasoning/thinking capability.
4///
5/// Each model supports one or more reasoning modes.
6/// The Protocol layer uses this to decide which fields to include in the request.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum ReasoningMode {
9    /// No reasoning support (don't send any reasoning fields)
10    None,
11    /// OpenAI-style effort parameter (`reasoning_effort: "low"|"medium"|"high"`)
12    Effort,
13    /// Anthropic-style thinking block (`thinking: {type: "enabled", budget_tokens: N}`)
14    Thinking,
15    /// Both modes supported (protocol decides which to use)
16    Both,
17}
18
19/// Resolved reasoning specification — what to actually send to the API.
20///
21/// Derived from `ReasoningConfig` (user intent) + `ReasoningMode` (model capability).
22#[derive(Debug, Clone)]
23pub enum ReasoningSpec {
24    /// Don't send any reasoning fields
25    None,
26    /// Send OpenAI-style effort parameter
27    Effort(ReasoningEffort),
28    /// Send Anthropic-style thinking block
29    Thinking { budget_tokens: u64 },
30}
31
32/// Reasoning/thinking configuration, unifying reasoning/thinking parameters across vendors.
33#[derive(Debug, Clone, Default)]
34pub struct ReasoningConfig {
35    /// Whether to enable reasoning/thinking process
36    pub enabled: Option<bool>,
37    /// Thinking budget (token count limit)
38    pub budget_tokens: Option<u64>,
39    /// Reasoning intensity/depth (semantics vary by vendor)
40    pub effort: Option<ReasoningEffort>,
41}
42
43/// Reasoning intensity/depth enumeration.
44#[derive(Debug, Clone, Default)]
45pub enum ReasoningEffort {
46    #[default]
47    None,
48    Low,
49    Medium,
50    High,
51    XHigh,
52}
53
54impl ReasoningConfig {
55    /// Convert user intent (ReasoningConfig) + model capability (ReasoningMode)
56    /// into a concrete ReasoningSpec to send to the API.
57    pub fn to_spec(&self, mode: ReasoningMode) -> ReasoningSpec {
58        // If explicitly disabled, skip reasoning regardless of mode
59        if self.enabled == Some(false) {
60            return ReasoningSpec::None;
61        }
62
63        match mode {
64            ReasoningMode::None => ReasoningSpec::None,
65            ReasoningMode::Effort => {
66                // Only accept Effort-type parameters
67                self.effort
68                    .as_ref()
69                    .filter(|e| !matches!(e, ReasoningEffort::None))
70                    .map(|e| ReasoningSpec::Effort(e.clone()))
71                    .unwrap_or(ReasoningSpec::None)
72            }
73            ReasoningMode::Thinking => {
74                // Only accept Thinking-type parameters
75                ReasoningSpec::Thinking {
76                    budget_tokens: self.budget_tokens.unwrap_or(2048),
77                }
78            }
79            ReasoningMode::Both => {
80                // Prefer Thinking over Effort.
81                // Thinking is more expressive; Anthropic only handles Thinking.
82                // If budget_tokens is provided, use Thinking; otherwise fall back to Effort.
83                if let Some(budget_tokens) = self.budget_tokens {
84                    ReasoningSpec::Thinking { budget_tokens }
85                } else if let Some(effort) = &self.effort {
86                    if !matches!(effort, ReasoningEffort::None) {
87                        return ReasoningSpec::Effort(effort.clone());
88                    }
89                    ReasoningSpec::Thinking {
90                        budget_tokens: 2048,
91                    }
92                } else {
93                    ReasoningSpec::Thinking {
94                        budget_tokens: 2048,
95                    }
96                }
97            }
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn to_spec_none_mode() {
108        let config = ReasoningConfig {
109            effort: Some(ReasoningEffort::High),
110            budget_tokens: Some(4096),
111            ..Default::default()
112        };
113        assert!(matches!(
114            config.to_spec(ReasoningMode::None),
115            ReasoningSpec::None
116        ));
117    }
118
119    #[test]
120    fn to_spec_effort_mode_with_effort() {
121        let config = ReasoningConfig {
122            effort: Some(ReasoningEffort::Medium),
123            ..Default::default()
124        };
125        assert!(matches!(
126            config.to_spec(ReasoningMode::Effort),
127            ReasoningSpec::Effort(ReasoningEffort::Medium)
128        ));
129    }
130
131    #[test]
132    fn to_spec_effort_mode_without_effort() {
133        let config = ReasoningConfig {
134            effort: None,
135            budget_tokens: Some(4096),
136            ..Default::default()
137        };
138        assert!(matches!(
139            config.to_spec(ReasoningMode::Effort),
140            ReasoningSpec::None
141        ));
142    }
143
144    #[test]
145    fn to_spec_thinking_mode() {
146        let config = ReasoningConfig {
147            budget_tokens: Some(4096),
148            ..Default::default()
149        };
150        assert!(matches!(
151            config.to_spec(ReasoningMode::Thinking),
152            ReasoningSpec::Thinking {
153                budget_tokens: 4096
154            }
155        ));
156    }
157
158    #[test]
159    fn to_spec_thinking_mode_default_budget() {
160        let config = ReasoningConfig::default();
161        assert!(matches!(
162            config.to_spec(ReasoningMode::Thinking),
163            ReasoningSpec::Thinking {
164                budget_tokens: 2048
165            }
166        ));
167    }
168
169    #[test]
170    fn to_spec_both_effort_priority() {
171        let config = ReasoningConfig {
172            effort: Some(ReasoningEffort::High),
173            budget_tokens: Some(4096),
174            ..Default::default()
175        };
176        // Both mode should prefer Thinking over Effort,
177        // because Thinking is more expressive and Effort can be derived from it.
178        // Anthropic only handles Thinking, so preferring Effort would silently drop reasoning.
179        assert!(matches!(
180            config.to_spec(ReasoningMode::Both),
181            ReasoningSpec::Thinking {
182                budget_tokens: 4096
183            }
184        ));
185    }
186
187    #[test]
188    fn to_spec_both_fallback_to_thinking() {
189        let config = ReasoningConfig {
190            effort: None,
191            budget_tokens: Some(4096),
192            ..Default::default()
193        };
194        assert!(matches!(
195            config.to_spec(ReasoningMode::Both),
196            ReasoningSpec::Thinking {
197                budget_tokens: 4096
198            }
199        ));
200    }
201
202    #[test]
203    fn to_spec_both_effort_none_fallback() {
204        let config = ReasoningConfig {
205            effort: Some(ReasoningEffort::None),
206            budget_tokens: Some(4096),
207            ..Default::default()
208        };
209        // Effort::None should be treated as "no effort", fallback to thinking
210        assert!(matches!(
211            config.to_spec(ReasoningMode::Both),
212            ReasoningSpec::Thinking {
213                budget_tokens: 4096
214            }
215        ));
216    }
217
218    #[test]
219    fn to_spec_enabled_false_disables_reasoning() {
220        // ReasoningConfig.enabled=false now disables reasoning.
221        let config = ReasoningConfig {
222            enabled: Some(false),
223            effort: Some(ReasoningEffort::High),
224            ..Default::default()
225        };
226        let spec = config.to_spec(ReasoningMode::Effort);
227        assert!(
228            matches!(spec, ReasoningSpec::None),
229            "enabled=false should disable reasoning"
230        );
231
232        let config2 = ReasoningConfig {
233            enabled: Some(false),
234            budget_tokens: Some(4096),
235            ..Default::default()
236        };
237        let spec2 = config2.to_spec(ReasoningMode::Thinking);
238        assert!(
239            matches!(spec2, ReasoningSpec::None),
240            "enabled=false should disable thinking"
241        );
242    }
243
244    // ── proptest: ReasoningConfig::to_spec ──
245
246    mod proptest_tests {
247        use super::*;
248        use proptest::prelude::*;
249
250        proptest! {
251            #[test]
252            fn to_spec_never_panics(
253                enabled in proptest::option::of(proptest::bool::ANY),
254                budget_tokens in proptest::option::of(0u64..100_000),
255                effort_idx in 0usize..5,
256                mode_idx in 0usize..4,
257            ) {
258                let effort = match effort_idx {
259                    0 => None,
260                    1 => Some(ReasoningEffort::None),
261                    2 => Some(ReasoningEffort::Low),
262                    3 => Some(ReasoningEffort::Medium),
263                    4 => Some(ReasoningEffort::High),
264                    _ => unreachable!(),
265                };
266                let mode = match mode_idx {
267                    0 => ReasoningMode::None,
268                    1 => ReasoningMode::Effort,
269                    2 => ReasoningMode::Thinking,
270                    3 => ReasoningMode::Both,
271                    _ => unreachable!(),
272                };
273                let config = ReasoningConfig { enabled, budget_tokens, effort };
274                let _spec = config.to_spec(mode);
275            }
276
277            #[test]
278            fn none_mode_always_returns_none(
279                enabled in proptest::option::of(proptest::bool::ANY),
280                budget_tokens in proptest::option::of(0u64..100_000),
281                effort_idx in 0usize..5,
282            ) {
283                let effort = match effort_idx {
284                    0 => None,
285                    1 => Some(ReasoningEffort::None),
286                    2 => Some(ReasoningEffort::Low),
287                    3 => Some(ReasoningEffort::Medium),
288                    4 => Some(ReasoningEffort::High),
289                    _ => unreachable!(),
290                };
291                let config = ReasoningConfig { enabled, budget_tokens, effort };
292                let spec = config.to_spec(ReasoningMode::None);
293                assert!(matches!(spec, ReasoningSpec::None),
294                    "Mode::None should always produce Spec::None, got {:?}", spec);
295            }
296
297            #[test]
298            fn enabled_false_always_returns_none(
299                budget_tokens in proptest::option::of(0u64..100_000),
300                effort_idx in 0usize..5,
301                mode_idx in 0usize..4,
302            ) {
303                let effort = match effort_idx {
304                    0 => None,
305                    1 => Some(ReasoningEffort::None),
306                    2 => Some(ReasoningEffort::Low),
307                    3 => Some(ReasoningEffort::Medium),
308                    4 => Some(ReasoningEffort::High),
309                    _ => unreachable!(),
310                };
311                let mode = match mode_idx {
312                    0 => ReasoningMode::None,
313                    1 => ReasoningMode::Effort,
314                    2 => ReasoningMode::Thinking,
315                    3 => ReasoningMode::Both,
316                    _ => unreachable!(),
317                };
318                let config = ReasoningConfig { enabled: Some(false), budget_tokens, effort };
319                let spec = config.to_spec(mode);
320                assert!(matches!(spec, ReasoningSpec::None),
321                    "enabled=false should disable reasoning for any mode, got {:?}", spec);
322            }
323        }
324    }
325}