Skip to main content

magi_code/
thinking.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::{fmt, str::FromStr};
4
5#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
6#[serde(rename_all = "snake_case")]
7pub enum ThinkingLevel {
8    #[default]
9    Default,
10    Low,
11    Medium,
12    High,
13    XHigh,
14    Max,
15}
16
17impl ThinkingLevel {
18    pub(crate) const ALL: [Self; 6] = [
19        Self::Default,
20        Self::Low,
21        Self::Medium,
22        Self::High,
23        Self::XHigh,
24        Self::Max,
25    ];
26    pub(crate) const EFFORT_GENERIC: [Self; 4] =
27        [Self::Default, Self::Low, Self::Medium, Self::High];
28    pub(crate) const EFFORT_FULL: [Self; 5] = [
29        Self::Default,
30        Self::Low,
31        Self::Medium,
32        Self::High,
33        Self::XHigh,
34    ];
35    pub(crate) const HIGH_MAX: [Self; 3] = [Self::Default, Self::High, Self::Max];
36
37    pub(crate) fn as_str(self) -> &'static str {
38        match self {
39            Self::Default => "default",
40            Self::Low => "low",
41            Self::Medium => "medium",
42            Self::High => "high",
43            Self::XHigh => "xhigh",
44            Self::Max => "max",
45        }
46    }
47
48    pub(crate) fn label(self) -> &'static str {
49        match self {
50            Self::Default => "default",
51            Self::Low => "Low",
52            Self::Medium => "Medium",
53            Self::High => "High",
54            Self::XHigh => "XHigh",
55            Self::Max => "Max",
56        }
57    }
58
59    pub(crate) fn explicit_effort(self) -> Option<&'static str> {
60        match self {
61            Self::Default => None,
62            Self::Low => Some("low"),
63            Self::Medium => Some("medium"),
64            Self::High => Some("high"),
65            Self::XHigh => Some("xhigh"),
66            Self::Max => Some("max"),
67        }
68    }
69}
70
71#[derive(Debug, Clone, Default, PartialEq, Eq)]
72pub(crate) struct CatalogThinkingMetadata {
73    pub(crate) reasoning_efforts: Option<Vec<ThinkingLevel>>,
74    pub(crate) supports_reasoning: Option<bool>,
75}
76
77enum ModelMatcher {
78    Exact(&'static str),
79    Prefix(&'static str),
80}
81
82impl ModelMatcher {
83    fn matches(&self, model: &str) -> bool {
84        match self {
85            Self::Exact(expected) => model == *expected,
86            Self::Prefix(prefix) => model.starts_with(prefix),
87        }
88    }
89}
90
91struct ThinkingProfileMatch {
92    provider: &'static str,
93    model: ModelMatcher,
94    levels: &'static [ThinkingLevel],
95}
96
97const BUILT_IN_THINKING_PROFILES: &[ThinkingProfileMatch] = &[
98    ThinkingProfileMatch {
99        provider: crate::providers::OPENAI_CODEX_PROVIDER,
100        model: ModelMatcher::Exact("gpt-5.5"),
101        levels: &ThinkingLevel::EFFORT_FULL,
102    },
103    ThinkingProfileMatch {
104        provider: crate::providers::OPENAI_CODEX_PROVIDER,
105        model: ModelMatcher::Prefix("gpt-5"),
106        levels: &ThinkingLevel::EFFORT_GENERIC,
107    },
108    ThinkingProfileMatch {
109        provider: crate::providers::OPENAI_CODEX_PROVIDER,
110        model: ModelMatcher::Prefix("o"),
111        levels: &ThinkingLevel::EFFORT_GENERIC,
112    },
113    ThinkingProfileMatch {
114        provider: "zai",
115        model: ModelMatcher::Exact("glm-5.2"),
116        levels: &ThinkingLevel::HIGH_MAX,
117    },
118    ThinkingProfileMatch {
119        provider: "*",
120        model: ModelMatcher::Exact("zai/glm-5.2"),
121        levels: &ThinkingLevel::HIGH_MAX,
122    },
123    ThinkingProfileMatch {
124        provider: crate::providers::ANTHROPIC_PROVIDER,
125        model: ModelMatcher::Prefix("claude-sonnet-4"),
126        levels: &ThinkingLevel::HIGH_MAX,
127    },
128    ThinkingProfileMatch {
129        provider: crate::providers::ANTHROPIC_PROVIDER,
130        model: ModelMatcher::Prefix("claude-opus-4"),
131        levels: &ThinkingLevel::HIGH_MAX,
132    },
133];
134
135impl fmt::Display for ThinkingLevel {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        f.write_str(self.as_str())
138    }
139}
140
141impl FromStr for ThinkingLevel {
142    type Err = String;
143
144    fn from_str(value: &str) -> Result<Self, Self::Err> {
145        match value.trim() {
146            "default" => Ok(Self::Default),
147            "low" => Ok(Self::Low),
148            "medium" => Ok(Self::Medium),
149            "high" => Ok(Self::High),
150            "xhigh" => Ok(Self::XHigh),
151            "max" => Ok(Self::Max),
152            other => Err(format!(
153                "invalid thinking_level '{other}'; expected one of: default, low, medium, high, xhigh, max"
154            )),
155        }
156    }
157}
158
159pub(crate) fn built_in_thinking_levels(
160    provider: &str,
161    model: &str,
162) -> Option<&'static [ThinkingLevel]> {
163    BUILT_IN_THINKING_PROFILES
164        .iter()
165        .find(|profile| {
166            (profile.provider == provider || profile.provider == "*")
167                && profile.model.matches(model)
168        })
169        .map(|profile| profile.levels)
170}
171
172pub(crate) fn default_thinking_levels() -> Vec<ThinkingLevel> {
173    vec![ThinkingLevel::Default]
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub(crate) enum ThinkingCapabilityScope {
178    BuiltIn,
179    Custom(crate::config::CustomReasoningProtocol),
180}
181
182pub(crate) fn capability_scope_for_provider(
183    custom_providers: &std::collections::BTreeMap<String, crate::config::CustomProviderConfig>,
184    provider: &str,
185) -> ThinkingCapabilityScope {
186    match custom_providers.get(provider) {
187        Some(custom) => ThinkingCapabilityScope::Custom(custom.reasoning_protocol),
188        None => ThinkingCapabilityScope::BuiltIn,
189    }
190}
191
192pub(crate) fn available_thinking_levels(
193    provider: &str,
194    model: &str,
195    catalog: Option<&CatalogThinkingMetadata>,
196    scope: ThinkingCapabilityScope,
197) -> Vec<ThinkingLevel> {
198    let levels = resolve_capability_levels(provider, model, catalog, scope);
199    if matches!(
200        scope,
201        ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::AnthropicLike)
202    ) {
203        levels
204            .into_iter()
205            .filter(|level| {
206                matches!(
207                    level,
208                    ThinkingLevel::Default | ThinkingLevel::High | ThinkingLevel::Max
209                )
210            })
211            .collect()
212    } else {
213        levels
214    }
215}
216
217fn resolve_capability_levels(
218    provider: &str,
219    model: &str,
220    catalog: Option<&CatalogThinkingMetadata>,
221    scope: ThinkingCapabilityScope,
222) -> Vec<ThinkingLevel> {
223    if let Some(levels) = catalog
224        .and_then(|metadata| metadata.reasoning_efforts.as_deref())
225        .filter(|levels| !levels.is_empty())
226    {
227        return normalize_thinking_levels(levels);
228    }
229    if matches!(
230        scope,
231        ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::GptLike)
232    ) {
233        return if catalog.and_then(|metadata| metadata.supports_reasoning) == Some(true) {
234            ThinkingLevel::EFFORT_GENERIC.to_vec()
235        } else {
236            default_thinking_levels()
237        };
238    }
239    if let Some(levels) = built_in_thinking_levels(provider, model) {
240        return levels.to_vec();
241    }
242    if catalog.and_then(|metadata| metadata.supports_reasoning) == Some(true) {
243        return ThinkingLevel::EFFORT_GENERIC.to_vec();
244    }
245    default_thinking_levels()
246}
247
248pub(crate) fn normalize_thinking_levels(levels: &[ThinkingLevel]) -> Vec<ThinkingLevel> {
249    let mut normalized = Vec::new();
250    if !levels.is_empty() {
251        normalized.push(ThinkingLevel::Default);
252    }
253    for candidate in ThinkingLevel::ALL {
254        if candidate != ThinkingLevel::Default
255            && levels.contains(&candidate)
256            && !normalized.contains(&candidate)
257        {
258            normalized.push(candidate);
259        }
260    }
261    if normalized.is_empty() {
262        default_thinking_levels()
263    } else {
264        normalized
265    }
266}
267
268pub(crate) fn resolve_thinking_level(
269    available: &[ThinkingLevel],
270    selected: ThinkingLevel,
271) -> ThinkingLevel {
272    if available.contains(&selected) {
273        selected
274    } else {
275        ThinkingLevel::Default
276    }
277}
278
279pub(crate) fn next_thinking_level(
280    available: &[ThinkingLevel],
281    selected: ThinkingLevel,
282) -> Option<ThinkingLevel> {
283    if available.len() <= 1 {
284        return None;
285    }
286    let current = available
287        .iter()
288        .position(|level| *level == selected)
289        .unwrap_or(0);
290    Some(available[(current + 1) % available.len()])
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn thinking_levels_parse_display_and_cycle_in_stable_order() {
299        assert_eq!(
300            "default".parse::<ThinkingLevel>().unwrap(),
301            ThinkingLevel::Default
302        );
303        assert_eq!("low".parse::<ThinkingLevel>().unwrap(), ThinkingLevel::Low);
304        assert_eq!(
305            "xhigh".parse::<ThinkingLevel>().unwrap(),
306            ThinkingLevel::XHigh
307        );
308        assert_eq!("max".parse::<ThinkingLevel>().unwrap(), ThinkingLevel::Max);
309        assert_eq!(ThinkingLevel::Medium.to_string(), "medium");
310        assert_eq!(ThinkingLevel::XHigh.to_string(), "xhigh");
311        assert_eq!(ThinkingLevel::Max.to_string(), "max");
312        assert_eq!(ThinkingLevel::High.label(), "High");
313        assert_eq!(ThinkingLevel::XHigh.label(), "XHigh");
314        assert!("invalid".parse::<ThinkingLevel>().is_err());
315        assert_eq!(
316            ThinkingLevel::ALL,
317            [
318                ThinkingLevel::Default,
319                ThinkingLevel::Low,
320                ThinkingLevel::Medium,
321                ThinkingLevel::High,
322                ThinkingLevel::XHigh,
323                ThinkingLevel::Max,
324            ]
325        );
326    }
327
328    #[test]
329    fn built_in_thinking_levels_match_model_capabilities_for_builtin_scope() {
330        assert_eq!(
331            available_thinking_levels(
332                crate::providers::OPENAI_CODEX_PROVIDER,
333                "gpt-5.5",
334                None,
335                ThinkingCapabilityScope::BuiltIn,
336            ),
337            ThinkingLevel::EFFORT_FULL.to_vec()
338        );
339        assert_eq!(
340            available_thinking_levels("zai", "glm-5.2", None, ThinkingCapabilityScope::BuiltIn,),
341            ThinkingLevel::HIGH_MAX.to_vec()
342        );
343        assert_eq!(
344            available_thinking_levels(
345                "custom",
346                "zai/glm-5.2",
347                None,
348                ThinkingCapabilityScope::BuiltIn,
349            ),
350            ThinkingLevel::HIGH_MAX.to_vec()
351        );
352        assert_eq!(
353            available_thinking_levels(
354                crate::providers::ANTHROPIC_PROVIDER,
355                "claude-sonnet-4-5-20250929",
356                None,
357                ThinkingCapabilityScope::BuiltIn,
358            ),
359            ThinkingLevel::HIGH_MAX.to_vec()
360        );
361        for model in ["gpt-5", "gpt-5-nano", "o3", "o4-mini"] {
362            assert_eq!(
363                available_thinking_levels(
364                    crate::providers::OPENAI_CODEX_PROVIDER,
365                    model,
366                    None,
367                    ThinkingCapabilityScope::BuiltIn,
368                ),
369                ThinkingLevel::EFFORT_GENERIC.to_vec()
370            );
371        }
372    }
373
374    #[test]
375    fn unsupported_models_and_unsupported_selected_levels_fall_back_to_default() {
376        let available = available_thinking_levels(
377            crate::providers::OPENAI_CODEX_PROVIDER,
378            "gpt-4.1",
379            None,
380            ThinkingCapabilityScope::BuiltIn,
381        );
382        assert_eq!(available, vec![ThinkingLevel::Default]);
383        assert_eq!(
384            available_thinking_levels("custom", "gpt-5", None, ThinkingCapabilityScope::BuiltIn,),
385            vec![ThinkingLevel::Default]
386        );
387        assert_eq!(
388            resolve_thinking_level(&available, ThinkingLevel::High),
389            ThinkingLevel::Default
390        );
391        assert_eq!(
392            next_thinking_level(&available, ThinkingLevel::Default),
393            None
394        );
395    }
396
397    #[test]
398    fn catalog_precedence_uses_explicit_builtin_boolean_then_default() {
399        let explicit = CatalogThinkingMetadata {
400            reasoning_efforts: Some(vec![
401                ThinkingLevel::Max,
402                ThinkingLevel::High,
403                ThinkingLevel::High,
404            ]),
405            supports_reasoning: Some(true),
406        };
407        let available = available_thinking_levels(
408            crate::providers::OPENAI_CODEX_PROVIDER,
409            "gpt-5.5",
410            Some(&explicit),
411            ThinkingCapabilityScope::BuiltIn,
412        );
413        assert_eq!(
414            available,
415            vec![
416                ThinkingLevel::Default,
417                ThinkingLevel::High,
418                ThinkingLevel::Max
419            ]
420        );
421        let boolean = CatalogThinkingMetadata {
422            reasoning_efforts: None,
423            supports_reasoning: Some(true),
424        };
425        assert_eq!(
426            available_thinking_levels(
427                crate::providers::OPENAI_CODEX_PROVIDER,
428                "gpt-5.5",
429                Some(&boolean),
430                ThinkingCapabilityScope::BuiltIn,
431            ),
432            ThinkingLevel::EFFORT_FULL.to_vec()
433        );
434        assert_eq!(
435            available_thinking_levels(
436                "zai",
437                "glm-5.2",
438                Some(&boolean),
439                ThinkingCapabilityScope::BuiltIn,
440            ),
441            ThinkingLevel::HIGH_MAX.to_vec()
442        );
443        assert_eq!(
444            available_thinking_levels(
445                "custom",
446                "unknown",
447                Some(&boolean),
448                ThinkingCapabilityScope::BuiltIn,
449            ),
450            ThinkingLevel::EFFORT_GENERIC.to_vec()
451        );
452        assert_eq!(
453            resolve_thinking_level(&available, ThinkingLevel::Max),
454            ThinkingLevel::Max
455        );
456        assert_eq!(
457            resolve_thinking_level(&available, ThinkingLevel::XHigh),
458            ThinkingLevel::Default
459        );
460        assert_eq!(
461            next_thinking_level(&available, ThinkingLevel::Default),
462            Some(ThinkingLevel::High)
463        );
464        assert_eq!(
465            next_thinking_level(&available, ThinkingLevel::High),
466            Some(ThinkingLevel::Max)
467        );
468        assert_eq!(
469            next_thinking_level(&available, ThinkingLevel::Max),
470            Some(ThinkingLevel::Default)
471        );
472    }
473
474    #[test]
475    fn custom_gpt_like_provider_requires_reasoning_metadata() {
476        let generic =
477            ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::GptLike);
478        for model in ["unknown", "gpt-5", "gpt-5.6", "zai/glm-5.2", "extra-model"] {
479            assert_eq!(
480                available_thinking_levels("custom-provider", model, None, generic),
481                default_thinking_levels(),
482                "{model}"
483            );
484        }
485    }
486
487    #[test]
488    fn custom_gpt_like_provider_boolean_metadata_controls_generic_levels() {
489        let generic =
490            ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::GptLike);
491        for supports_reasoning in [Some(true), Some(false), None] {
492            let catalog = CatalogThinkingMetadata {
493                reasoning_efforts: None,
494                supports_reasoning,
495            };
496            assert_eq!(
497                available_thinking_levels("custom-provider", "alias", Some(&catalog), generic),
498                if supports_reasoning == Some(true) {
499                    ThinkingLevel::EFFORT_GENERIC.to_vec()
500                } else {
501                    default_thinking_levels()
502                },
503                "supports_reasoning={supports_reasoning:?}"
504            );
505        }
506    }
507
508    #[test]
509    fn explicit_catalog_efforts_normalize_and_override_custom_fallback() {
510        let generic =
511            ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::GptLike);
512        let catalog = CatalogThinkingMetadata {
513            reasoning_efforts: Some(vec![
514                ThinkingLevel::Max,
515                ThinkingLevel::High,
516                ThinkingLevel::High,
517            ]),
518            supports_reasoning: Some(false),
519        };
520        assert_eq!(
521            available_thinking_levels("custom-provider", "alias", Some(&catalog), generic),
522            vec![
523                ThinkingLevel::Default,
524                ThinkingLevel::High,
525                ThinkingLevel::Max
526            ]
527        );
528        let xhigh = CatalogThinkingMetadata {
529            reasoning_efforts: Some(vec![ThinkingLevel::XHigh]),
530            supports_reasoning: None,
531        };
532        assert_eq!(
533            available_thinking_levels("custom-provider", "alias", Some(&xhigh), generic),
534            vec![ThinkingLevel::Default, ThinkingLevel::XHigh]
535        );
536    }
537
538    #[test]
539    fn empty_catalog_efforts_behave_as_absent_metadata() {
540        let generic =
541            ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::GptLike);
542        let empty = CatalogThinkingMetadata {
543            reasoning_efforts: Some(Vec::new()),
544            supports_reasoning: Some(false),
545        };
546        assert_eq!(
547            available_thinking_levels("custom-provider", "alias", Some(&empty), generic),
548            default_thinking_levels()
549        );
550    }
551
552    #[test]
553    fn custom_gpt_like_scope_does_not_infer_capability_from_model_name() {
554        let generic =
555            ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::GptLike);
556        assert_eq!(
557            available_thinking_levels("custom-provider", "zai/glm-5.2", None, generic),
558            default_thinking_levels()
559        );
560        let anthropic =
561            ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::AnthropicLike);
562        assert_eq!(
563            available_thinking_levels("custom-provider", "zai/glm-5.2", None, anthropic),
564            ThinkingLevel::HIGH_MAX.to_vec()
565        );
566    }
567
568    #[test]
569    fn custom_anthropic_like_intersects_with_default_high_max() {
570        let anthropic =
571            ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::AnthropicLike);
572        let catalog = CatalogThinkingMetadata {
573            reasoning_efforts: Some(vec![
574                ThinkingLevel::Low,
575                ThinkingLevel::High,
576                ThinkingLevel::Max,
577            ]),
578            supports_reasoning: None,
579        };
580        assert_eq!(
581            available_thinking_levels("custom-provider", "alias", Some(&catalog), anthropic),
582            vec![
583                ThinkingLevel::Default,
584                ThinkingLevel::High,
585                ThinkingLevel::Max
586            ]
587        );
588        assert_eq!(
589            resolve_thinking_level(
590                &available_thinking_levels("custom-provider", "alias", Some(&catalog), anthropic,),
591                ThinkingLevel::Low,
592            ),
593            ThinkingLevel::Default
594        );
595    }
596
597    #[test]
598    fn persisted_unsupported_level_clamps_non_destructively() {
599        let generic =
600            ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::GptLike);
601        let available = available_thinking_levels("custom-provider", "alias", None, generic);
602        assert_eq!(
603            resolve_thinking_level(&available, ThinkingLevel::XHigh),
604            ThinkingLevel::Default
605        );
606        assert!(!available.contains(&ThinkingLevel::High));
607        assert_eq!(
608            resolve_thinking_level(&available, ThinkingLevel::High),
609            ThinkingLevel::Default
610        );
611        let xhigh_catalog = CatalogThinkingMetadata {
612            reasoning_efforts: Some(vec![ThinkingLevel::XHigh]),
613            supports_reasoning: None,
614        };
615        let xhigh_available =
616            available_thinking_levels("custom-provider", "alias", Some(&xhigh_catalog), generic);
617        assert_eq!(
618            resolve_thinking_level(&xhigh_available, ThinkingLevel::XHigh),
619            ThinkingLevel::XHigh
620        );
621    }
622
623    #[test]
624    fn capability_gate_requires_reasoning_metadata() {
625        let generic =
626            ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::GptLike);
627        let multi = available_thinking_levels(
628            "custom-provider",
629            "alias",
630            Some(&CatalogThinkingMetadata {
631                reasoning_efforts: None,
632                supports_reasoning: Some(true),
633            }),
634            generic,
635        );
636        assert!(multi.len() > 1);
637        assert!(multi.contains(&ThinkingLevel::High));
638        assert_eq!(
639            resolve_thinking_level(&multi, ThinkingLevel::High),
640            ThinkingLevel::High
641        );
642        let single = ThinkingCapabilityScope::BuiltIn;
643        let none = available_thinking_levels("custom-provider", "alias", None, single);
644        assert_eq!(none, vec![ThinkingLevel::Default]);
645        assert!(none.len() <= 1);
646    }
647}