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    BUILT_IN_THINKING_PROFILES
154        .iter()
155        .find(|profile| {
156            (profile.provider == provider || profile.provider == "*")
157                && profile.model.matches(model)
158        })
159        .map(|profile| profile.levels)
160}
161
162pub(crate) fn default_thinking_levels() -> Vec<ThinkingLevel> {
163    vec![ThinkingLevel::Default]
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub(crate) enum ThinkingCapabilityScope {
168    BuiltIn,
169    Custom(crate::config::CustomReasoningProtocol),
170}
171
172pub(crate) fn capability_scope_for_provider(
173    custom_providers: &std::collections::BTreeMap<String, crate::config::CustomProviderConfig>,
174    provider: &str,
175) -> ThinkingCapabilityScope {
176    match custom_providers.get(provider) {
177        Some(custom) => ThinkingCapabilityScope::Custom(custom.reasoning_protocol),
178        None => ThinkingCapabilityScope::BuiltIn,
179    }
180}
181
182pub(crate) fn available_thinking_levels(
183    provider: &str,
184    model: &str,
185    catalog: Option<&CatalogThinkingMetadata>,
186    scope: ThinkingCapabilityScope,
187) -> Vec<ThinkingLevel> {
188    let levels = resolve_capability_levels(provider, model, catalog, scope);
189    if matches!(
190        scope,
191        ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::AnthropicLike)
192    ) {
193        levels
194            .into_iter()
195            .filter(|level| {
196                matches!(
197                    level,
198                    ThinkingLevel::Default | ThinkingLevel::High | ThinkingLevel::Max
199                )
200            })
201            .collect()
202    } else {
203        levels
204    }
205}
206
207fn resolve_capability_levels(
208    provider: &str,
209    model: &str,
210    catalog: Option<&CatalogThinkingMetadata>,
211    scope: ThinkingCapabilityScope,
212) -> Vec<ThinkingLevel> {
213    if let Some(levels) = catalog
214        .and_then(|metadata| metadata.reasoning_efforts.as_deref())
215        .filter(|levels| !levels.is_empty())
216    {
217        return normalize_thinking_levels(levels);
218    }
219    if matches!(
220        scope,
221        ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::GptLike)
222    ) {
223        return if catalog.and_then(|metadata| metadata.supports_reasoning) == Some(true) {
224            ThinkingLevel::EFFORT_GENERIC.to_vec()
225        } else {
226            default_thinking_levels()
227        };
228    }
229    if provider == crate::providers::OPENAI_CODEX_PROVIDER
230        && matches!(scope, ThinkingCapabilityScope::BuiltIn)
231        && catalog.and_then(|metadata| metadata.supports_reasoning) == Some(false)
232    {
233        return default_thinking_levels();
234    }
235
236    if let Some(levels) = built_in_thinking_levels(provider, model) {
237        return levels.to_vec();
238    }
239    if catalog.and_then(|metadata| metadata.supports_reasoning) == Some(true) {
240        return ThinkingLevel::EFFORT_GENERIC.to_vec();
241    }
242    default_thinking_levels()
243}
244
245pub(crate) fn normalize_thinking_levels(levels: &[ThinkingLevel]) -> Vec<ThinkingLevel> {
246    let mut normalized = Vec::new();
247    if !levels.is_empty() {
248        normalized.push(ThinkingLevel::Default);
249    }
250    for candidate in ThinkingLevel::ALL {
251        if candidate != ThinkingLevel::Default
252            && levels.contains(&candidate)
253            && !normalized.contains(&candidate)
254        {
255            normalized.push(candidate);
256        }
257    }
258    if normalized.is_empty() {
259        default_thinking_levels()
260    } else {
261        normalized
262    }
263}
264
265pub(crate) fn resolve_thinking_level(
266    available: &[ThinkingLevel],
267    selected: ThinkingLevel,
268) -> ThinkingLevel {
269    if available.contains(&selected) {
270        selected
271    } else {
272        ThinkingLevel::Default
273    }
274}
275
276pub(crate) fn next_thinking_level(
277    available: &[ThinkingLevel],
278    selected: ThinkingLevel,
279) -> Option<ThinkingLevel> {
280    if available.len() <= 1 {
281        return None;
282    }
283    let current = available
284        .iter()
285        .position(|level| *level == selected)
286        .unwrap_or(0);
287    Some(available[(current + 1) % available.len()])
288}