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