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}