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