1use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone)]
15pub enum ThinkingMode {
16 Enabled { budget_tokens: u32 },
18 Adaptive,
20 Default,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum ThinkingDisplay {
33 Summarized,
35 Omitted,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum Effort {
44 Low,
45 Medium,
46 High,
47 XHigh,
48 Max,
49}
50
51#[derive(Debug, Clone)]
56pub struct ThinkingConfig {
57 pub mode: ThinkingMode,
59 pub effort: Option<Effort>,
61 pub display: ThinkingDisplay,
63}
64
65impl ThinkingConfig {
66 pub const DEFAULT_BUDGET_TOKENS: u32 = 10_000;
71
72 pub const MIN_BUDGET_TOKENS: u32 = 1_024;
74
75 #[must_use]
77 pub const fn new(budget_tokens: u32) -> Self {
78 Self {
79 mode: ThinkingMode::Enabled { budget_tokens },
80 effort: None,
81 display: ThinkingDisplay::Omitted,
82 }
83 }
84
85 #[must_use]
87 pub const fn adaptive() -> Self {
88 Self {
89 mode: ThinkingMode::Adaptive,
90 effort: None,
91 display: ThinkingDisplay::Omitted,
92 }
93 }
94
95 #[must_use]
97 pub const fn adaptive_with_effort(effort: Effort) -> Self {
98 Self {
99 mode: ThinkingMode::Adaptive,
100 effort: Some(effort),
101 display: ThinkingDisplay::Omitted,
102 }
103 }
104
105 #[must_use]
107 pub const fn default_with_effort(effort: Effort) -> Self {
108 Self {
109 mode: ThinkingMode::Default,
110 effort: Some(effort),
111 display: ThinkingDisplay::Omitted,
112 }
113 }
114
115 #[must_use]
117 pub const fn with_display(mut self, display: ThinkingDisplay) -> Self {
118 self.display = display;
119 self
120 }
121
122 #[must_use]
124 pub const fn with_effort(mut self, effort: Effort) -> Self {
125 self.effort = Some(effort);
126 self
127 }
128}
129
130impl Default for ThinkingConfig {
131 fn default() -> Self {
132 Self::new(Self::DEFAULT_BUDGET_TOKENS)
133 }
134}
135
136#[derive(Debug, Clone)]
140pub enum ToolChoice {
141 Auto,
143 Tool(String),
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct ResponseFormat {
162 pub name: String,
165 pub schema: serde_json::Value,
171 pub strict: bool,
175}
176
177impl ResponseFormat {
178 #[must_use]
183 pub fn new(name: impl Into<String>, schema: serde_json::Value) -> Self {
184 Self {
185 name: name.into(),
186 schema,
187 strict: true,
188 }
189 }
190
191 #[must_use]
193 pub const fn with_strict(mut self, strict: bool) -> Self {
194 self.strict = strict;
195 self
196 }
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum CacheTtl {
206 FiveMinutes,
208 OneHour,
210}
211
212impl CacheTtl {
213 #[must_use]
215 pub const fn as_wire_str(self) -> &'static str {
216 match self {
217 Self::FiveMinutes => "5m",
218 Self::OneHour => "1h",
219 }
220 }
221}
222
223#[derive(Debug, Clone)]
237pub struct CacheConfig {
238 pub enabled: bool,
240 pub ttl: Option<CacheTtl>,
242 pub max_breakpoints: Option<u8>,
244}
245
246impl Default for CacheConfig {
247 fn default() -> Self {
248 Self::enabled()
249 }
250}
251
252impl CacheConfig {
253 #[must_use]
256 pub const fn enabled() -> Self {
257 Self {
258 enabled: true,
259 ttl: None,
260 max_breakpoints: None,
261 }
262 }
263
264 #[must_use]
266 pub const fn disabled() -> Self {
267 Self {
268 enabled: false,
269 ttl: None,
270 max_breakpoints: None,
271 }
272 }
273
274 #[must_use]
276 pub const fn with_ttl(mut self, ttl: CacheTtl) -> Self {
277 self.ttl = Some(ttl);
278 self
279 }
280
281 #[must_use]
283 pub const fn with_max_breakpoints(mut self, max_breakpoints: u8) -> Self {
284 self.max_breakpoints = Some(max_breakpoints);
285 self
286 }
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
307#[serde(rename_all = "snake_case")]
308#[non_exhaustive]
309pub enum SpeedTier {
310 #[default]
312 Standard,
313 Fast,
315}
316
317impl SpeedTier {
318 #[must_use]
323 pub const fn is_premium(self) -> bool {
324 matches!(self, Self::Fast)
325 }
326
327 #[must_use]
329 pub const fn same(self, other: Self) -> bool {
330 matches!(
331 (self, other),
332 (Self::Standard, Self::Standard) | (Self::Fast, Self::Fast)
333 )
334 }
335}
336
337#[derive(Debug, Clone)]
338pub struct ChatRequest {
339 pub system: String,
340 pub messages: Vec<Message>,
341 pub tools: Option<Vec<Tool>>,
342 pub max_tokens: u32,
343 pub max_tokens_explicit: bool,
345 pub session_id: Option<String>,
347 pub cached_content: Option<String>,
351 pub thinking: Option<ThinkingConfig>,
353 pub tool_choice: Option<ToolChoice>,
357 pub response_format: Option<ResponseFormat>,
365 pub cache: Option<CacheConfig>,
371}
372
373impl ChatRequest {
374 pub const DEFAULT_MAX_TOKENS: u32 = 4096;
377
378 #[must_use]
394 pub fn new(system: impl Into<String>, messages: Vec<Message>) -> Self {
395 Self {
396 system: system.into(),
397 messages,
398 tools: None,
399 max_tokens: Self::DEFAULT_MAX_TOKENS,
400 max_tokens_explicit: false,
401 session_id: None,
402 cached_content: None,
403 thinking: None,
404 tool_choice: None,
405 response_format: None,
406 cache: None,
407 }
408 }
409
410 #[must_use]
412 pub fn with_tools(mut self, tools: Vec<Tool>) -> Self {
413 self.tools = Some(tools);
414 self
415 }
416
417 #[must_use]
419 pub const fn with_max_tokens(mut self, max_tokens: u32) -> Self {
420 self.max_tokens = max_tokens;
421 self.max_tokens_explicit = true;
422 self
423 }
424
425 #[must_use]
427 pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
428 self.session_id = Some(session_id.into());
429 self
430 }
431
432 #[must_use]
434 pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
435 self.thinking = Some(thinking);
436 self
437 }
438
439 #[must_use]
441 pub fn with_tool_choice(mut self, tool_choice: ToolChoice) -> Self {
442 self.tool_choice = Some(tool_choice);
443 self
444 }
445
446 #[must_use]
449 pub fn with_response_format(mut self, response_format: ResponseFormat) -> Self {
450 self.response_format = Some(response_format);
451 self
452 }
453
454 #[must_use]
456 pub const fn with_cache(mut self, cache: CacheConfig) -> Self {
457 self.cache = Some(cache);
458 self
459 }
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct Message {
464 pub role: Role,
465 pub content: Content,
466}
467
468impl Message {
469 #[must_use]
470 pub fn user(text: impl Into<String>) -> Self {
471 Self {
472 role: Role::User,
473 content: Content::Text(text.into()),
474 }
475 }
476
477 #[must_use]
478 pub const fn user_with_content(blocks: Vec<ContentBlock>) -> Self {
479 Self {
480 role: Role::User,
481 content: Content::Blocks(blocks),
482 }
483 }
484
485 #[must_use]
486 pub fn assistant(text: impl Into<String>) -> Self {
487 Self {
488 role: Role::Assistant,
489 content: Content::Text(text.into()),
490 }
491 }
492
493 #[must_use]
494 pub const fn assistant_with_content(blocks: Vec<ContentBlock>) -> Self {
495 Self {
496 role: Role::Assistant,
497 content: Content::Blocks(blocks),
498 }
499 }
500
501 #[must_use]
502 pub fn assistant_with_tool_use(
503 text: Option<String>,
504 id: impl Into<String>,
505 name: impl Into<String>,
506 input: serde_json::Value,
507 ) -> Self {
508 let mut blocks = Vec::new();
509 if let Some(t) = text {
510 blocks.push(ContentBlock::Text { text: t });
511 }
512 blocks.push(ContentBlock::ToolUse {
513 id: id.into(),
514 name: name.into(),
515 input,
516 thought_signature: None,
517 });
518 Self {
519 role: Role::Assistant,
520 content: Content::Blocks(blocks),
521 }
522 }
523
524 #[must_use]
525 pub fn tool_result(
526 tool_use_id: impl Into<String>,
527 content: impl Into<String>,
528 is_error: bool,
529 ) -> Self {
530 Self {
531 role: Role::User,
532 content: Content::Blocks(vec![ContentBlock::ToolResult {
533 tool_use_id: tool_use_id.into(),
534 content: content.into(),
535 is_error: if is_error { Some(true) } else { None },
536 }]),
537 }
538 }
539}
540
541#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
542#[serde(rename_all = "lowercase")]
543pub enum Role {
544 User,
545 Assistant,
546}
547
548#[derive(Debug, Clone, Serialize, Deserialize)]
549#[serde(untagged)]
550pub enum Content {
551 Text(String),
552 Blocks(Vec<ContentBlock>),
553}
554
555impl Content {
556 #[must_use]
557 pub fn first_text(&self) -> Option<&str> {
558 match self {
559 Self::Text(s) => Some(s),
560 Self::Blocks(blocks) => blocks.iter().find_map(|b| match b {
561 ContentBlock::Text { text } => Some(text.as_str()),
562 _ => None,
563 }),
564 }
565 }
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize)]
570pub struct ContentSource {
571 pub media_type: String,
572 pub data: String,
573}
574
575impl ContentSource {
576 #[must_use]
577 pub fn new(media_type: impl Into<String>, data: impl Into<String>) -> Self {
578 Self {
579 media_type: media_type.into(),
580 data: data.into(),
581 }
582 }
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize)]
586#[serde(tag = "type")]
587#[non_exhaustive]
588pub enum ContentBlock {
589 #[serde(rename = "text")]
590 Text { text: String },
591
592 #[serde(rename = "thinking")]
593 Thinking {
594 thinking: String,
595 #[serde(skip_serializing_if = "Option::is_none")]
597 signature: Option<String>,
598 },
599
600 #[serde(rename = "redacted_thinking")]
601 RedactedThinking { data: String },
602
603 #[serde(rename = "opaque_reasoning")]
611 OpaqueReasoning {
612 provider: String,
613 data: serde_json::Value,
614 },
615
616 #[serde(rename = "tool_use")]
617 ToolUse {
618 id: String,
619 name: String,
620 input: serde_json::Value,
621 #[serde(skip_serializing_if = "Option::is_none")]
624 thought_signature: Option<String>,
625 },
626
627 #[serde(rename = "tool_result")]
628 ToolResult {
629 tool_use_id: String,
630 content: String,
631 #[serde(skip_serializing_if = "Option::is_none")]
632 is_error: Option<bool>,
633 },
634
635 #[serde(rename = "image")]
636 Image { source: ContentSource },
637
638 #[serde(rename = "document")]
639 Document { source: ContentSource },
640}
641
642#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
643pub struct Tool {
644 pub name: String,
645 pub description: String,
646 pub input_schema: serde_json::Value,
647 pub display_name: String,
649 pub tier: super::types::ToolTier,
651}
652
653#[derive(Debug, Clone)]
654pub struct ChatResponse {
655 pub id: String,
656 pub content: Vec<ContentBlock>,
657 pub model: String,
658 pub stop_reason: Option<StopReason>,
659 pub usage: Usage,
660}
661
662impl ChatResponse {
663 #[must_use]
664 pub fn first_text(&self) -> Option<&str> {
665 self.content.iter().find_map(|b| match b {
666 ContentBlock::Text { text } => Some(text.as_str()),
667 _ => None,
668 })
669 }
670
671 #[must_use]
672 pub fn first_thinking(&self) -> Option<&str> {
673 self.content.iter().find_map(|b| match b {
674 ContentBlock::Thinking { thinking, .. } => Some(thinking.as_str()),
675 _ => None,
676 })
677 }
678
679 pub fn tool_uses(&self) -> impl Iterator<Item = (&str, &str, &serde_json::Value)> {
680 self.content.iter().filter_map(|b| match b {
681 ContentBlock::ToolUse {
682 id, name, input, ..
683 } => Some((id.as_str(), name.as_str(), input)),
684 _ => None,
685 })
686 }
687
688 #[must_use]
689 pub fn has_tool_use(&self) -> bool {
690 self.content
691 .iter()
692 .any(|b| matches!(b, ContentBlock::ToolUse { .. }))
693 }
694}
695
696#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
697#[serde(rename_all = "snake_case")]
698#[non_exhaustive]
699pub enum StopReason {
700 EndTurn,
701 ToolUse,
702 MaxTokens,
703 StopSequence,
704 Refusal,
705 ModelContextWindowExceeded,
706 #[serde(other)]
715 Unknown,
716}
717
718impl StopReason {
719 #[must_use]
722 pub const fn as_str(&self) -> &'static str {
723 match self {
724 Self::EndTurn => "end_turn",
725 Self::ToolUse => "tool_use",
726 Self::MaxTokens => "max_tokens",
727 Self::StopSequence => "stop_sequence",
728 Self::Refusal => "refusal",
729 Self::ModelContextWindowExceeded => "model_context_window_exceeded",
730 Self::Unknown => "unknown",
731 }
732 }
733}
734
735#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
744#[serde(rename_all = "snake_case")]
745#[non_exhaustive]
746pub enum ServedSpeed {
747 Uniform(SpeedTier),
749 Mixed,
753}
754
755impl ServedSpeed {
756 #[must_use]
762 pub const fn merge(left: Option<Self>, right: Option<Self>) -> Option<Self> {
763 match (left, right) {
764 (None, other) | (other, None) => other,
765 (Some(Self::Uniform(left)), Some(Self::Uniform(right))) => {
766 if left.same(right) {
767 Some(Self::Uniform(left))
768 } else {
769 Some(Self::Mixed)
770 }
771 }
772 (Some(_), Some(_)) => Some(Self::Mixed),
774 }
775 }
776
777 #[must_use]
779 pub const fn used_premium(self) -> bool {
780 match self {
781 Self::Uniform(tier) => tier.is_premium(),
782 Self::Mixed => true,
785 }
786 }
787}
788
789#[derive(Debug, Clone, Default, Serialize, Deserialize)]
790pub struct Usage {
791 pub input_tokens: u32,
793 pub output_tokens: u32,
794 #[serde(default)]
796 pub cached_input_tokens: u32,
797 #[serde(default)]
799 pub cache_creation_input_tokens: u32,
800 #[serde(default, skip_serializing_if = "Option::is_none")]
811 pub served_speed: Option<ServedSpeed>,
812}
813
814#[derive(Debug, Clone)]
815#[non_exhaustive]
816pub enum ChatOutcome {
817 Success(ChatResponse),
818 RateLimited(Option<Duration>),
825 InvalidRequest(String),
826 ServerError(String),
827}
828
829#[must_use]
839pub fn parse_retry_after(value: &str) -> Option<Duration> {
840 let trimmed = value.trim();
841 if trimmed.is_empty() {
842 return None;
843 }
844
845 if let Ok(seconds) = trimmed.parse::<u64>() {
847 return Some(Duration::from_secs(seconds));
848 }
849
850 let target = parse_imf_fixdate(trimmed)?;
852 let now = time::OffsetDateTime::now_utc();
853 if target <= now {
854 return None;
855 }
856 (target - now).try_into().ok()
857}
858
859fn parse_imf_fixdate(value: &str) -> Option<time::OffsetDateTime> {
861 let format = time::format_description::parse_borrowed::<1>(
864 "[weekday repr:short], [day] [month repr:short] [year] \
865 [hour]:[minute]:[second] GMT",
866 )
867 .ok()?;
868 time::PrimitiveDateTime::parse(value, &format)
869 .ok()
870 .map(time::PrimitiveDateTime::assume_utc)
871}
872
873pub const USER_CANCELLED_TOOL_RESULT: &str = "User cancelled";
883
884fn message_tool_use_ids(message: &Message) -> Vec<&str> {
889 match &message.content {
890 Content::Text(_) => Vec::new(),
891 Content::Blocks(blocks) => blocks
892 .iter()
893 .filter_map(|block| match block {
894 ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
895 _ => None,
896 })
897 .collect(),
898 }
899}
900
901fn message_tool_result_ids(message: &Message) -> std::collections::HashSet<&str> {
905 match &message.content {
906 Content::Text(_) => std::collections::HashSet::new(),
907 Content::Blocks(blocks) => blocks
908 .iter()
909 .filter_map(|block| match block {
910 ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()),
911 _ => None,
912 })
913 .collect(),
914 }
915}
916
917fn all_answered_tool_use_ids(messages: &[Message]) -> std::collections::HashSet<&str> {
926 messages.iter().flat_map(message_tool_result_ids).collect()
927}
928
929#[must_use]
939pub fn has_unbalanced_tool_use(messages: &[Message]) -> bool {
940 let answered = all_answered_tool_use_ids(messages);
941 messages
942 .iter()
943 .flat_map(message_tool_use_ids)
944 .any(|id| !answered.contains(id))
945}
946
947#[must_use]
973pub fn balance_tool_results(messages: &[Message], cancel_text: &str) -> Vec<Message> {
974 let answered = all_answered_tool_use_ids(messages);
977 let mut out: Vec<Message> = Vec::with_capacity(messages.len() + 1);
978 let mut idx = 0;
979 while idx < messages.len() {
980 let message = &messages[idx];
981 let tool_use_ids = message_tool_use_ids(message);
982 if tool_use_ids.is_empty() {
983 out.push(message.clone());
984 idx += 1;
985 continue;
986 }
987
988 let synthetic: Vec<ContentBlock> = tool_use_ids
989 .iter()
990 .filter(|id| !answered.contains(*id))
991 .map(|id| ContentBlock::ToolResult {
992 tool_use_id: (*id).to_owned(),
993 content: cancel_text.to_owned(),
994 is_error: Some(true),
995 })
996 .collect();
997
998 out.push(message.clone());
999
1000 let next = messages.get(idx + 1);
1001
1002 if synthetic.is_empty() {
1003 idx += 1;
1006 continue;
1007 }
1008
1009 match next {
1015 Some(next_message) if !message_tool_result_ids(next_message).is_empty() => {
1016 let mut merged = next_message.clone();
1017 if let Content::Blocks(blocks) = &mut merged.content {
1018 blocks.extend(synthetic);
1019 } else {
1020 merged.content = Content::Blocks(synthetic);
1024 }
1025 out.push(merged);
1026 idx += 2;
1027 }
1028 _ => {
1029 out.push(Message::user_with_content(synthetic));
1030 idx += 1;
1031 }
1032 }
1033 }
1034 out
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039 use super::*;
1040
1041 #[test]
1042 fn served_speed_merge_reports_disagreement_instead_of_hiding_it() {
1043 let fast = Some(ServedSpeed::Uniform(SpeedTier::Fast));
1044 let standard = Some(ServedSpeed::Uniform(SpeedTier::Standard));
1045
1046 assert_eq!(ServedSpeed::merge(None, None), None);
1048
1049 assert_eq!(ServedSpeed::merge(None, fast), fast);
1052 assert_eq!(ServedSpeed::merge(fast, None), fast);
1053
1054 assert_eq!(ServedSpeed::merge(fast, fast), fast);
1056 assert_eq!(ServedSpeed::merge(standard, standard), standard);
1057
1058 assert_eq!(ServedSpeed::merge(fast, standard), Some(ServedSpeed::Mixed));
1061 assert_eq!(ServedSpeed::merge(standard, fast), Some(ServedSpeed::Mixed));
1062
1063 let mixed = Some(ServedSpeed::Mixed);
1065 assert_eq!(ServedSpeed::merge(mixed, fast), mixed);
1066 assert_eq!(ServedSpeed::merge(mixed, mixed), mixed);
1067 assert_eq!(ServedSpeed::merge(None, mixed), mixed);
1068 }
1069
1070 #[test]
1071 fn served_speed_used_premium_flags_any_premium_call() {
1072 assert!(!ServedSpeed::Uniform(SpeedTier::Standard).used_premium());
1073 assert!(ServedSpeed::Uniform(SpeedTier::Fast).used_premium());
1074 assert!(ServedSpeed::Mixed.used_premium());
1077 }
1078
1079 #[test]
1080 fn usage_defaults_report_no_served_tier() {
1081 let usage = Usage::default();
1082 assert_eq!(usage.input_tokens, 0);
1083 assert_eq!(usage.served_speed, None);
1084 }
1085
1086 #[test]
1087 fn speed_tier_defaults_to_standard_and_only_fast_is_premium() {
1088 assert_eq!(SpeedTier::default(), SpeedTier::Standard);
1089 assert!(!SpeedTier::Standard.is_premium());
1090 assert!(SpeedTier::Fast.is_premium());
1091 }
1092
1093 #[test]
1094 fn chat_request_new_defaults_then_setters() {
1095 let req = ChatRequest::new("sys", vec![Message::user("hi")]);
1096 assert_eq!(req.system, "sys");
1097 assert_eq!(req.messages.len(), 1);
1098 assert_eq!(req.max_tokens, ChatRequest::DEFAULT_MAX_TOKENS);
1099 assert!(!req.max_tokens_explicit);
1100 assert!(req.tools.is_none());
1101 assert!(req.tool_choice.is_none());
1102 assert!(req.response_format.is_none());
1103
1104 let req = req
1105 .with_max_tokens(1234)
1106 .with_tool_choice(ToolChoice::Auto)
1107 .with_response_format(ResponseFormat::new(
1108 "r",
1109 serde_json::json!({"type": "object"}),
1110 ))
1111 .with_session_id("s-1");
1112 assert_eq!(req.max_tokens, 1234);
1113 assert!(req.max_tokens_explicit);
1114 assert!(matches!(req.tool_choice, Some(ToolChoice::Auto)));
1115 assert!(req.response_format.is_some());
1116 assert_eq!(req.session_id.as_deref(), Some("s-1"));
1117 }
1118
1119 #[test]
1120 fn stop_reason_known_values_round_trip() -> Result<(), serde_json::Error> {
1121 for (json, expected) in [
1122 ("\"end_turn\"", StopReason::EndTurn),
1123 ("\"tool_use\"", StopReason::ToolUse),
1124 ("\"max_tokens\"", StopReason::MaxTokens),
1125 ("\"stop_sequence\"", StopReason::StopSequence),
1126 ("\"refusal\"", StopReason::Refusal),
1127 (
1128 "\"model_context_window_exceeded\"",
1129 StopReason::ModelContextWindowExceeded,
1130 ),
1131 ] {
1132 let parsed: StopReason = serde_json::from_str(json)?;
1133 assert_eq!(parsed, expected);
1134 assert_eq!(serde_json::to_string(&parsed)?, json);
1135 }
1136 Ok(())
1137 }
1138
1139 #[test]
1140 fn stop_reason_unknown_value_deserializes_to_unknown() -> Result<(), serde_json::Error> {
1141 let parsed: StopReason = serde_json::from_str("\"some_future_reason\"")?;
1144 assert_eq!(parsed, StopReason::Unknown);
1145 assert_eq!(parsed.as_str(), "unknown");
1146 Ok(())
1147 }
1148
1149 #[test]
1150 fn stop_reason_unknown_serializes_to_unknown() -> Result<(), serde_json::Error> {
1151 assert_eq!(serde_json::to_string(&StopReason::Unknown)?, "\"unknown\"");
1152 Ok(())
1153 }
1154
1155 #[test]
1163 fn content_block_text_wire_format() -> Result<(), serde_json::Error> {
1164 let json = serde_json::to_value(ContentBlock::Text { text: "hi".into() })?;
1165 assert_eq!(json, serde_json::json!({"type": "text", "text": "hi"}));
1166 Ok(())
1167 }
1168
1169 #[test]
1170 fn content_block_thinking_omits_none_signature() -> Result<(), serde_json::Error> {
1171 let none = serde_json::to_value(ContentBlock::Thinking {
1172 thinking: "t".into(),
1173 signature: None,
1174 })?;
1175 assert_eq!(
1176 none,
1177 serde_json::json!({"type": "thinking", "thinking": "t"})
1178 );
1179
1180 let some = serde_json::to_value(ContentBlock::Thinking {
1181 thinking: "t".into(),
1182 signature: Some("sig".into()),
1183 })?;
1184 assert_eq!(
1185 some,
1186 serde_json::json!({"type": "thinking", "thinking": "t", "signature": "sig"})
1187 );
1188 Ok(())
1189 }
1190
1191 #[test]
1192 fn content_block_tool_use_omits_none_thought_signature() -> Result<(), serde_json::Error> {
1193 let none = serde_json::to_value(ContentBlock::ToolUse {
1194 id: "i".into(),
1195 name: "n".into(),
1196 input: serde_json::json!({"a": 1}),
1197 thought_signature: None,
1198 })?;
1199 assert_eq!(
1200 none,
1201 serde_json::json!({"type": "tool_use", "id": "i", "name": "n", "input": {"a": 1}})
1202 );
1203
1204 let some = serde_json::to_value(ContentBlock::ToolUse {
1205 id: "i".into(),
1206 name: "n".into(),
1207 input: serde_json::json!({}),
1208 thought_signature: Some("ts".into()),
1209 })?;
1210 assert_eq!(
1211 some.get("thought_signature").and_then(|v| v.as_str()),
1212 Some("ts")
1213 );
1214 Ok(())
1215 }
1216
1217 #[test]
1218 fn content_block_tool_result_omits_none_is_error() -> Result<(), serde_json::Error> {
1219 let none = serde_json::to_value(ContentBlock::ToolResult {
1220 tool_use_id: "t".into(),
1221 content: "out".into(),
1222 is_error: None,
1223 })?;
1224 assert_eq!(
1225 none,
1226 serde_json::json!({"type": "tool_result", "tool_use_id": "t", "content": "out"})
1227 );
1228
1229 let some = serde_json::to_value(ContentBlock::ToolResult {
1230 tool_use_id: "t".into(),
1231 content: "out".into(),
1232 is_error: Some(true),
1233 })?;
1234 assert_eq!(
1235 some.get("is_error").and_then(serde_json::Value::as_bool),
1236 Some(true)
1237 );
1238 Ok(())
1239 }
1240
1241 #[test]
1242 fn content_block_remaining_variant_tags() -> Result<(), serde_json::Error> {
1243 assert_eq!(
1244 serde_json::to_value(ContentBlock::RedactedThinking { data: "d".into() })?,
1245 serde_json::json!({"type": "redacted_thinking", "data": "d"})
1246 );
1247 assert_eq!(
1248 serde_json::to_value(ContentBlock::Image {
1249 source: ContentSource::new("image/png", "b64"),
1250 })?,
1251 serde_json::json!({"type": "image", "source": {"media_type": "image/png", "data": "b64"}})
1252 );
1253 assert_eq!(
1254 serde_json::to_value(ContentBlock::Document {
1255 source: ContentSource::new("application/pdf", "b64"),
1256 })?,
1257 serde_json::json!({"type": "document", "source": {"media_type": "application/pdf", "data": "b64"}})
1258 );
1259 assert_eq!(
1260 serde_json::to_value(ContentBlock::OpaqueReasoning {
1261 provider: "test-provider".into(),
1262 data: serde_json::json!({"id": "reasoning_1", "encrypted": "ciphertext"}),
1263 })?,
1264 serde_json::json!({
1265 "type": "opaque_reasoning",
1266 "provider": "test-provider",
1267 "data": {"id": "reasoning_1", "encrypted": "ciphertext"}
1268 })
1269 );
1270 Ok(())
1271 }
1272
1273 #[test]
1274 fn content_block_every_tag_round_trips() -> Result<(), serde_json::Error> {
1275 let blocks = vec![
1276 ContentBlock::Text { text: "t".into() },
1277 ContentBlock::Thinking {
1278 thinking: "th".into(),
1279 signature: Some("s".into()),
1280 },
1281 ContentBlock::RedactedThinking { data: "d".into() },
1282 ContentBlock::OpaqueReasoning {
1283 provider: "test-provider".into(),
1284 data: serde_json::json!({"id": "reasoning_1", "state": [1, 2, 3]}),
1285 },
1286 ContentBlock::ToolUse {
1287 id: "i".into(),
1288 name: "n".into(),
1289 input: serde_json::json!({"x": 1}),
1290 thought_signature: None,
1291 },
1292 ContentBlock::ToolResult {
1293 tool_use_id: "t".into(),
1294 content: "c".into(),
1295 is_error: Some(true),
1296 },
1297 ContentBlock::Image {
1298 source: ContentSource::new("image/png", "b"),
1299 },
1300 ContentBlock::Document {
1301 source: ContentSource::new("application/pdf", "b"),
1302 },
1303 ];
1304 for block in blocks {
1305 let json = serde_json::to_value(&block)?;
1306 let back: ContentBlock = serde_json::from_value(json.clone())?;
1307 assert_eq!(serde_json::to_value(&back)?, json);
1308 }
1309 Ok(())
1310 }
1311
1312 #[test]
1315 fn content_text_serializes_as_bare_string() -> Result<(), serde_json::Error> {
1316 let json = serde_json::to_value(Content::Text("hello".into()))?;
1317 assert_eq!(json, serde_json::json!("hello"));
1318 let back: Content = serde_json::from_value(serde_json::json!("hello"))?;
1319 assert!(matches!(back, Content::Text(s) if s == "hello"));
1320 Ok(())
1321 }
1322
1323 #[test]
1324 fn content_blocks_serialize_as_array_including_empty() -> Result<(), serde_json::Error> {
1325 let json = serde_json::to_value(Content::Blocks(vec![ContentBlock::Text {
1326 text: "x".into(),
1327 }]))?;
1328 assert_eq!(json, serde_json::json!([{"type": "text", "text": "x"}]));
1329
1330 let empty = serde_json::to_value(Content::Blocks(vec![]))?;
1333 assert_eq!(empty, serde_json::json!([]));
1334 let back: Content = serde_json::from_value(empty)?;
1335 assert!(matches!(back, Content::Blocks(b) if b.is_empty()));
1336 Ok(())
1337 }
1338
1339 #[test]
1342 fn message_wire_format_text_and_blocks() -> Result<(), serde_json::Error> {
1343 let user = serde_json::to_value(Message::user("hi"))?;
1344 assert_eq!(user, serde_json::json!({"role": "user", "content": "hi"}));
1345
1346 let assistant =
1347 serde_json::to_value(Message::assistant_with_content(vec![ContentBlock::Text {
1348 text: "yo".into(),
1349 }]))?;
1350 assert_eq!(
1351 assistant,
1352 serde_json::json!({"role": "assistant", "content": [{"type": "text", "text": "yo"}]})
1353 );
1354
1355 let back: Message =
1356 serde_json::from_value(serde_json::json!({"role": "user", "content": "hi"}))?;
1357 assert_eq!(back.role, Role::User);
1358 assert!(matches!(back.content, Content::Text(s) if s == "hi"));
1359 Ok(())
1360 }
1361
1362 #[test]
1365 fn parse_retry_after_delta_seconds() {
1366 assert_eq!(parse_retry_after("125"), Some(Duration::from_secs(125)));
1367 assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
1368 assert_eq!(parse_retry_after(" 30 "), Some(Duration::from_secs(30)));
1370 }
1371
1372 #[test]
1373 fn parse_retry_after_rejects_garbage_and_empty() {
1374 assert_eq!(parse_retry_after(""), None);
1375 assert_eq!(parse_retry_after(" "), None);
1376 assert_eq!(parse_retry_after("soon"), None);
1377 assert_eq!(parse_retry_after("-5"), None);
1379 }
1380
1381 #[test]
1382 fn parse_retry_after_past_imf_date_is_none() {
1383 assert_eq!(parse_retry_after("Sun, 06 Nov 1994 08:49:37 GMT"), None);
1385 }
1386
1387 #[test]
1388 fn parse_retry_after_future_imf_date_is_some() {
1389 let parsed = parse_retry_after("Fri, 31 Dec 9999 23:59:59 GMT");
1393 assert!(parsed.is_some_and(|d| d > Duration::from_secs(1_000_000)));
1394 }
1395
1396 #[test]
1399 fn cache_ttl_wire_strings() {
1400 assert_eq!(CacheTtl::FiveMinutes.as_wire_str(), "5m");
1401 assert_eq!(CacheTtl::OneHour.as_wire_str(), "1h");
1402 }
1403
1404 #[test]
1405 fn cache_config_builders_and_default_request_cache_is_none() {
1406 let req = ChatRequest::new("sys", vec![Message::user("hi")]);
1407 assert!(
1408 req.cache.is_none(),
1409 "default request must not set a cache config"
1410 );
1411
1412 let enabled = CacheConfig::enabled().with_ttl(CacheTtl::OneHour);
1413 assert!(enabled.enabled);
1414 assert_eq!(enabled.ttl, Some(CacheTtl::OneHour));
1415 assert_eq!(enabled.max_breakpoints, None);
1416
1417 let disabled = CacheConfig::disabled();
1418 assert!(!disabled.enabled);
1419
1420 let capped = CacheConfig::enabled().with_max_breakpoints(2);
1421 assert_eq!(capped.max_breakpoints, Some(2));
1422
1423 let req = ChatRequest::new("s", vec![]).with_cache(CacheConfig::disabled());
1424 assert!(req.cache.is_some_and(|c| !c.enabled));
1425 }
1426
1427 fn assistant_tool_uses(ids: &[&str]) -> Message {
1428 let blocks = ids
1429 .iter()
1430 .map(|id| ContentBlock::ToolUse {
1431 id: (*id).to_string(),
1432 name: "ask_user".to_string(),
1433 input: serde_json::json!({}),
1434 thought_signature: None,
1435 })
1436 .collect();
1437 Message::assistant_with_content(blocks)
1438 }
1439
1440 fn tool_results(ids: &[&str]) -> Message {
1441 let blocks = ids
1442 .iter()
1443 .map(|id| ContentBlock::ToolResult {
1444 tool_use_id: (*id).to_string(),
1445 content: "answered".to_string(),
1446 is_error: None,
1447 })
1448 .collect();
1449 Message::user_with_content(blocks)
1450 }
1451
1452 fn assert_balanced(messages: &[Message]) {
1453 assert!(
1454 !has_unbalanced_tool_use(messages),
1455 "expected balanced history, found an orphaned tool_use",
1456 );
1457 }
1458
1459 #[test]
1460 fn balanced_history_is_left_untouched() {
1461 let messages = vec![
1462 Message::user("hi"),
1463 assistant_tool_uses(&["a"]),
1464 tool_results(&["a"]),
1465 ];
1466 assert!(!has_unbalanced_tool_use(&messages));
1467 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1468 assert_eq!(out.len(), 3);
1469 assert_balanced(&out);
1470 }
1471
1472 #[test]
1473 fn partial_cancellation_merges_into_existing_results_message() {
1474 let messages = vec![
1476 assistant_tool_uses(&["q1", "q2", "q3", "q4"]),
1477 tool_results(&["q1"]),
1478 ];
1479 assert!(has_unbalanced_tool_use(&messages));
1480
1481 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1482 assert_eq!(
1483 out.len(),
1484 2,
1485 "synthetic results merge into the existing message"
1486 );
1487 assert_balanced(&out);
1488
1489 let Content::Blocks(blocks) = &out[1].content else {
1490 panic!("results message must carry blocks");
1491 };
1492 let cancelled: Vec<&str> = blocks
1493 .iter()
1494 .filter_map(|b| match b {
1495 ContentBlock::ToolResult {
1496 tool_use_id,
1497 content,
1498 is_error: Some(true),
1499 } if content == USER_CANCELLED_TOOL_RESULT => Some(tool_use_id.as_str()),
1500 _ => None,
1501 })
1502 .collect();
1503 assert_eq!(cancelled, vec!["q2", "q3", "q4"]);
1504 }
1505
1506 #[test]
1507 fn all_cancelled_with_no_following_message_appends_results() {
1508 let messages = vec![assistant_tool_uses(&["q1", "q2"])];
1510 assert!(has_unbalanced_tool_use(&messages));
1511
1512 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1513 assert_eq!(out.len(), 2, "a fresh results message is inserted");
1514 assert_eq!(out[1].role, Role::User);
1515 assert_balanced(&out);
1516 }
1517
1518 #[test]
1519 fn orphan_followed_by_user_prompt_inserts_results_between() {
1520 let messages = vec![
1523 assistant_tool_uses(&["q1"]),
1524 Message::user("a brand new question from the user"),
1525 ];
1526 assert!(has_unbalanced_tool_use(&messages));
1527
1528 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1529 assert_eq!(out.len(), 3);
1530 assert_balanced(&out);
1531 assert!(!message_tool_use_ids(&out[0]).is_empty());
1533 assert!(!message_tool_result_ids(&out[1]).is_empty());
1534 assert_eq!(
1535 out[2].content.first_text(),
1536 Some("a brand new question from the user")
1537 );
1538 }
1539
1540 #[test]
1541 fn balancing_is_idempotent() {
1542 let messages = vec![
1543 assistant_tool_uses(&["q1", "q2", "q3"]),
1544 tool_results(&["q2"]),
1545 ];
1546 let once = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1547 let twice = balance_tool_results(&once, USER_CANCELLED_TOOL_RESULT);
1548 assert_eq!(once.len(), twice.len());
1549 assert_balanced(&twice);
1550 }
1551
1552 #[test]
1553 fn no_tool_use_history_is_a_noop() {
1554 let messages = vec![Message::user("hi"), Message::assistant("hello")];
1555 assert!(!has_unbalanced_tool_use(&messages));
1556 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1557 assert_eq!(out.len(), 2);
1558 }
1559
1560 #[test]
1561 fn real_result_not_at_idx1_is_not_duplicated_or_relabelled() {
1562 let messages = vec![
1568 assistant_tool_uses(&["a"]),
1569 Message::user("an interjection between the call and its result"),
1570 tool_results(&["a"]),
1571 ];
1572 assert!(!has_unbalanced_tool_use(&messages));
1574
1575 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1576 let a_results: Vec<&ContentBlock> = out
1579 .iter()
1580 .flat_map(|m| match &m.content {
1581 Content::Blocks(b) => b.as_slice(),
1582 Content::Text(_) => &[][..],
1583 })
1584 .filter(
1585 |b| matches!(b, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "a"),
1586 )
1587 .collect();
1588 assert_eq!(a_results.len(), 1, "must not duplicate the real result");
1589 assert!(
1590 !matches!(a_results[0], ContentBlock::ToolResult { content, .. } if content == USER_CANCELLED_TOOL_RESULT),
1591 "the real successful result must not be relabelled cancelled",
1592 );
1593 }
1594}