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}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn thinking_levels_parse_display_and_cycle_in_stable_order() {
300 assert_eq!(
301 "default".parse::<ThinkingLevel>().unwrap(),
302 ThinkingLevel::Default
303 );
304 assert_eq!("low".parse::<ThinkingLevel>().unwrap(), ThinkingLevel::Low);
305 assert_eq!(
306 "xhigh".parse::<ThinkingLevel>().unwrap(),
307 ThinkingLevel::XHigh
308 );
309 assert_eq!("max".parse::<ThinkingLevel>().unwrap(), ThinkingLevel::Max);
310 assert_eq!(ThinkingLevel::Medium.to_string(), "medium");
311 assert_eq!(ThinkingLevel::XHigh.to_string(), "xhigh");
312 assert_eq!(ThinkingLevel::Max.to_string(), "max");
313 assert_eq!(ThinkingLevel::High.label(), "High");
314 assert_eq!(ThinkingLevel::XHigh.label(), "XHigh");
315 assert!("invalid".parse::<ThinkingLevel>().is_err());
316 assert_eq!(
317 ThinkingLevel::ALL,
318 [
319 ThinkingLevel::Default,
320 ThinkingLevel::Low,
321 ThinkingLevel::Medium,
322 ThinkingLevel::High,
323 ThinkingLevel::XHigh,
324 ThinkingLevel::Max,
325 ]
326 );
327 }
328
329 #[test]
330 fn built_in_thinking_levels_match_model_capabilities_for_builtin_scope() {
331 assert_eq!(
332 available_thinking_levels(
333 crate::providers::OPENAI_CODEX_PROVIDER,
334 "gpt-5.5",
335 None,
336 ThinkingCapabilityScope::BuiltIn,
337 ),
338 ThinkingLevel::EFFORT_FULL.to_vec()
339 );
340 assert_eq!(
341 available_thinking_levels("zai", "glm-5.2", None, ThinkingCapabilityScope::BuiltIn,),
342 ThinkingLevel::HIGH_MAX.to_vec()
343 );
344 assert_eq!(
345 available_thinking_levels(
346 "custom",
347 "zai/glm-5.2",
348 None,
349 ThinkingCapabilityScope::BuiltIn,
350 ),
351 ThinkingLevel::HIGH_MAX.to_vec()
352 );
353 for model in [
354 "claude-sonnet-4-5-20250929",
355 "claude-opus-4-1-20250805",
356 "claude-haiku-4-5-20251001",
357 "claude-3-7-sonnet-20250219",
358 "claude-fable-5-1",
359 "claude-future-family-9",
360 ] {
361 assert_eq!(
362 available_thinking_levels(
363 crate::providers::ANTHROPIC_PROVIDER,
364 model,
365 None,
366 ThinkingCapabilityScope::BuiltIn,
367 ),
368 ThinkingLevel::HIGH_MAX.to_vec(),
369 "{model}"
370 );
371 }
372 for model in ["gpt-5", "gpt-5-nano", "o3", "o4-mini"] {
373 assert_eq!(
374 available_thinking_levels(
375 crate::providers::OPENAI_CODEX_PROVIDER,
376 model,
377 None,
378 ThinkingCapabilityScope::BuiltIn,
379 ),
380 ThinkingLevel::EFFORT_GENERIC.to_vec()
381 );
382 }
383 }
384
385 #[test]
386 fn unsupported_models_and_unsupported_selected_levels_fall_back_to_default() {
387 let available = available_thinking_levels(
388 crate::providers::OPENAI_CODEX_PROVIDER,
389 "gpt-4.1",
390 None,
391 ThinkingCapabilityScope::BuiltIn,
392 );
393 assert_eq!(available, vec![ThinkingLevel::Default]);
394 assert_eq!(
395 available_thinking_levels("custom", "gpt-5", None, ThinkingCapabilityScope::BuiltIn,),
396 vec![ThinkingLevel::Default]
397 );
398 assert_eq!(
399 resolve_thinking_level(&available, ThinkingLevel::High),
400 ThinkingLevel::Default
401 );
402 assert_eq!(
403 next_thinking_level(&available, ThinkingLevel::Default),
404 None
405 );
406 }
407
408 #[test]
409 fn catalog_precedence_uses_explicit_builtin_boolean_then_default() {
410 let explicit = CatalogThinkingMetadata {
411 reasoning_efforts: Some(vec![
412 ThinkingLevel::Max,
413 ThinkingLevel::High,
414 ThinkingLevel::High,
415 ]),
416 supports_reasoning: Some(true),
417 };
418 let available = available_thinking_levels(
419 crate::providers::OPENAI_CODEX_PROVIDER,
420 "gpt-5.5",
421 Some(&explicit),
422 ThinkingCapabilityScope::BuiltIn,
423 );
424 assert_eq!(
425 available,
426 vec![
427 ThinkingLevel::Default,
428 ThinkingLevel::High,
429 ThinkingLevel::Max
430 ]
431 );
432 let boolean = CatalogThinkingMetadata {
433 reasoning_efforts: None,
434 supports_reasoning: Some(true),
435 };
436 assert_eq!(
437 available_thinking_levels(
438 crate::providers::OPENAI_CODEX_PROVIDER,
439 "gpt-5.5",
440 Some(&boolean),
441 ThinkingCapabilityScope::BuiltIn,
442 ),
443 ThinkingLevel::EFFORT_FULL.to_vec()
444 );
445 let negative = CatalogThinkingMetadata {
446 reasoning_efforts: None,
447 supports_reasoning: Some(false),
448 };
449 assert_eq!(
450 available_thinking_levels(
451 crate::providers::OPENAI_CODEX_PROVIDER,
452 "gpt-5",
453 Some(&negative),
454 ThinkingCapabilityScope::BuiltIn,
455 ),
456 default_thinking_levels()
457 );
458 assert_eq!(
459 available_thinking_levels(
460 "zai",
461 "glm-5.2",
462 Some(&boolean),
463 ThinkingCapabilityScope::BuiltIn,
464 ),
465 ThinkingLevel::HIGH_MAX.to_vec()
466 );
467 assert_eq!(
468 available_thinking_levels(
469 "custom",
470 "unknown",
471 Some(&boolean),
472 ThinkingCapabilityScope::BuiltIn,
473 ),
474 ThinkingLevel::EFFORT_GENERIC.to_vec()
475 );
476 assert_eq!(
477 resolve_thinking_level(&available, ThinkingLevel::Max),
478 ThinkingLevel::Max
479 );
480 assert_eq!(
481 resolve_thinking_level(&available, ThinkingLevel::XHigh),
482 ThinkingLevel::Default
483 );
484 assert_eq!(
485 next_thinking_level(&available, ThinkingLevel::Default),
486 Some(ThinkingLevel::High)
487 );
488 assert_eq!(
489 next_thinking_level(&available, ThinkingLevel::High),
490 Some(ThinkingLevel::Max)
491 );
492 assert_eq!(
493 next_thinking_level(&available, ThinkingLevel::Max),
494 Some(ThinkingLevel::Default)
495 );
496 }
497
498 #[test]
499 fn custom_gpt_like_provider_requires_reasoning_metadata() {
500 let generic =
501 ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::GptLike);
502 for model in ["unknown", "gpt-5", "gpt-5.6", "zai/glm-5.2", "extra-model"] {
503 assert_eq!(
504 available_thinking_levels("custom-provider", model, None, generic),
505 default_thinking_levels(),
506 "{model}"
507 );
508 }
509 }
510
511 #[test]
512 fn custom_gpt_like_provider_boolean_metadata_controls_generic_levels() {
513 let generic =
514 ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::GptLike);
515 for supports_reasoning in [Some(true), Some(false), None] {
516 let catalog = CatalogThinkingMetadata {
517 reasoning_efforts: None,
518 supports_reasoning,
519 };
520 assert_eq!(
521 available_thinking_levels("custom-provider", "alias", Some(&catalog), generic),
522 if supports_reasoning == Some(true) {
523 ThinkingLevel::EFFORT_GENERIC.to_vec()
524 } else {
525 default_thinking_levels()
526 },
527 "supports_reasoning={supports_reasoning:?}"
528 );
529 }
530 }
531
532 #[test]
533 fn explicit_catalog_efforts_normalize_and_override_custom_fallback() {
534 let generic =
535 ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::GptLike);
536 let catalog = CatalogThinkingMetadata {
537 reasoning_efforts: Some(vec![
538 ThinkingLevel::Max,
539 ThinkingLevel::High,
540 ThinkingLevel::High,
541 ]),
542 supports_reasoning: Some(false),
543 };
544 assert_eq!(
545 available_thinking_levels("custom-provider", "alias", Some(&catalog), generic),
546 vec![
547 ThinkingLevel::Default,
548 ThinkingLevel::High,
549 ThinkingLevel::Max
550 ]
551 );
552 let xhigh = CatalogThinkingMetadata {
553 reasoning_efforts: Some(vec![ThinkingLevel::XHigh]),
554 supports_reasoning: None,
555 };
556 assert_eq!(
557 available_thinking_levels("custom-provider", "alias", Some(&xhigh), generic),
558 vec![ThinkingLevel::Default, ThinkingLevel::XHigh]
559 );
560 }
561
562 #[test]
563 fn empty_catalog_efforts_behave_as_absent_metadata() {
564 let generic =
565 ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::GptLike);
566 let empty = CatalogThinkingMetadata {
567 reasoning_efforts: Some(Vec::new()),
568 supports_reasoning: Some(false),
569 };
570 assert_eq!(
571 available_thinking_levels("custom-provider", "alias", Some(&empty), generic),
572 default_thinking_levels()
573 );
574 }
575
576 #[test]
577 fn custom_gpt_like_scope_does_not_infer_capability_from_model_name() {
578 let generic =
579 ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::GptLike);
580 assert_eq!(
581 available_thinking_levels("custom-provider", "zai/glm-5.2", None, generic),
582 default_thinking_levels()
583 );
584 let anthropic =
585 ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::AnthropicLike);
586 assert_eq!(
587 available_thinking_levels("custom-provider", "zai/glm-5.2", None, anthropic),
588 ThinkingLevel::HIGH_MAX.to_vec()
589 );
590 }
591
592 #[test]
593 fn custom_anthropic_like_intersects_with_default_high_max() {
594 let anthropic =
595 ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::AnthropicLike);
596 let catalog = CatalogThinkingMetadata {
597 reasoning_efforts: Some(vec![
598 ThinkingLevel::Low,
599 ThinkingLevel::High,
600 ThinkingLevel::Max,
601 ]),
602 supports_reasoning: None,
603 };
604 assert_eq!(
605 available_thinking_levels("custom-provider", "alias", Some(&catalog), anthropic),
606 vec![
607 ThinkingLevel::Default,
608 ThinkingLevel::High,
609 ThinkingLevel::Max
610 ]
611 );
612 assert_eq!(
613 resolve_thinking_level(
614 &available_thinking_levels("custom-provider", "alias", Some(&catalog), anthropic,),
615 ThinkingLevel::Low,
616 ),
617 ThinkingLevel::Default
618 );
619 }
620
621 #[test]
622 fn persisted_unsupported_level_clamps_non_destructively() {
623 let generic =
624 ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::GptLike);
625 let available = available_thinking_levels("custom-provider", "alias", None, generic);
626 assert_eq!(
627 resolve_thinking_level(&available, ThinkingLevel::XHigh),
628 ThinkingLevel::Default
629 );
630 assert!(!available.contains(&ThinkingLevel::High));
631 assert_eq!(
632 resolve_thinking_level(&available, ThinkingLevel::High),
633 ThinkingLevel::Default
634 );
635 let xhigh_catalog = CatalogThinkingMetadata {
636 reasoning_efforts: Some(vec![ThinkingLevel::XHigh]),
637 supports_reasoning: None,
638 };
639 let xhigh_available =
640 available_thinking_levels("custom-provider", "alias", Some(&xhigh_catalog), generic);
641 assert_eq!(
642 resolve_thinking_level(&xhigh_available, ThinkingLevel::XHigh),
643 ThinkingLevel::XHigh
644 );
645 }
646
647 #[test]
648 fn capability_gate_requires_reasoning_metadata() {
649 let generic =
650 ThinkingCapabilityScope::Custom(crate::config::CustomReasoningProtocol::GptLike);
651 let multi = available_thinking_levels(
652 "custom-provider",
653 "alias",
654 Some(&CatalogThinkingMetadata {
655 reasoning_efforts: None,
656 supports_reasoning: Some(true),
657 }),
658 generic,
659 );
660 assert!(multi.len() > 1);
661 assert!(multi.contains(&ThinkingLevel::High));
662 assert_eq!(
663 resolve_thinking_level(&multi, ThinkingLevel::High),
664 ThinkingLevel::High
665 );
666 let single = ThinkingCapabilityScope::BuiltIn;
667 let none = available_thinking_levels("custom-provider", "alias", None, single);
668 assert_eq!(none, vec![ThinkingLevel::Default]);
669 assert!(none.len() <= 1);
670 }
671}