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)]
290pub struct ChatRequest {
291 pub system: String,
292 pub messages: Vec<Message>,
293 pub tools: Option<Vec<Tool>>,
294 pub max_tokens: u32,
295 pub max_tokens_explicit: bool,
297 pub session_id: Option<String>,
299 pub cached_content: Option<String>,
303 pub thinking: Option<ThinkingConfig>,
305 pub tool_choice: Option<ToolChoice>,
309 pub response_format: Option<ResponseFormat>,
317 pub cache: Option<CacheConfig>,
323}
324
325impl ChatRequest {
326 pub const DEFAULT_MAX_TOKENS: u32 = 4096;
329
330 #[must_use]
346 pub fn new(system: impl Into<String>, messages: Vec<Message>) -> Self {
347 Self {
348 system: system.into(),
349 messages,
350 tools: None,
351 max_tokens: Self::DEFAULT_MAX_TOKENS,
352 max_tokens_explicit: false,
353 session_id: None,
354 cached_content: None,
355 thinking: None,
356 tool_choice: None,
357 response_format: None,
358 cache: None,
359 }
360 }
361
362 #[must_use]
364 pub fn with_tools(mut self, tools: Vec<Tool>) -> Self {
365 self.tools = Some(tools);
366 self
367 }
368
369 #[must_use]
371 pub const fn with_max_tokens(mut self, max_tokens: u32) -> Self {
372 self.max_tokens = max_tokens;
373 self.max_tokens_explicit = true;
374 self
375 }
376
377 #[must_use]
379 pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
380 self.session_id = Some(session_id.into());
381 self
382 }
383
384 #[must_use]
386 pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
387 self.thinking = Some(thinking);
388 self
389 }
390
391 #[must_use]
393 pub fn with_tool_choice(mut self, tool_choice: ToolChoice) -> Self {
394 self.tool_choice = Some(tool_choice);
395 self
396 }
397
398 #[must_use]
401 pub fn with_response_format(mut self, response_format: ResponseFormat) -> Self {
402 self.response_format = Some(response_format);
403 self
404 }
405
406 #[must_use]
408 pub const fn with_cache(mut self, cache: CacheConfig) -> Self {
409 self.cache = Some(cache);
410 self
411 }
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct Message {
416 pub role: Role,
417 pub content: Content,
418}
419
420impl Message {
421 #[must_use]
422 pub fn user(text: impl Into<String>) -> Self {
423 Self {
424 role: Role::User,
425 content: Content::Text(text.into()),
426 }
427 }
428
429 #[must_use]
430 pub const fn user_with_content(blocks: Vec<ContentBlock>) -> Self {
431 Self {
432 role: Role::User,
433 content: Content::Blocks(blocks),
434 }
435 }
436
437 #[must_use]
438 pub fn assistant(text: impl Into<String>) -> Self {
439 Self {
440 role: Role::Assistant,
441 content: Content::Text(text.into()),
442 }
443 }
444
445 #[must_use]
446 pub const fn assistant_with_content(blocks: Vec<ContentBlock>) -> Self {
447 Self {
448 role: Role::Assistant,
449 content: Content::Blocks(blocks),
450 }
451 }
452
453 #[must_use]
454 pub fn assistant_with_tool_use(
455 text: Option<String>,
456 id: impl Into<String>,
457 name: impl Into<String>,
458 input: serde_json::Value,
459 ) -> Self {
460 let mut blocks = Vec::new();
461 if let Some(t) = text {
462 blocks.push(ContentBlock::Text { text: t });
463 }
464 blocks.push(ContentBlock::ToolUse {
465 id: id.into(),
466 name: name.into(),
467 input,
468 thought_signature: None,
469 });
470 Self {
471 role: Role::Assistant,
472 content: Content::Blocks(blocks),
473 }
474 }
475
476 #[must_use]
477 pub fn tool_result(
478 tool_use_id: impl Into<String>,
479 content: impl Into<String>,
480 is_error: bool,
481 ) -> Self {
482 Self {
483 role: Role::User,
484 content: Content::Blocks(vec![ContentBlock::ToolResult {
485 tool_use_id: tool_use_id.into(),
486 content: content.into(),
487 is_error: if is_error { Some(true) } else { None },
488 }]),
489 }
490 }
491}
492
493#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
494#[serde(rename_all = "lowercase")]
495pub enum Role {
496 User,
497 Assistant,
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize)]
501#[serde(untagged)]
502pub enum Content {
503 Text(String),
504 Blocks(Vec<ContentBlock>),
505}
506
507impl Content {
508 #[must_use]
509 pub fn first_text(&self) -> Option<&str> {
510 match self {
511 Self::Text(s) => Some(s),
512 Self::Blocks(blocks) => blocks.iter().find_map(|b| match b {
513 ContentBlock::Text { text } => Some(text.as_str()),
514 _ => None,
515 }),
516 }
517 }
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize)]
522pub struct ContentSource {
523 pub media_type: String,
524 pub data: String,
525}
526
527impl ContentSource {
528 #[must_use]
529 pub fn new(media_type: impl Into<String>, data: impl Into<String>) -> Self {
530 Self {
531 media_type: media_type.into(),
532 data: data.into(),
533 }
534 }
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize)]
538#[serde(tag = "type")]
539#[non_exhaustive]
540pub enum ContentBlock {
541 #[serde(rename = "text")]
542 Text { text: String },
543
544 #[serde(rename = "thinking")]
545 Thinking {
546 thinking: String,
547 #[serde(skip_serializing_if = "Option::is_none")]
549 signature: Option<String>,
550 },
551
552 #[serde(rename = "redacted_thinking")]
553 RedactedThinking { data: String },
554
555 #[serde(rename = "opaque_reasoning")]
563 OpaqueReasoning {
564 provider: String,
565 data: serde_json::Value,
566 },
567
568 #[serde(rename = "tool_use")]
569 ToolUse {
570 id: String,
571 name: String,
572 input: serde_json::Value,
573 #[serde(skip_serializing_if = "Option::is_none")]
576 thought_signature: Option<String>,
577 },
578
579 #[serde(rename = "tool_result")]
580 ToolResult {
581 tool_use_id: String,
582 content: String,
583 #[serde(skip_serializing_if = "Option::is_none")]
584 is_error: Option<bool>,
585 },
586
587 #[serde(rename = "image")]
588 Image { source: ContentSource },
589
590 #[serde(rename = "document")]
591 Document { source: ContentSource },
592}
593
594#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
595pub struct Tool {
596 pub name: String,
597 pub description: String,
598 pub input_schema: serde_json::Value,
599 pub display_name: String,
601 pub tier: super::types::ToolTier,
603}
604
605#[derive(Debug, Clone)]
606pub struct ChatResponse {
607 pub id: String,
608 pub content: Vec<ContentBlock>,
609 pub model: String,
610 pub stop_reason: Option<StopReason>,
611 pub usage: Usage,
612}
613
614impl ChatResponse {
615 #[must_use]
616 pub fn first_text(&self) -> Option<&str> {
617 self.content.iter().find_map(|b| match b {
618 ContentBlock::Text { text } => Some(text.as_str()),
619 _ => None,
620 })
621 }
622
623 #[must_use]
624 pub fn first_thinking(&self) -> Option<&str> {
625 self.content.iter().find_map(|b| match b {
626 ContentBlock::Thinking { thinking, .. } => Some(thinking.as_str()),
627 _ => None,
628 })
629 }
630
631 pub fn tool_uses(&self) -> impl Iterator<Item = (&str, &str, &serde_json::Value)> {
632 self.content.iter().filter_map(|b| match b {
633 ContentBlock::ToolUse {
634 id, name, input, ..
635 } => Some((id.as_str(), name.as_str(), input)),
636 _ => None,
637 })
638 }
639
640 #[must_use]
641 pub fn has_tool_use(&self) -> bool {
642 self.content
643 .iter()
644 .any(|b| matches!(b, ContentBlock::ToolUse { .. }))
645 }
646}
647
648#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
649#[serde(rename_all = "snake_case")]
650#[non_exhaustive]
651pub enum StopReason {
652 EndTurn,
653 ToolUse,
654 MaxTokens,
655 StopSequence,
656 Refusal,
657 ModelContextWindowExceeded,
658 #[serde(other)]
667 Unknown,
668}
669
670impl StopReason {
671 #[must_use]
674 pub const fn as_str(&self) -> &'static str {
675 match self {
676 Self::EndTurn => "end_turn",
677 Self::ToolUse => "tool_use",
678 Self::MaxTokens => "max_tokens",
679 Self::StopSequence => "stop_sequence",
680 Self::Refusal => "refusal",
681 Self::ModelContextWindowExceeded => "model_context_window_exceeded",
682 Self::Unknown => "unknown",
683 }
684 }
685}
686
687#[derive(Debug, Clone, Serialize, Deserialize)]
688pub struct Usage {
689 pub input_tokens: u32,
691 pub output_tokens: u32,
692 #[serde(default)]
694 pub cached_input_tokens: u32,
695 #[serde(default)]
697 pub cache_creation_input_tokens: u32,
698}
699
700#[derive(Debug, Clone)]
701#[non_exhaustive]
702pub enum ChatOutcome {
703 Success(ChatResponse),
704 RateLimited(Option<Duration>),
711 InvalidRequest(String),
712 ServerError(String),
713}
714
715#[must_use]
725pub fn parse_retry_after(value: &str) -> Option<Duration> {
726 let trimmed = value.trim();
727 if trimmed.is_empty() {
728 return None;
729 }
730
731 if let Ok(seconds) = trimmed.parse::<u64>() {
733 return Some(Duration::from_secs(seconds));
734 }
735
736 let target = parse_imf_fixdate(trimmed)?;
738 let now = time::OffsetDateTime::now_utc();
739 if target <= now {
740 return None;
741 }
742 (target - now).try_into().ok()
743}
744
745fn parse_imf_fixdate(value: &str) -> Option<time::OffsetDateTime> {
747 let format = time::format_description::parse_borrowed::<1>(
750 "[weekday repr:short], [day] [month repr:short] [year] \
751 [hour]:[minute]:[second] GMT",
752 )
753 .ok()?;
754 time::PrimitiveDateTime::parse(value, &format)
755 .ok()
756 .map(time::PrimitiveDateTime::assume_utc)
757}
758
759pub const USER_CANCELLED_TOOL_RESULT: &str = "User cancelled";
769
770fn message_tool_use_ids(message: &Message) -> Vec<&str> {
775 match &message.content {
776 Content::Text(_) => Vec::new(),
777 Content::Blocks(blocks) => blocks
778 .iter()
779 .filter_map(|block| match block {
780 ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
781 _ => None,
782 })
783 .collect(),
784 }
785}
786
787fn message_tool_result_ids(message: &Message) -> std::collections::HashSet<&str> {
791 match &message.content {
792 Content::Text(_) => std::collections::HashSet::new(),
793 Content::Blocks(blocks) => blocks
794 .iter()
795 .filter_map(|block| match block {
796 ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()),
797 _ => None,
798 })
799 .collect(),
800 }
801}
802
803fn all_answered_tool_use_ids(messages: &[Message]) -> std::collections::HashSet<&str> {
812 messages.iter().flat_map(message_tool_result_ids).collect()
813}
814
815#[must_use]
825pub fn has_unbalanced_tool_use(messages: &[Message]) -> bool {
826 let answered = all_answered_tool_use_ids(messages);
827 messages
828 .iter()
829 .flat_map(message_tool_use_ids)
830 .any(|id| !answered.contains(id))
831}
832
833#[must_use]
859pub fn balance_tool_results(messages: &[Message], cancel_text: &str) -> Vec<Message> {
860 let answered = all_answered_tool_use_ids(messages);
863 let mut out: Vec<Message> = Vec::with_capacity(messages.len() + 1);
864 let mut idx = 0;
865 while idx < messages.len() {
866 let message = &messages[idx];
867 let tool_use_ids = message_tool_use_ids(message);
868 if tool_use_ids.is_empty() {
869 out.push(message.clone());
870 idx += 1;
871 continue;
872 }
873
874 let synthetic: Vec<ContentBlock> = tool_use_ids
875 .iter()
876 .filter(|id| !answered.contains(*id))
877 .map(|id| ContentBlock::ToolResult {
878 tool_use_id: (*id).to_owned(),
879 content: cancel_text.to_owned(),
880 is_error: Some(true),
881 })
882 .collect();
883
884 out.push(message.clone());
885
886 let next = messages.get(idx + 1);
887
888 if synthetic.is_empty() {
889 idx += 1;
892 continue;
893 }
894
895 match next {
901 Some(next_message) if !message_tool_result_ids(next_message).is_empty() => {
902 let mut merged = next_message.clone();
903 if let Content::Blocks(blocks) = &mut merged.content {
904 blocks.extend(synthetic);
905 } else {
906 merged.content = Content::Blocks(synthetic);
910 }
911 out.push(merged);
912 idx += 2;
913 }
914 _ => {
915 out.push(Message::user_with_content(synthetic));
916 idx += 1;
917 }
918 }
919 }
920 out
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926
927 #[test]
928 fn chat_request_new_defaults_then_setters() {
929 let req = ChatRequest::new("sys", vec![Message::user("hi")]);
930 assert_eq!(req.system, "sys");
931 assert_eq!(req.messages.len(), 1);
932 assert_eq!(req.max_tokens, ChatRequest::DEFAULT_MAX_TOKENS);
933 assert!(!req.max_tokens_explicit);
934 assert!(req.tools.is_none());
935 assert!(req.tool_choice.is_none());
936 assert!(req.response_format.is_none());
937
938 let req = req
939 .with_max_tokens(1234)
940 .with_tool_choice(ToolChoice::Auto)
941 .with_response_format(ResponseFormat::new(
942 "r",
943 serde_json::json!({"type": "object"}),
944 ))
945 .with_session_id("s-1");
946 assert_eq!(req.max_tokens, 1234);
947 assert!(req.max_tokens_explicit);
948 assert!(matches!(req.tool_choice, Some(ToolChoice::Auto)));
949 assert!(req.response_format.is_some());
950 assert_eq!(req.session_id.as_deref(), Some("s-1"));
951 }
952
953 #[test]
954 fn stop_reason_known_values_round_trip() -> Result<(), serde_json::Error> {
955 for (json, expected) in [
956 ("\"end_turn\"", StopReason::EndTurn),
957 ("\"tool_use\"", StopReason::ToolUse),
958 ("\"max_tokens\"", StopReason::MaxTokens),
959 ("\"stop_sequence\"", StopReason::StopSequence),
960 ("\"refusal\"", StopReason::Refusal),
961 (
962 "\"model_context_window_exceeded\"",
963 StopReason::ModelContextWindowExceeded,
964 ),
965 ] {
966 let parsed: StopReason = serde_json::from_str(json)?;
967 assert_eq!(parsed, expected);
968 assert_eq!(serde_json::to_string(&parsed)?, json);
969 }
970 Ok(())
971 }
972
973 #[test]
974 fn stop_reason_unknown_value_deserializes_to_unknown() -> Result<(), serde_json::Error> {
975 let parsed: StopReason = serde_json::from_str("\"some_future_reason\"")?;
978 assert_eq!(parsed, StopReason::Unknown);
979 assert_eq!(parsed.as_str(), "unknown");
980 Ok(())
981 }
982
983 #[test]
984 fn stop_reason_unknown_serializes_to_unknown() -> Result<(), serde_json::Error> {
985 assert_eq!(serde_json::to_string(&StopReason::Unknown)?, "\"unknown\"");
986 Ok(())
987 }
988
989 #[test]
997 fn content_block_text_wire_format() -> Result<(), serde_json::Error> {
998 let json = serde_json::to_value(ContentBlock::Text { text: "hi".into() })?;
999 assert_eq!(json, serde_json::json!({"type": "text", "text": "hi"}));
1000 Ok(())
1001 }
1002
1003 #[test]
1004 fn content_block_thinking_omits_none_signature() -> Result<(), serde_json::Error> {
1005 let none = serde_json::to_value(ContentBlock::Thinking {
1006 thinking: "t".into(),
1007 signature: None,
1008 })?;
1009 assert_eq!(
1010 none,
1011 serde_json::json!({"type": "thinking", "thinking": "t"})
1012 );
1013
1014 let some = serde_json::to_value(ContentBlock::Thinking {
1015 thinking: "t".into(),
1016 signature: Some("sig".into()),
1017 })?;
1018 assert_eq!(
1019 some,
1020 serde_json::json!({"type": "thinking", "thinking": "t", "signature": "sig"})
1021 );
1022 Ok(())
1023 }
1024
1025 #[test]
1026 fn content_block_tool_use_omits_none_thought_signature() -> Result<(), serde_json::Error> {
1027 let none = serde_json::to_value(ContentBlock::ToolUse {
1028 id: "i".into(),
1029 name: "n".into(),
1030 input: serde_json::json!({"a": 1}),
1031 thought_signature: None,
1032 })?;
1033 assert_eq!(
1034 none,
1035 serde_json::json!({"type": "tool_use", "id": "i", "name": "n", "input": {"a": 1}})
1036 );
1037
1038 let some = serde_json::to_value(ContentBlock::ToolUse {
1039 id: "i".into(),
1040 name: "n".into(),
1041 input: serde_json::json!({}),
1042 thought_signature: Some("ts".into()),
1043 })?;
1044 assert_eq!(
1045 some.get("thought_signature").and_then(|v| v.as_str()),
1046 Some("ts")
1047 );
1048 Ok(())
1049 }
1050
1051 #[test]
1052 fn content_block_tool_result_omits_none_is_error() -> Result<(), serde_json::Error> {
1053 let none = serde_json::to_value(ContentBlock::ToolResult {
1054 tool_use_id: "t".into(),
1055 content: "out".into(),
1056 is_error: None,
1057 })?;
1058 assert_eq!(
1059 none,
1060 serde_json::json!({"type": "tool_result", "tool_use_id": "t", "content": "out"})
1061 );
1062
1063 let some = serde_json::to_value(ContentBlock::ToolResult {
1064 tool_use_id: "t".into(),
1065 content: "out".into(),
1066 is_error: Some(true),
1067 })?;
1068 assert_eq!(
1069 some.get("is_error").and_then(serde_json::Value::as_bool),
1070 Some(true)
1071 );
1072 Ok(())
1073 }
1074
1075 #[test]
1076 fn content_block_remaining_variant_tags() -> Result<(), serde_json::Error> {
1077 assert_eq!(
1078 serde_json::to_value(ContentBlock::RedactedThinking { data: "d".into() })?,
1079 serde_json::json!({"type": "redacted_thinking", "data": "d"})
1080 );
1081 assert_eq!(
1082 serde_json::to_value(ContentBlock::Image {
1083 source: ContentSource::new("image/png", "b64"),
1084 })?,
1085 serde_json::json!({"type": "image", "source": {"media_type": "image/png", "data": "b64"}})
1086 );
1087 assert_eq!(
1088 serde_json::to_value(ContentBlock::Document {
1089 source: ContentSource::new("application/pdf", "b64"),
1090 })?,
1091 serde_json::json!({"type": "document", "source": {"media_type": "application/pdf", "data": "b64"}})
1092 );
1093 assert_eq!(
1094 serde_json::to_value(ContentBlock::OpaqueReasoning {
1095 provider: "test-provider".into(),
1096 data: serde_json::json!({"id": "reasoning_1", "encrypted": "ciphertext"}),
1097 })?,
1098 serde_json::json!({
1099 "type": "opaque_reasoning",
1100 "provider": "test-provider",
1101 "data": {"id": "reasoning_1", "encrypted": "ciphertext"}
1102 })
1103 );
1104 Ok(())
1105 }
1106
1107 #[test]
1108 fn content_block_every_tag_round_trips() -> Result<(), serde_json::Error> {
1109 let blocks = vec![
1110 ContentBlock::Text { text: "t".into() },
1111 ContentBlock::Thinking {
1112 thinking: "th".into(),
1113 signature: Some("s".into()),
1114 },
1115 ContentBlock::RedactedThinking { data: "d".into() },
1116 ContentBlock::OpaqueReasoning {
1117 provider: "test-provider".into(),
1118 data: serde_json::json!({"id": "reasoning_1", "state": [1, 2, 3]}),
1119 },
1120 ContentBlock::ToolUse {
1121 id: "i".into(),
1122 name: "n".into(),
1123 input: serde_json::json!({"x": 1}),
1124 thought_signature: None,
1125 },
1126 ContentBlock::ToolResult {
1127 tool_use_id: "t".into(),
1128 content: "c".into(),
1129 is_error: Some(true),
1130 },
1131 ContentBlock::Image {
1132 source: ContentSource::new("image/png", "b"),
1133 },
1134 ContentBlock::Document {
1135 source: ContentSource::new("application/pdf", "b"),
1136 },
1137 ];
1138 for block in blocks {
1139 let json = serde_json::to_value(&block)?;
1140 let back: ContentBlock = serde_json::from_value(json.clone())?;
1141 assert_eq!(serde_json::to_value(&back)?, json);
1142 }
1143 Ok(())
1144 }
1145
1146 #[test]
1149 fn content_text_serializes_as_bare_string() -> Result<(), serde_json::Error> {
1150 let json = serde_json::to_value(Content::Text("hello".into()))?;
1151 assert_eq!(json, serde_json::json!("hello"));
1152 let back: Content = serde_json::from_value(serde_json::json!("hello"))?;
1153 assert!(matches!(back, Content::Text(s) if s == "hello"));
1154 Ok(())
1155 }
1156
1157 #[test]
1158 fn content_blocks_serialize_as_array_including_empty() -> Result<(), serde_json::Error> {
1159 let json = serde_json::to_value(Content::Blocks(vec![ContentBlock::Text {
1160 text: "x".into(),
1161 }]))?;
1162 assert_eq!(json, serde_json::json!([{"type": "text", "text": "x"}]));
1163
1164 let empty = serde_json::to_value(Content::Blocks(vec![]))?;
1167 assert_eq!(empty, serde_json::json!([]));
1168 let back: Content = serde_json::from_value(empty)?;
1169 assert!(matches!(back, Content::Blocks(b) if b.is_empty()));
1170 Ok(())
1171 }
1172
1173 #[test]
1176 fn message_wire_format_text_and_blocks() -> Result<(), serde_json::Error> {
1177 let user = serde_json::to_value(Message::user("hi"))?;
1178 assert_eq!(user, serde_json::json!({"role": "user", "content": "hi"}));
1179
1180 let assistant =
1181 serde_json::to_value(Message::assistant_with_content(vec![ContentBlock::Text {
1182 text: "yo".into(),
1183 }]))?;
1184 assert_eq!(
1185 assistant,
1186 serde_json::json!({"role": "assistant", "content": [{"type": "text", "text": "yo"}]})
1187 );
1188
1189 let back: Message =
1190 serde_json::from_value(serde_json::json!({"role": "user", "content": "hi"}))?;
1191 assert_eq!(back.role, Role::User);
1192 assert!(matches!(back.content, Content::Text(s) if s == "hi"));
1193 Ok(())
1194 }
1195
1196 #[test]
1199 fn parse_retry_after_delta_seconds() {
1200 assert_eq!(parse_retry_after("125"), Some(Duration::from_secs(125)));
1201 assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
1202 assert_eq!(parse_retry_after(" 30 "), Some(Duration::from_secs(30)));
1204 }
1205
1206 #[test]
1207 fn parse_retry_after_rejects_garbage_and_empty() {
1208 assert_eq!(parse_retry_after(""), None);
1209 assert_eq!(parse_retry_after(" "), None);
1210 assert_eq!(parse_retry_after("soon"), None);
1211 assert_eq!(parse_retry_after("-5"), None);
1213 }
1214
1215 #[test]
1216 fn parse_retry_after_past_imf_date_is_none() {
1217 assert_eq!(parse_retry_after("Sun, 06 Nov 1994 08:49:37 GMT"), None);
1219 }
1220
1221 #[test]
1222 fn parse_retry_after_future_imf_date_is_some() {
1223 let parsed = parse_retry_after("Fri, 31 Dec 9999 23:59:59 GMT");
1227 assert!(parsed.is_some_and(|d| d > Duration::from_secs(1_000_000)));
1228 }
1229
1230 #[test]
1233 fn cache_ttl_wire_strings() {
1234 assert_eq!(CacheTtl::FiveMinutes.as_wire_str(), "5m");
1235 assert_eq!(CacheTtl::OneHour.as_wire_str(), "1h");
1236 }
1237
1238 #[test]
1239 fn cache_config_builders_and_default_request_cache_is_none() {
1240 let req = ChatRequest::new("sys", vec![Message::user("hi")]);
1241 assert!(
1242 req.cache.is_none(),
1243 "default request must not set a cache config"
1244 );
1245
1246 let enabled = CacheConfig::enabled().with_ttl(CacheTtl::OneHour);
1247 assert!(enabled.enabled);
1248 assert_eq!(enabled.ttl, Some(CacheTtl::OneHour));
1249 assert_eq!(enabled.max_breakpoints, None);
1250
1251 let disabled = CacheConfig::disabled();
1252 assert!(!disabled.enabled);
1253
1254 let capped = CacheConfig::enabled().with_max_breakpoints(2);
1255 assert_eq!(capped.max_breakpoints, Some(2));
1256
1257 let req = ChatRequest::new("s", vec![]).with_cache(CacheConfig::disabled());
1258 assert!(req.cache.is_some_and(|c| !c.enabled));
1259 }
1260
1261 fn assistant_tool_uses(ids: &[&str]) -> Message {
1262 let blocks = ids
1263 .iter()
1264 .map(|id| ContentBlock::ToolUse {
1265 id: (*id).to_string(),
1266 name: "ask_user".to_string(),
1267 input: serde_json::json!({}),
1268 thought_signature: None,
1269 })
1270 .collect();
1271 Message::assistant_with_content(blocks)
1272 }
1273
1274 fn tool_results(ids: &[&str]) -> Message {
1275 let blocks = ids
1276 .iter()
1277 .map(|id| ContentBlock::ToolResult {
1278 tool_use_id: (*id).to_string(),
1279 content: "answered".to_string(),
1280 is_error: None,
1281 })
1282 .collect();
1283 Message::user_with_content(blocks)
1284 }
1285
1286 fn assert_balanced(messages: &[Message]) {
1287 assert!(
1288 !has_unbalanced_tool_use(messages),
1289 "expected balanced history, found an orphaned tool_use",
1290 );
1291 }
1292
1293 #[test]
1294 fn balanced_history_is_left_untouched() {
1295 let messages = vec![
1296 Message::user("hi"),
1297 assistant_tool_uses(&["a"]),
1298 tool_results(&["a"]),
1299 ];
1300 assert!(!has_unbalanced_tool_use(&messages));
1301 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1302 assert_eq!(out.len(), 3);
1303 assert_balanced(&out);
1304 }
1305
1306 #[test]
1307 fn partial_cancellation_merges_into_existing_results_message() {
1308 let messages = vec![
1310 assistant_tool_uses(&["q1", "q2", "q3", "q4"]),
1311 tool_results(&["q1"]),
1312 ];
1313 assert!(has_unbalanced_tool_use(&messages));
1314
1315 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1316 assert_eq!(
1317 out.len(),
1318 2,
1319 "synthetic results merge into the existing message"
1320 );
1321 assert_balanced(&out);
1322
1323 let Content::Blocks(blocks) = &out[1].content else {
1324 panic!("results message must carry blocks");
1325 };
1326 let cancelled: Vec<&str> = blocks
1327 .iter()
1328 .filter_map(|b| match b {
1329 ContentBlock::ToolResult {
1330 tool_use_id,
1331 content,
1332 is_error: Some(true),
1333 } if content == USER_CANCELLED_TOOL_RESULT => Some(tool_use_id.as_str()),
1334 _ => None,
1335 })
1336 .collect();
1337 assert_eq!(cancelled, vec!["q2", "q3", "q4"]);
1338 }
1339
1340 #[test]
1341 fn all_cancelled_with_no_following_message_appends_results() {
1342 let messages = vec![assistant_tool_uses(&["q1", "q2"])];
1344 assert!(has_unbalanced_tool_use(&messages));
1345
1346 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1347 assert_eq!(out.len(), 2, "a fresh results message is inserted");
1348 assert_eq!(out[1].role, Role::User);
1349 assert_balanced(&out);
1350 }
1351
1352 #[test]
1353 fn orphan_followed_by_user_prompt_inserts_results_between() {
1354 let messages = vec![
1357 assistant_tool_uses(&["q1"]),
1358 Message::user("a brand new question from the user"),
1359 ];
1360 assert!(has_unbalanced_tool_use(&messages));
1361
1362 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1363 assert_eq!(out.len(), 3);
1364 assert_balanced(&out);
1365 assert!(!message_tool_use_ids(&out[0]).is_empty());
1367 assert!(!message_tool_result_ids(&out[1]).is_empty());
1368 assert_eq!(
1369 out[2].content.first_text(),
1370 Some("a brand new question from the user")
1371 );
1372 }
1373
1374 #[test]
1375 fn balancing_is_idempotent() {
1376 let messages = vec![
1377 assistant_tool_uses(&["q1", "q2", "q3"]),
1378 tool_results(&["q2"]),
1379 ];
1380 let once = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1381 let twice = balance_tool_results(&once, USER_CANCELLED_TOOL_RESULT);
1382 assert_eq!(once.len(), twice.len());
1383 assert_balanced(&twice);
1384 }
1385
1386 #[test]
1387 fn no_tool_use_history_is_a_noop() {
1388 let messages = vec![Message::user("hi"), Message::assistant("hello")];
1389 assert!(!has_unbalanced_tool_use(&messages));
1390 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1391 assert_eq!(out.len(), 2);
1392 }
1393
1394 #[test]
1395 fn real_result_not_at_idx1_is_not_duplicated_or_relabelled() {
1396 let messages = vec![
1402 assistant_tool_uses(&["a"]),
1403 Message::user("an interjection between the call and its result"),
1404 tool_results(&["a"]),
1405 ];
1406 assert!(!has_unbalanced_tool_use(&messages));
1408
1409 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1410 let a_results: Vec<&ContentBlock> = out
1413 .iter()
1414 .flat_map(|m| match &m.content {
1415 Content::Blocks(b) => b.as_slice(),
1416 Content::Text(_) => &[][..],
1417 })
1418 .filter(
1419 |b| matches!(b, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "a"),
1420 )
1421 .collect();
1422 assert_eq!(a_results.len(), 1, "must not duplicate the real result");
1423 assert!(
1424 !matches!(a_results[0], ContentBlock::ToolResult { content, .. } if content == USER_CANCELLED_TOOL_RESULT),
1425 "the real successful result must not be relabelled cancelled",
1426 );
1427 }
1428}