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}