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}