1use crate::tool::{ToolDefinition, ToolInvocation};
2use anyhow::Result;
3use async_trait::async_trait;
4use futures_util::StreamExt;
5use futures_util::stream::BoxStream;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16#[serde(tag = "type", rename_all = "snake_case")]
17pub enum ContentPart {
18 Text {
20 text: String,
22 },
23 Image {
25 media_type: String,
27 data: String,
29 },
30 Audio {
32 media_type: String,
34 data: String,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 name: Option<String>,
39 },
40 Video {
42 media_type: String,
44 data: String,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 name: Option<String>,
49 },
50 Document {
52 media_type: String,
54 data: String,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 name: Option<String>,
59 },
60}
61
62impl ContentPart {
63 pub fn is_text(&self) -> bool {
65 matches!(self, Self::Text { .. })
66 }
67
68 pub fn is_image(&self) -> bool {
70 matches!(self, Self::Image { .. })
71 }
72
73 pub fn is_audio(&self) -> bool {
75 matches!(self, Self::Audio { .. })
76 }
77
78 pub fn is_video(&self) -> bool {
80 matches!(self, Self::Video { .. })
81 }
82
83 pub fn is_document(&self) -> bool {
85 matches!(self, Self::Document { .. })
86 }
87
88 pub fn attachment_kind(&self) -> Option<AttachmentKind> {
90 match self {
91 Self::Text { .. } => None,
92 Self::Image { .. } => Some(AttachmentKind::Image),
93 Self::Audio { .. } => Some(AttachmentKind::Audio),
94 Self::Video { .. } => Some(AttachmentKind::Video),
95 Self::Document { .. } => Some(AttachmentKind::Document),
96 }
97 }
98
99 pub fn media_type(&self) -> Option<&str> {
101 match self {
102 Self::Text { .. } => None,
103 Self::Image { media_type, .. }
104 | Self::Audio { media_type, .. }
105 | Self::Video { media_type, .. }
106 | Self::Document { media_type, .. } => Some(media_type),
107 }
108 }
109
110 pub fn data(&self) -> Option<&str> {
112 match self {
113 Self::Text { .. } => None,
114 Self::Image { data, .. }
115 | Self::Audio { data, .. }
116 | Self::Video { data, .. }
117 | Self::Document { data, .. } => Some(data),
118 }
119 }
120
121 pub fn name(&self) -> Option<&str> {
123 match self {
124 Self::Audio { name, .. } | Self::Video { name, .. } | Self::Document { name, .. } => {
125 name.as_deref()
126 }
127 Self::Text { .. } | Self::Image { .. } => None,
128 }
129 }
130
131 pub fn as_text(&self) -> Option<&str> {
133 match self {
134 Self::Text { text } => Some(text),
135 _ => None,
136 }
137 }
138
139 pub fn text_from_parts(parts: &[ContentPart]) -> String {
141 parts
142 .iter()
143 .filter_map(|p| p.as_text())
144 .collect::<Vec<_>>()
145 .join("")
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum AttachmentKind {
153 Image,
154 Audio,
155 Video,
156 Document,
157}
158
159impl AttachmentKind {
160 pub fn as_str(self) -> &'static str {
161 match self {
162 Self::Image => "image",
163 Self::Audio => "audio",
164 Self::Video => "video",
165 Self::Document => "document",
166 }
167 }
168}
169
170#[async_trait]
176pub trait ModelProvider: Send + Sync {
177 fn stream(&self, request: ModelRequest) -> ModelStream;
179
180 async fn complete(&self, request: ModelRequest) -> Result<ModelResponse> {
183 let mut stream = self.stream(request);
184 let mut text = String::new();
185
186 while let Some(event) = stream.next().await {
187 match event? {
188 ModelStreamEvent::TextDelta { text: delta } => text.push_str(&delta),
189 ModelStreamEvent::Done => break,
190 ModelStreamEvent::Status { .. }
191 | ModelStreamEvent::Usage { .. }
192 | ModelStreamEvent::ThinkingDelta { .. }
193 | ModelStreamEvent::ToolCall(_) => {}
194 }
195 }
196
197 Ok(ModelResponse { text })
198 }
199
200 async fn list_models(&self) -> Result<Vec<String>> {
204 anyhow::bail!("listing models is not supported by this provider")
205 }
206}
207
208pub type ModelStream = BoxStream<'static, Result<ModelStreamEvent>>;
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct ModelRequest {
215 pub model: String,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub instructions: Option<String>,
224 pub messages: Vec<ModelMessage>,
226 pub thinking: ThinkingConfig,
228 #[serde(default)]
230 pub tools: Vec<ToolDefinition>,
231 #[serde(default, skip_serializing, skip_deserializing)]
237 pub session_id: Option<String>,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct ModelMessage {
247 pub role: ModelRole,
249 pub content: String,
251 #[serde(default, skip_serializing_if = "Vec::is_empty")]
257 pub content_parts: Vec<ContentPart>,
258 #[serde(default)]
260 pub tool_call_id: Option<String>,
261 #[serde(default)]
263 pub tool_name: Option<String>,
264 #[serde(default)]
266 pub tool_calls: Vec<ToolInvocation>,
267 #[serde(default, skip_serializing, skip_deserializing)]
269 pub created_at: Option<u64>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub thinking_content: Option<String>,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "lowercase")]
278pub enum ModelRole {
279 System,
281 Developer,
284 User,
286 Assistant,
288 Tool,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct ModelResponse {
295 pub text: String,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
301pub enum ModelStreamEvent {
302 TextDelta {
304 text: String,
306 },
307 ThinkingDelta {
309 text: String,
311 },
312 Status {
314 label: String,
316 },
317 Usage {
319 input_tokens: Option<u64>,
321 output_tokens: Option<u64>,
323 cache_creation_tokens: Option<u64>,
325 cache_read_tokens: Option<u64>,
327 },
328 ToolCall(ToolInvocation),
330 Done,
332}
333
334impl ModelMessage {
335 pub fn system(content: impl Into<String>) -> Self {
337 Self::new(ModelRole::System, content)
338 }
339
340 pub fn developer(content: impl Into<String>) -> Self {
343 Self::new(ModelRole::Developer, content)
344 }
345
346 pub fn user(content: impl Into<String>) -> Self {
348 Self::new(ModelRole::User, content)
349 }
350
351 pub fn user_multimodal(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
353 Self {
354 role: ModelRole::User,
355 content: content.into(),
356 content_parts: parts,
357 tool_call_id: None,
358 tool_name: None,
359 tool_calls: Vec::new(),
360 created_at: Some(current_unix_millis()),
361 thinking_content: None,
362 }
363 }
364
365 pub fn assistant(content: impl Into<String>) -> Self {
367 Self {
368 thinking_content: None,
369 ..Self::new(ModelRole::Assistant, content)
370 }
371 }
372
373 pub fn assistant_with_thinking(content: impl Into<String>, thinking: Option<String>) -> Self {
375 Self {
376 thinking_content: thinking,
377 ..Self::new(ModelRole::Assistant, content)
378 }
379 }
380
381 pub fn tool_result(
383 tool_call_id: impl Into<String>,
384 tool_name: impl Into<String>,
385 content: impl Into<String>,
386 ) -> Self {
387 Self::tool_result_with_parts(tool_call_id, tool_name, content, Vec::new())
388 }
389
390 pub fn tool_result_with_parts(
393 tool_call_id: impl Into<String>,
394 tool_name: impl Into<String>,
395 content: impl Into<String>,
396 content_parts: Vec<ContentPart>,
397 ) -> Self {
398 Self {
399 role: ModelRole::Tool,
400 content: content.into(),
401 content_parts,
402 tool_call_id: Some(tool_call_id.into()),
403 tool_name: Some(tool_name.into()),
404 tool_calls: Vec::new(),
405 created_at: Some(current_unix_millis()),
406 thinking_content: None,
407 }
408 }
409
410 pub fn assistant_tool_call(invocation: ToolInvocation) -> Self {
412 Self::assistant_tool_call_with_context(invocation, String::new(), None)
413 }
414
415 pub fn assistant_tool_call_with_context(
418 invocation: ToolInvocation,
419 content: impl Into<String>,
420 thinking: Option<String>,
421 ) -> Self {
422 Self::assistant_tool_calls_with_context(vec![invocation], content, thinking)
423 }
424
425 pub fn assistant_tool_calls_with_context(
426 invocations: Vec<ToolInvocation>,
427 content: impl Into<String>,
428 thinking: Option<String>,
429 ) -> Self {
430 Self {
431 role: ModelRole::Assistant,
432 content: content.into(),
433 content_parts: Vec::new(),
434 tool_call_id: None,
435 tool_name: None,
436 tool_calls: invocations,
437 created_at: Some(current_unix_millis()),
438 thinking_content: thinking,
439 }
440 }
441
442 fn new(role: ModelRole, content: impl Into<String>) -> Self {
443 Self {
444 role,
445 content: content.into(),
446 content_parts: Vec::new(),
447 tool_call_id: None,
448 tool_name: None,
449 tool_calls: Vec::new(),
450 created_at: Some(current_unix_millis()),
451 thinking_content: None,
452 }
453 }
454}
455
456fn current_unix_millis() -> u64 {
457 std::time::SystemTime::now()
458 .duration_since(std::time::UNIX_EPOCH)
459 .unwrap_or_default()
460 .as_millis() as u64
461}
462
463#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
472#[serde(rename_all = "lowercase")]
473pub enum ThinkingConfig {
474 #[serde(alias = "adaptive", alias = "auto")]
478 Max,
479 High,
481 Medium,
483 Low,
485 Off,
487}
488
489#[derive(Debug, Clone, PartialEq, Eq)]
494pub struct ThinkingRequest {
495 pub enabled: bool,
497 pub effort: Option<String>,
501 pub budget_tokens: Option<u32>,
504}
505
506impl ThinkingConfig {
507 pub fn to_thinking_request(self) -> ThinkingRequest {
512 match self {
513 Self::Max => ThinkingRequest {
514 enabled: true,
515 effort: Some("xhigh".to_string()),
518 budget_tokens: Some(32000),
519 },
520 Self::High => ThinkingRequest {
521 enabled: true,
522 effort: Some("high".to_string()),
523 budget_tokens: Some(10000),
524 },
525 Self::Medium => ThinkingRequest {
526 enabled: true,
527 effort: Some("medium".to_string()),
528 budget_tokens: Some(4096),
529 },
530 Self::Low => ThinkingRequest {
531 enabled: true,
532 effort: Some("low".to_string()),
533 budget_tokens: Some(1024),
534 },
535 Self::Off => ThinkingRequest {
536 enabled: false,
537 effort: None,
538 budget_tokens: None,
539 },
540 }
541 }
542
543 pub fn as_config_str(self) -> &'static str {
545 match self {
546 Self::Max => "max",
547 Self::High => "high",
548 Self::Medium => "medium",
549 Self::Low => "low",
550 Self::Off => "off",
551 }
552 }
553
554 pub fn from_config_str(value: &str) -> Self {
558 parse_reasoning_level(value).unwrap_or(Self::Max)
559 }
560
561 pub fn clamp_to_supported(self, supported: &[ThinkingConfig]) -> Self {
566 if supported.is_empty() || supported.contains(&self) {
567 return self;
568 }
569 for candidate in [Self::Max, Self::High, Self::Medium, Self::Low, Self::Off] {
571 if supported.contains(&candidate) {
572 return candidate;
573 }
574 }
575 supported[0]
576 }
577}
578
579pub fn parse_reasoning_level(raw: &str) -> Option<ThinkingConfig> {
585 match raw.trim().to_ascii_lowercase().as_str() {
586 "adaptive" | "auto" | "max" | "xhigh" | "x-high" | "ultra" | "highest" => {
588 Some(ThinkingConfig::Max)
589 }
590 "high" => Some(ThinkingConfig::High),
591 "medium" | "med" | "mid" | "default" => Some(ThinkingConfig::Medium),
593 "on" | "enabled" | "true" | "1" => Some(ThinkingConfig::Max),
594 "low" | "minimal" | "min" => Some(ThinkingConfig::Low),
595 "off" | "none" | "disabled" | "false" | "0" => Some(ThinkingConfig::Off),
596 _ => None,
597 }
598}
599
600pub fn effort_display_label(level: ThinkingConfig, binary: bool) -> &'static str {
605 if binary {
606 match level {
607 ThinkingConfig::Off => "thinking off",
608 _ => "thinking on",
609 }
610 } else {
611 level.as_config_str()
612 }
613}
614
615pub const DEFAULT_REASONING_LEVELS: &[ThinkingConfig] = &[
617 ThinkingConfig::Max,
618 ThinkingConfig::High,
619 ThinkingConfig::Medium,
620 ThinkingConfig::Low,
621 ThinkingConfig::Off,
622];
623
624pub const BINARY_REASONING_LEVELS: &[ThinkingConfig] = &[ThinkingConfig::Max, ThinkingConfig::Off];
629
630pub fn thinking_levels_for_model(
637 supports_thinking: Option<bool>,
638 reasoning_levels: &[String],
639) -> Vec<ThinkingConfig> {
640 if supports_thinking == Some(false) {
641 return vec![ThinkingConfig::Off];
642 }
643
644 if reasoning_levels.is_empty() {
645 return BINARY_REASONING_LEVELS.to_vec();
646 }
647
648 let mut out = Vec::new();
649 for raw in reasoning_levels {
650 if let Some(level) = parse_reasoning_level(raw) {
651 if !out.contains(&level) {
652 out.push(level);
653 }
654 }
655 }
656 if out.is_empty() {
657 return BINARY_REASONING_LEVELS.to_vec();
658 }
659 let order = DEFAULT_REASONING_LEVELS;
661 out.sort_by_key(|l| order.iter().position(|o| o == l).unwrap_or(99));
662 out
663}
664
665pub fn is_binary_effort_model(
667 supports_thinking: Option<bool>,
668 reasoning_levels: &[String],
669) -> bool {
670 if supports_thinking == Some(false) {
671 return false;
672 }
673 if reasoning_levels.is_empty() {
674 return true;
675 }
676 !reasoning_levels
678 .iter()
679 .any(|raw| parse_reasoning_level(raw).is_some())
680}
681
682pub fn resolve_model_thinking_level(
689 current: ThinkingConfig,
690 supports_thinking: Option<bool>,
691 reasoning_levels: &[String],
692 default_reasoning_effort: Option<&str>,
693) -> ThinkingConfig {
694 let supported = thinking_levels_for_model(supports_thinking, reasoning_levels);
695 if supports_thinking == Some(false) {
696 return ThinkingConfig::Off;
697 }
698 if supported.contains(¤t) {
699 return current;
700 }
701 if let Some(def) = default_reasoning_effort.and_then(parse_reasoning_level) {
702 return def.clamp_to_supported(&supported);
703 }
704 ThinkingConfig::Max.clamp_to_supported(&supported)
706}
707
708pub fn resolve_effort_label(
712 thinking: ThinkingConfig,
713 reasoning_levels: &[String],
714 provider_id: &str,
715) -> Option<String> {
716 if matches!(thinking, ThinkingConfig::Off) {
717 return None;
718 }
719 let concrete = thinking;
720
721 for raw in reasoning_levels {
723 if parse_reasoning_level(raw) == Some(concrete) {
724 return Some(raw.trim().to_ascii_lowercase());
725 }
726 }
727
728 let provider = crate::ProviderId::from_config_id(provider_id);
730 if provider.as_str() == crate::ProviderId::OPENROUTER {
731 return Some(
732 match concrete {
733 ThinkingConfig::Max => "xhigh",
734 ThinkingConfig::High => "high",
735 ThinkingConfig::Medium => "medium",
736 ThinkingConfig::Low => "low",
737 ThinkingConfig::Off => "medium",
738 }
739 .to_string(),
740 );
741 }
742
743 Some(
744 match concrete {
745 ThinkingConfig::Max => {
746 if reasoning_levels
748 .iter()
749 .any(|l| matches!(l.trim().to_ascii_lowercase().as_str(), "xhigh" | "max"))
750 {
751 "xhigh"
752 } else {
753 "high"
754 }
755 }
756 ThinkingConfig::High => "high",
757 ThinkingConfig::Medium => "medium",
758 ThinkingConfig::Low => "low",
759 ThinkingConfig::Off => return None,
760 }
761 .to_string(),
762 )
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768
769 #[test]
772 fn regression_thinking_request_high_produces_effort_and_budget() {
773 let request = ThinkingConfig::High.to_thinking_request();
774 assert!(request.enabled);
775 assert_eq!(request.effort.as_deref(), Some("high"));
776 assert_eq!(request.budget_tokens, Some(10000));
777 }
778
779 #[test]
780 fn regression_thinking_request_max_produces_effort_and_budget() {
781 let request = ThinkingConfig::Max.to_thinking_request();
782 assert!(request.enabled);
783 assert_eq!(request.effort.as_deref(), Some("xhigh"));
784 assert_eq!(request.budget_tokens, Some(32000));
785 }
786
787 #[test]
788 fn regression_thinking_request_off_produces_disabled() {
789 let request = ThinkingConfig::Off.to_thinking_request();
790 assert!(!request.enabled);
791 assert!(request.effort.is_none());
792 assert!(request.budget_tokens.is_none());
793 }
794
795 #[test]
796 fn regression_thinking_request_medium_produces_medium_effort() {
797 let request = ThinkingConfig::Medium.to_thinking_request();
798 assert!(request.enabled);
799 assert_eq!(request.effort.as_deref(), Some("medium"));
800 assert_eq!(request.budget_tokens, Some(4096));
801 }
802
803 #[test]
804 fn regression_thinking_request_low_produces_low_effort() {
805 let request = ThinkingConfig::Low.to_thinking_request();
806 assert!(request.enabled);
807 assert_eq!(request.effort.as_deref(), Some("low"));
808 assert_eq!(request.budget_tokens, Some(1024));
809 }
810
811 #[test]
812 fn thinking_levels_for_model_respects_registry() {
813 let levels = thinking_levels_for_model(
814 Some(true),
815 &["none".into(), "low".into(), "high".into(), "xhigh".into()],
816 );
817 assert_eq!(
819 levels,
820 vec![
821 ThinkingConfig::Max,
822 ThinkingConfig::High,
823 ThinkingConfig::Low,
824 ThinkingConfig::Off,
825 ]
826 );
827 assert!(!is_binary_effort_model(
828 Some(true),
829 &["none".into(), "low".into(), "high".into(), "xhigh".into()],
830 ));
831 }
832
833 #[test]
834 fn thinking_levels_off_only_when_no_thinking() {
835 let levels = thinking_levels_for_model(Some(false), &["high".into()]);
836 assert_eq!(levels, vec![ThinkingConfig::Off]);
837 assert!(!is_binary_effort_model(Some(false), &["high".into()]));
838 }
839
840 #[test]
841 fn thinking_levels_binary_when_registry_empty() {
842 let levels = thinking_levels_for_model(Some(true), &[]);
843 assert_eq!(levels, vec![ThinkingConfig::Max, ThinkingConfig::Off]);
844 assert!(is_binary_effort_model(Some(true), &[]));
845 assert!(is_binary_effort_model(None, &[]));
846 }
847
848 #[test]
849 fn thinking_levels_model_specific_no_extra_options() {
850 let levels = thinking_levels_for_model(Some(true), &["low".into(), "high".into()]);
851 assert_eq!(levels, vec![ThinkingConfig::High, ThinkingConfig::Low]);
852 }
853
854 #[test]
855 fn resolve_effort_prefers_registry_label() {
856 let label = resolve_effort_label(
857 ThinkingConfig::Max,
858 &["low".into(), "high".into(), "xhigh".into()],
859 "openai",
860 );
861 assert_eq!(label.as_deref(), Some("xhigh"));
862 }
863
864 #[test]
865 fn clamp_unsupported_level_to_supported() {
866 let supported = vec![ThinkingConfig::Low, ThinkingConfig::Off];
867 assert_eq!(
868 ThinkingConfig::High.clamp_to_supported(&supported),
869 ThinkingConfig::Low
870 );
871 }
872
873 #[test]
876 fn regression_system_message_has_correct_role() {
877 let msg = ModelMessage::system("test".to_string());
878 assert_eq!(msg.role, ModelRole::System);
879 assert_eq!(msg.content, "test");
880 }
881
882 #[test]
883 fn regression_user_message_has_correct_role() {
884 let msg = ModelMessage::user("hello".to_string());
885 assert_eq!(msg.role, ModelRole::User);
886 assert_eq!(msg.content, "hello");
887 }
888
889 #[test]
890 fn regression_assistant_message_has_correct_role() {
891 let msg = ModelMessage::assistant("response".to_string());
892 assert_eq!(msg.role, ModelRole::Assistant);
893 assert_eq!(msg.content, "response");
894 }
895
896 #[test]
897 fn regression_tool_result_sets_call_id_and_name() {
898 let msg = ModelMessage::tool_result("call-1", "read_file", "content");
899 assert_eq!(msg.role, ModelRole::Tool);
900 assert_eq!(msg.tool_call_id.as_deref(), Some("call-1"));
901 assert_eq!(msg.tool_name.as_deref(), Some("read_file"));
902 assert_eq!(msg.content, "content");
903 }
904
905 #[test]
906 fn regression_assistant_tool_call_with_context_sets_fields() {
907 let inv = ToolInvocation {
908 id: "call-1".to_string(),
909 tool_name: "read_file".to_string(),
910 input: serde_json::json!({"path": "test.rs"}),
911 };
912 let msg = ModelMessage::assistant_tool_call_with_context(
913 inv,
914 "thinking text",
915 Some("reasoning".to_string()),
916 );
917 assert_eq!(msg.role, ModelRole::Assistant);
918 assert_eq!(msg.content, "thinking text");
919 assert_eq!(msg.thinking_content.as_deref(), Some("reasoning"));
920 assert_eq!(msg.tool_calls.len(), 1);
921 assert_eq!(msg.tool_calls[0].id, "call-1");
922 }
923
924 #[test]
927 fn regression_model_message_serialization_roundtrip() {
928 let msg = ModelMessage {
929 role: ModelRole::Assistant,
930 content: "hello".to_string(),
931 content_parts: Vec::new(),
932 tool_call_id: None,
933 tool_name: None,
934 tool_calls: vec![],
935 thinking_content: Some("thinking".to_string()),
936 created_at: Some(12345),
937 };
938 let json = serde_json::to_string(&msg).unwrap();
939 let deserialized: ModelMessage = serde_json::from_str(&json).unwrap();
940 assert_eq!(deserialized.role, msg.role);
941 assert_eq!(deserialized.content, msg.content);
942 assert_eq!(deserialized.thinking_content, msg.thinking_content);
943 assert!(deserialized.created_at.is_none());
945 }
946
947 #[test]
950 fn legacy_adaptive_string_maps_to_max() {
951 assert_eq!(
952 ThinkingConfig::from_config_str("adaptive"),
953 ThinkingConfig::Max
954 );
955 assert_eq!(parse_reasoning_level("auto"), Some(ThinkingConfig::Max));
956 assert_eq!(parse_reasoning_level("on"), Some(ThinkingConfig::Max));
957 let deserialized: ThinkingConfig = serde_json::from_str("\"adaptive\"").unwrap();
958 assert_eq!(deserialized, ThinkingConfig::Max);
959 }
960
961 #[test]
962 fn resolve_forces_off_when_model_lacks_reasoning() {
963 let resolved =
964 resolve_model_thinking_level(ThinkingConfig::Max, Some(false), &["high".into()], None);
965 assert_eq!(resolved, ThinkingConfig::Off);
966 }
967
968 #[test]
969 fn resolve_defaults_to_max_when_preference_unsupported() {
970 let resolved = resolve_model_thinking_level(
971 ThinkingConfig::Low,
972 Some(true),
973 &["high".into(), "xhigh".into()],
974 None,
975 );
976 assert_eq!(resolved, ThinkingConfig::Max);
977 }
978
979 #[test]
980 fn binary_on_is_max() {
981 assert_eq!(
982 BINARY_REASONING_LEVELS,
983 &[ThinkingConfig::Max, ThinkingConfig::Off]
984 );
985 assert_eq!(
986 effort_display_label(ThinkingConfig::Max, true),
987 "thinking on"
988 );
989 assert_eq!(
990 effort_display_label(ThinkingConfig::Off, true),
991 "thinking off"
992 );
993 }
994}