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}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum Effort {
26 Low,
27 Medium,
28 High,
29 Max,
30}
31
32#[derive(Debug, Clone)]
37pub struct ThinkingConfig {
38 pub mode: ThinkingMode,
40 pub effort: Option<Effort>,
42}
43
44impl ThinkingConfig {
45 pub const DEFAULT_BUDGET_TOKENS: u32 = 10_000;
50
51 pub const MIN_BUDGET_TOKENS: u32 = 1_024;
53
54 #[must_use]
56 pub const fn new(budget_tokens: u32) -> Self {
57 Self {
58 mode: ThinkingMode::Enabled { budget_tokens },
59 effort: None,
60 }
61 }
62
63 #[must_use]
65 pub const fn adaptive() -> Self {
66 Self {
67 mode: ThinkingMode::Adaptive,
68 effort: None,
69 }
70 }
71
72 #[must_use]
74 pub const fn adaptive_with_effort(effort: Effort) -> Self {
75 Self {
76 mode: ThinkingMode::Adaptive,
77 effort: Some(effort),
78 }
79 }
80
81 #[must_use]
83 pub const fn with_effort(mut self, effort: Effort) -> Self {
84 self.effort = Some(effort);
85 self
86 }
87}
88
89impl Default for ThinkingConfig {
90 fn default() -> Self {
91 Self::new(Self::DEFAULT_BUDGET_TOKENS)
92 }
93}
94
95#[derive(Debug, Clone)]
99pub enum ToolChoice {
100 Auto,
102 Tool(String),
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct ResponseFormat {
121 pub name: String,
124 pub schema: serde_json::Value,
130 pub strict: bool,
134}
135
136impl ResponseFormat {
137 #[must_use]
142 pub fn new(name: impl Into<String>, schema: serde_json::Value) -> Self {
143 Self {
144 name: name.into(),
145 schema,
146 strict: true,
147 }
148 }
149
150 #[must_use]
152 pub const fn with_strict(mut self, strict: bool) -> Self {
153 self.strict = strict;
154 self
155 }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum CacheTtl {
165 FiveMinutes,
167 OneHour,
169}
170
171impl CacheTtl {
172 #[must_use]
174 pub const fn as_wire_str(self) -> &'static str {
175 match self {
176 Self::FiveMinutes => "5m",
177 Self::OneHour => "1h",
178 }
179 }
180}
181
182#[derive(Debug, Clone)]
196pub struct CacheConfig {
197 pub enabled: bool,
199 pub ttl: Option<CacheTtl>,
201 pub max_breakpoints: Option<u8>,
203}
204
205impl Default for CacheConfig {
206 fn default() -> Self {
207 Self::enabled()
208 }
209}
210
211impl CacheConfig {
212 #[must_use]
215 pub const fn enabled() -> Self {
216 Self {
217 enabled: true,
218 ttl: None,
219 max_breakpoints: None,
220 }
221 }
222
223 #[must_use]
225 pub const fn disabled() -> Self {
226 Self {
227 enabled: false,
228 ttl: None,
229 max_breakpoints: None,
230 }
231 }
232
233 #[must_use]
235 pub const fn with_ttl(mut self, ttl: CacheTtl) -> Self {
236 self.ttl = Some(ttl);
237 self
238 }
239
240 #[must_use]
242 pub const fn with_max_breakpoints(mut self, max_breakpoints: u8) -> Self {
243 self.max_breakpoints = Some(max_breakpoints);
244 self
245 }
246}
247
248#[derive(Debug, Clone)]
249pub struct ChatRequest {
250 pub system: String,
251 pub messages: Vec<Message>,
252 pub tools: Option<Vec<Tool>>,
253 pub max_tokens: u32,
254 pub max_tokens_explicit: bool,
256 pub session_id: Option<String>,
258 pub cached_content: Option<String>,
262 pub thinking: Option<ThinkingConfig>,
264 pub tool_choice: Option<ToolChoice>,
268 pub response_format: Option<ResponseFormat>,
276 pub cache: Option<CacheConfig>,
282}
283
284impl ChatRequest {
285 pub const DEFAULT_MAX_TOKENS: u32 = 4096;
288
289 #[must_use]
305 pub fn new(system: impl Into<String>, messages: Vec<Message>) -> Self {
306 Self {
307 system: system.into(),
308 messages,
309 tools: None,
310 max_tokens: Self::DEFAULT_MAX_TOKENS,
311 max_tokens_explicit: false,
312 session_id: None,
313 cached_content: None,
314 thinking: None,
315 tool_choice: None,
316 response_format: None,
317 cache: None,
318 }
319 }
320
321 #[must_use]
323 pub fn with_tools(mut self, tools: Vec<Tool>) -> Self {
324 self.tools = Some(tools);
325 self
326 }
327
328 #[must_use]
330 pub const fn with_max_tokens(mut self, max_tokens: u32) -> Self {
331 self.max_tokens = max_tokens;
332 self.max_tokens_explicit = true;
333 self
334 }
335
336 #[must_use]
338 pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
339 self.session_id = Some(session_id.into());
340 self
341 }
342
343 #[must_use]
345 pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
346 self.thinking = Some(thinking);
347 self
348 }
349
350 #[must_use]
352 pub fn with_tool_choice(mut self, tool_choice: ToolChoice) -> Self {
353 self.tool_choice = Some(tool_choice);
354 self
355 }
356
357 #[must_use]
360 pub fn with_response_format(mut self, response_format: ResponseFormat) -> Self {
361 self.response_format = Some(response_format);
362 self
363 }
364
365 #[must_use]
367 pub const fn with_cache(mut self, cache: CacheConfig) -> Self {
368 self.cache = Some(cache);
369 self
370 }
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct Message {
375 pub role: Role,
376 pub content: Content,
377}
378
379impl Message {
380 #[must_use]
381 pub fn user(text: impl Into<String>) -> Self {
382 Self {
383 role: Role::User,
384 content: Content::Text(text.into()),
385 }
386 }
387
388 #[must_use]
389 pub const fn user_with_content(blocks: Vec<ContentBlock>) -> Self {
390 Self {
391 role: Role::User,
392 content: Content::Blocks(blocks),
393 }
394 }
395
396 #[must_use]
397 pub fn assistant(text: impl Into<String>) -> Self {
398 Self {
399 role: Role::Assistant,
400 content: Content::Text(text.into()),
401 }
402 }
403
404 #[must_use]
405 pub const fn assistant_with_content(blocks: Vec<ContentBlock>) -> Self {
406 Self {
407 role: Role::Assistant,
408 content: Content::Blocks(blocks),
409 }
410 }
411
412 #[must_use]
413 pub fn assistant_with_tool_use(
414 text: Option<String>,
415 id: impl Into<String>,
416 name: impl Into<String>,
417 input: serde_json::Value,
418 ) -> Self {
419 let mut blocks = Vec::new();
420 if let Some(t) = text {
421 blocks.push(ContentBlock::Text { text: t });
422 }
423 blocks.push(ContentBlock::ToolUse {
424 id: id.into(),
425 name: name.into(),
426 input,
427 thought_signature: None,
428 });
429 Self {
430 role: Role::Assistant,
431 content: Content::Blocks(blocks),
432 }
433 }
434
435 #[must_use]
436 pub fn tool_result(
437 tool_use_id: impl Into<String>,
438 content: impl Into<String>,
439 is_error: bool,
440 ) -> Self {
441 Self {
442 role: Role::User,
443 content: Content::Blocks(vec![ContentBlock::ToolResult {
444 tool_use_id: tool_use_id.into(),
445 content: content.into(),
446 is_error: if is_error { Some(true) } else { None },
447 }]),
448 }
449 }
450}
451
452#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
453#[serde(rename_all = "lowercase")]
454pub enum Role {
455 User,
456 Assistant,
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize)]
460#[serde(untagged)]
461pub enum Content {
462 Text(String),
463 Blocks(Vec<ContentBlock>),
464}
465
466impl Content {
467 #[must_use]
468 pub fn first_text(&self) -> Option<&str> {
469 match self {
470 Self::Text(s) => Some(s),
471 Self::Blocks(blocks) => blocks.iter().find_map(|b| match b {
472 ContentBlock::Text { text } => Some(text.as_str()),
473 _ => None,
474 }),
475 }
476 }
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct ContentSource {
482 pub media_type: String,
483 pub data: String,
484}
485
486impl ContentSource {
487 #[must_use]
488 pub fn new(media_type: impl Into<String>, data: impl Into<String>) -> Self {
489 Self {
490 media_type: media_type.into(),
491 data: data.into(),
492 }
493 }
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize)]
497#[serde(tag = "type")]
498#[non_exhaustive]
499pub enum ContentBlock {
500 #[serde(rename = "text")]
501 Text { text: String },
502
503 #[serde(rename = "thinking")]
504 Thinking {
505 thinking: String,
506 #[serde(skip_serializing_if = "Option::is_none")]
508 signature: Option<String>,
509 },
510
511 #[serde(rename = "redacted_thinking")]
512 RedactedThinking { data: String },
513
514 #[serde(rename = "opaque_reasoning")]
522 OpaqueReasoning {
523 provider: String,
524 data: serde_json::Value,
525 },
526
527 #[serde(rename = "tool_use")]
528 ToolUse {
529 id: String,
530 name: String,
531 input: serde_json::Value,
532 #[serde(skip_serializing_if = "Option::is_none")]
535 thought_signature: Option<String>,
536 },
537
538 #[serde(rename = "tool_result")]
539 ToolResult {
540 tool_use_id: String,
541 content: String,
542 #[serde(skip_serializing_if = "Option::is_none")]
543 is_error: Option<bool>,
544 },
545
546 #[serde(rename = "image")]
547 Image { source: ContentSource },
548
549 #[serde(rename = "document")]
550 Document { source: ContentSource },
551}
552
553#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
554pub struct Tool {
555 pub name: String,
556 pub description: String,
557 pub input_schema: serde_json::Value,
558 pub display_name: String,
560 pub tier: super::types::ToolTier,
562}
563
564#[derive(Debug, Clone)]
565pub struct ChatResponse {
566 pub id: String,
567 pub content: Vec<ContentBlock>,
568 pub model: String,
569 pub stop_reason: Option<StopReason>,
570 pub usage: Usage,
571}
572
573impl ChatResponse {
574 #[must_use]
575 pub fn first_text(&self) -> Option<&str> {
576 self.content.iter().find_map(|b| match b {
577 ContentBlock::Text { text } => Some(text.as_str()),
578 _ => None,
579 })
580 }
581
582 #[must_use]
583 pub fn first_thinking(&self) -> Option<&str> {
584 self.content.iter().find_map(|b| match b {
585 ContentBlock::Thinking { thinking, .. } => Some(thinking.as_str()),
586 _ => None,
587 })
588 }
589
590 pub fn tool_uses(&self) -> impl Iterator<Item = (&str, &str, &serde_json::Value)> {
591 self.content.iter().filter_map(|b| match b {
592 ContentBlock::ToolUse {
593 id, name, input, ..
594 } => Some((id.as_str(), name.as_str(), input)),
595 _ => None,
596 })
597 }
598
599 #[must_use]
600 pub fn has_tool_use(&self) -> bool {
601 self.content
602 .iter()
603 .any(|b| matches!(b, ContentBlock::ToolUse { .. }))
604 }
605}
606
607#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
608#[serde(rename_all = "snake_case")]
609#[non_exhaustive]
610pub enum StopReason {
611 EndTurn,
612 ToolUse,
613 MaxTokens,
614 StopSequence,
615 Refusal,
616 ModelContextWindowExceeded,
617 #[serde(other)]
626 Unknown,
627}
628
629impl StopReason {
630 #[must_use]
633 pub const fn as_str(&self) -> &'static str {
634 match self {
635 Self::EndTurn => "end_turn",
636 Self::ToolUse => "tool_use",
637 Self::MaxTokens => "max_tokens",
638 Self::StopSequence => "stop_sequence",
639 Self::Refusal => "refusal",
640 Self::ModelContextWindowExceeded => "model_context_window_exceeded",
641 Self::Unknown => "unknown",
642 }
643 }
644}
645
646#[derive(Debug, Clone, Serialize, Deserialize)]
647pub struct Usage {
648 pub input_tokens: u32,
650 pub output_tokens: u32,
651 #[serde(default)]
653 pub cached_input_tokens: u32,
654 #[serde(default)]
656 pub cache_creation_input_tokens: u32,
657}
658
659#[derive(Debug, Clone)]
660#[non_exhaustive]
661pub enum ChatOutcome {
662 Success(ChatResponse),
663 RateLimited(Option<Duration>),
670 InvalidRequest(String),
671 ServerError(String),
672}
673
674#[must_use]
684pub fn parse_retry_after(value: &str) -> Option<Duration> {
685 let trimmed = value.trim();
686 if trimmed.is_empty() {
687 return None;
688 }
689
690 if let Ok(seconds) = trimmed.parse::<u64>() {
692 return Some(Duration::from_secs(seconds));
693 }
694
695 let target = parse_imf_fixdate(trimmed)?;
697 let now = time::OffsetDateTime::now_utc();
698 if target <= now {
699 return None;
700 }
701 (target - now).try_into().ok()
702}
703
704fn parse_imf_fixdate(value: &str) -> Option<time::OffsetDateTime> {
706 let format = time::format_description::parse_borrowed::<1>(
709 "[weekday repr:short], [day] [month repr:short] [year] \
710 [hour]:[minute]:[second] GMT",
711 )
712 .ok()?;
713 time::PrimitiveDateTime::parse(value, &format)
714 .ok()
715 .map(time::PrimitiveDateTime::assume_utc)
716}
717
718pub const USER_CANCELLED_TOOL_RESULT: &str = "User cancelled";
728
729fn message_tool_use_ids(message: &Message) -> Vec<&str> {
734 match &message.content {
735 Content::Text(_) => Vec::new(),
736 Content::Blocks(blocks) => blocks
737 .iter()
738 .filter_map(|block| match block {
739 ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
740 _ => None,
741 })
742 .collect(),
743 }
744}
745
746fn message_tool_result_ids(message: &Message) -> std::collections::HashSet<&str> {
750 match &message.content {
751 Content::Text(_) => std::collections::HashSet::new(),
752 Content::Blocks(blocks) => blocks
753 .iter()
754 .filter_map(|block| match block {
755 ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()),
756 _ => None,
757 })
758 .collect(),
759 }
760}
761
762fn all_answered_tool_use_ids(messages: &[Message]) -> std::collections::HashSet<&str> {
771 messages.iter().flat_map(message_tool_result_ids).collect()
772}
773
774#[must_use]
784pub fn has_unbalanced_tool_use(messages: &[Message]) -> bool {
785 let answered = all_answered_tool_use_ids(messages);
786 messages
787 .iter()
788 .flat_map(message_tool_use_ids)
789 .any(|id| !answered.contains(id))
790}
791
792#[must_use]
818pub fn balance_tool_results(messages: &[Message], cancel_text: &str) -> Vec<Message> {
819 let answered = all_answered_tool_use_ids(messages);
822 let mut out: Vec<Message> = Vec::with_capacity(messages.len() + 1);
823 let mut idx = 0;
824 while idx < messages.len() {
825 let message = &messages[idx];
826 let tool_use_ids = message_tool_use_ids(message);
827 if tool_use_ids.is_empty() {
828 out.push(message.clone());
829 idx += 1;
830 continue;
831 }
832
833 let synthetic: Vec<ContentBlock> = tool_use_ids
834 .iter()
835 .filter(|id| !answered.contains(*id))
836 .map(|id| ContentBlock::ToolResult {
837 tool_use_id: (*id).to_owned(),
838 content: cancel_text.to_owned(),
839 is_error: Some(true),
840 })
841 .collect();
842
843 out.push(message.clone());
844
845 let next = messages.get(idx + 1);
846
847 if synthetic.is_empty() {
848 idx += 1;
851 continue;
852 }
853
854 match next {
860 Some(next_message) if !message_tool_result_ids(next_message).is_empty() => {
861 let mut merged = next_message.clone();
862 if let Content::Blocks(blocks) = &mut merged.content {
863 blocks.extend(synthetic);
864 } else {
865 merged.content = Content::Blocks(synthetic);
869 }
870 out.push(merged);
871 idx += 2;
872 }
873 _ => {
874 out.push(Message::user_with_content(synthetic));
875 idx += 1;
876 }
877 }
878 }
879 out
880}
881
882#[cfg(test)]
883mod tests {
884 use super::*;
885
886 #[test]
887 fn chat_request_new_defaults_then_setters() {
888 let req = ChatRequest::new("sys", vec![Message::user("hi")]);
889 assert_eq!(req.system, "sys");
890 assert_eq!(req.messages.len(), 1);
891 assert_eq!(req.max_tokens, ChatRequest::DEFAULT_MAX_TOKENS);
892 assert!(!req.max_tokens_explicit);
893 assert!(req.tools.is_none());
894 assert!(req.tool_choice.is_none());
895 assert!(req.response_format.is_none());
896
897 let req = req
898 .with_max_tokens(1234)
899 .with_tool_choice(ToolChoice::Auto)
900 .with_response_format(ResponseFormat::new(
901 "r",
902 serde_json::json!({"type": "object"}),
903 ))
904 .with_session_id("s-1");
905 assert_eq!(req.max_tokens, 1234);
906 assert!(req.max_tokens_explicit);
907 assert!(matches!(req.tool_choice, Some(ToolChoice::Auto)));
908 assert!(req.response_format.is_some());
909 assert_eq!(req.session_id.as_deref(), Some("s-1"));
910 }
911
912 #[test]
913 fn stop_reason_known_values_round_trip() -> Result<(), serde_json::Error> {
914 for (json, expected) in [
915 ("\"end_turn\"", StopReason::EndTurn),
916 ("\"tool_use\"", StopReason::ToolUse),
917 ("\"max_tokens\"", StopReason::MaxTokens),
918 ("\"stop_sequence\"", StopReason::StopSequence),
919 ("\"refusal\"", StopReason::Refusal),
920 (
921 "\"model_context_window_exceeded\"",
922 StopReason::ModelContextWindowExceeded,
923 ),
924 ] {
925 let parsed: StopReason = serde_json::from_str(json)?;
926 assert_eq!(parsed, expected);
927 assert_eq!(serde_json::to_string(&parsed)?, json);
928 }
929 Ok(())
930 }
931
932 #[test]
933 fn stop_reason_unknown_value_deserializes_to_unknown() -> Result<(), serde_json::Error> {
934 let parsed: StopReason = serde_json::from_str("\"some_future_reason\"")?;
937 assert_eq!(parsed, StopReason::Unknown);
938 assert_eq!(parsed.as_str(), "unknown");
939 Ok(())
940 }
941
942 #[test]
943 fn stop_reason_unknown_serializes_to_unknown() -> Result<(), serde_json::Error> {
944 assert_eq!(serde_json::to_string(&StopReason::Unknown)?, "\"unknown\"");
945 Ok(())
946 }
947
948 #[test]
956 fn content_block_text_wire_format() -> Result<(), serde_json::Error> {
957 let json = serde_json::to_value(ContentBlock::Text { text: "hi".into() })?;
958 assert_eq!(json, serde_json::json!({"type": "text", "text": "hi"}));
959 Ok(())
960 }
961
962 #[test]
963 fn content_block_thinking_omits_none_signature() -> Result<(), serde_json::Error> {
964 let none = serde_json::to_value(ContentBlock::Thinking {
965 thinking: "t".into(),
966 signature: None,
967 })?;
968 assert_eq!(
969 none,
970 serde_json::json!({"type": "thinking", "thinking": "t"})
971 );
972
973 let some = serde_json::to_value(ContentBlock::Thinking {
974 thinking: "t".into(),
975 signature: Some("sig".into()),
976 })?;
977 assert_eq!(
978 some,
979 serde_json::json!({"type": "thinking", "thinking": "t", "signature": "sig"})
980 );
981 Ok(())
982 }
983
984 #[test]
985 fn content_block_tool_use_omits_none_thought_signature() -> Result<(), serde_json::Error> {
986 let none = serde_json::to_value(ContentBlock::ToolUse {
987 id: "i".into(),
988 name: "n".into(),
989 input: serde_json::json!({"a": 1}),
990 thought_signature: None,
991 })?;
992 assert_eq!(
993 none,
994 serde_json::json!({"type": "tool_use", "id": "i", "name": "n", "input": {"a": 1}})
995 );
996
997 let some = serde_json::to_value(ContentBlock::ToolUse {
998 id: "i".into(),
999 name: "n".into(),
1000 input: serde_json::json!({}),
1001 thought_signature: Some("ts".into()),
1002 })?;
1003 assert_eq!(
1004 some.get("thought_signature").and_then(|v| v.as_str()),
1005 Some("ts")
1006 );
1007 Ok(())
1008 }
1009
1010 #[test]
1011 fn content_block_tool_result_omits_none_is_error() -> Result<(), serde_json::Error> {
1012 let none = serde_json::to_value(ContentBlock::ToolResult {
1013 tool_use_id: "t".into(),
1014 content: "out".into(),
1015 is_error: None,
1016 })?;
1017 assert_eq!(
1018 none,
1019 serde_json::json!({"type": "tool_result", "tool_use_id": "t", "content": "out"})
1020 );
1021
1022 let some = serde_json::to_value(ContentBlock::ToolResult {
1023 tool_use_id: "t".into(),
1024 content: "out".into(),
1025 is_error: Some(true),
1026 })?;
1027 assert_eq!(
1028 some.get("is_error").and_then(serde_json::Value::as_bool),
1029 Some(true)
1030 );
1031 Ok(())
1032 }
1033
1034 #[test]
1035 fn content_block_remaining_variant_tags() -> Result<(), serde_json::Error> {
1036 assert_eq!(
1037 serde_json::to_value(ContentBlock::RedactedThinking { data: "d".into() })?,
1038 serde_json::json!({"type": "redacted_thinking", "data": "d"})
1039 );
1040 assert_eq!(
1041 serde_json::to_value(ContentBlock::Image {
1042 source: ContentSource::new("image/png", "b64"),
1043 })?,
1044 serde_json::json!({"type": "image", "source": {"media_type": "image/png", "data": "b64"}})
1045 );
1046 assert_eq!(
1047 serde_json::to_value(ContentBlock::Document {
1048 source: ContentSource::new("application/pdf", "b64"),
1049 })?,
1050 serde_json::json!({"type": "document", "source": {"media_type": "application/pdf", "data": "b64"}})
1051 );
1052 assert_eq!(
1053 serde_json::to_value(ContentBlock::OpaqueReasoning {
1054 provider: "test-provider".into(),
1055 data: serde_json::json!({"id": "reasoning_1", "encrypted": "ciphertext"}),
1056 })?,
1057 serde_json::json!({
1058 "type": "opaque_reasoning",
1059 "provider": "test-provider",
1060 "data": {"id": "reasoning_1", "encrypted": "ciphertext"}
1061 })
1062 );
1063 Ok(())
1064 }
1065
1066 #[test]
1067 fn content_block_every_tag_round_trips() -> Result<(), serde_json::Error> {
1068 let blocks = vec![
1069 ContentBlock::Text { text: "t".into() },
1070 ContentBlock::Thinking {
1071 thinking: "th".into(),
1072 signature: Some("s".into()),
1073 },
1074 ContentBlock::RedactedThinking { data: "d".into() },
1075 ContentBlock::OpaqueReasoning {
1076 provider: "test-provider".into(),
1077 data: serde_json::json!({"id": "reasoning_1", "state": [1, 2, 3]}),
1078 },
1079 ContentBlock::ToolUse {
1080 id: "i".into(),
1081 name: "n".into(),
1082 input: serde_json::json!({"x": 1}),
1083 thought_signature: None,
1084 },
1085 ContentBlock::ToolResult {
1086 tool_use_id: "t".into(),
1087 content: "c".into(),
1088 is_error: Some(true),
1089 },
1090 ContentBlock::Image {
1091 source: ContentSource::new("image/png", "b"),
1092 },
1093 ContentBlock::Document {
1094 source: ContentSource::new("application/pdf", "b"),
1095 },
1096 ];
1097 for block in blocks {
1098 let json = serde_json::to_value(&block)?;
1099 let back: ContentBlock = serde_json::from_value(json.clone())?;
1100 assert_eq!(serde_json::to_value(&back)?, json);
1101 }
1102 Ok(())
1103 }
1104
1105 #[test]
1108 fn content_text_serializes_as_bare_string() -> Result<(), serde_json::Error> {
1109 let json = serde_json::to_value(Content::Text("hello".into()))?;
1110 assert_eq!(json, serde_json::json!("hello"));
1111 let back: Content = serde_json::from_value(serde_json::json!("hello"))?;
1112 assert!(matches!(back, Content::Text(s) if s == "hello"));
1113 Ok(())
1114 }
1115
1116 #[test]
1117 fn content_blocks_serialize_as_array_including_empty() -> Result<(), serde_json::Error> {
1118 let json = serde_json::to_value(Content::Blocks(vec![ContentBlock::Text {
1119 text: "x".into(),
1120 }]))?;
1121 assert_eq!(json, serde_json::json!([{"type": "text", "text": "x"}]));
1122
1123 let empty = serde_json::to_value(Content::Blocks(vec![]))?;
1126 assert_eq!(empty, serde_json::json!([]));
1127 let back: Content = serde_json::from_value(empty)?;
1128 assert!(matches!(back, Content::Blocks(b) if b.is_empty()));
1129 Ok(())
1130 }
1131
1132 #[test]
1135 fn message_wire_format_text_and_blocks() -> Result<(), serde_json::Error> {
1136 let user = serde_json::to_value(Message::user("hi"))?;
1137 assert_eq!(user, serde_json::json!({"role": "user", "content": "hi"}));
1138
1139 let assistant =
1140 serde_json::to_value(Message::assistant_with_content(vec![ContentBlock::Text {
1141 text: "yo".into(),
1142 }]))?;
1143 assert_eq!(
1144 assistant,
1145 serde_json::json!({"role": "assistant", "content": [{"type": "text", "text": "yo"}]})
1146 );
1147
1148 let back: Message =
1149 serde_json::from_value(serde_json::json!({"role": "user", "content": "hi"}))?;
1150 assert_eq!(back.role, Role::User);
1151 assert!(matches!(back.content, Content::Text(s) if s == "hi"));
1152 Ok(())
1153 }
1154
1155 #[test]
1158 fn parse_retry_after_delta_seconds() {
1159 assert_eq!(parse_retry_after("125"), Some(Duration::from_secs(125)));
1160 assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
1161 assert_eq!(parse_retry_after(" 30 "), Some(Duration::from_secs(30)));
1163 }
1164
1165 #[test]
1166 fn parse_retry_after_rejects_garbage_and_empty() {
1167 assert_eq!(parse_retry_after(""), None);
1168 assert_eq!(parse_retry_after(" "), None);
1169 assert_eq!(parse_retry_after("soon"), None);
1170 assert_eq!(parse_retry_after("-5"), None);
1172 }
1173
1174 #[test]
1175 fn parse_retry_after_past_imf_date_is_none() {
1176 assert_eq!(parse_retry_after("Sun, 06 Nov 1994 08:49:37 GMT"), None);
1178 }
1179
1180 #[test]
1181 fn parse_retry_after_future_imf_date_is_some() {
1182 let parsed = parse_retry_after("Fri, 31 Dec 9999 23:59:59 GMT");
1186 assert!(parsed.is_some_and(|d| d > Duration::from_secs(1_000_000)));
1187 }
1188
1189 #[test]
1192 fn cache_ttl_wire_strings() {
1193 assert_eq!(CacheTtl::FiveMinutes.as_wire_str(), "5m");
1194 assert_eq!(CacheTtl::OneHour.as_wire_str(), "1h");
1195 }
1196
1197 #[test]
1198 fn cache_config_builders_and_default_request_cache_is_none() {
1199 let req = ChatRequest::new("sys", vec![Message::user("hi")]);
1200 assert!(
1201 req.cache.is_none(),
1202 "default request must not set a cache config"
1203 );
1204
1205 let enabled = CacheConfig::enabled().with_ttl(CacheTtl::OneHour);
1206 assert!(enabled.enabled);
1207 assert_eq!(enabled.ttl, Some(CacheTtl::OneHour));
1208 assert_eq!(enabled.max_breakpoints, None);
1209
1210 let disabled = CacheConfig::disabled();
1211 assert!(!disabled.enabled);
1212
1213 let capped = CacheConfig::enabled().with_max_breakpoints(2);
1214 assert_eq!(capped.max_breakpoints, Some(2));
1215
1216 let req = ChatRequest::new("s", vec![]).with_cache(CacheConfig::disabled());
1217 assert!(req.cache.is_some_and(|c| !c.enabled));
1218 }
1219
1220 fn assistant_tool_uses(ids: &[&str]) -> Message {
1221 let blocks = ids
1222 .iter()
1223 .map(|id| ContentBlock::ToolUse {
1224 id: (*id).to_string(),
1225 name: "ask_user".to_string(),
1226 input: serde_json::json!({}),
1227 thought_signature: None,
1228 })
1229 .collect();
1230 Message::assistant_with_content(blocks)
1231 }
1232
1233 fn tool_results(ids: &[&str]) -> Message {
1234 let blocks = ids
1235 .iter()
1236 .map(|id| ContentBlock::ToolResult {
1237 tool_use_id: (*id).to_string(),
1238 content: "answered".to_string(),
1239 is_error: None,
1240 })
1241 .collect();
1242 Message::user_with_content(blocks)
1243 }
1244
1245 fn assert_balanced(messages: &[Message]) {
1246 assert!(
1247 !has_unbalanced_tool_use(messages),
1248 "expected balanced history, found an orphaned tool_use",
1249 );
1250 }
1251
1252 #[test]
1253 fn balanced_history_is_left_untouched() {
1254 let messages = vec![
1255 Message::user("hi"),
1256 assistant_tool_uses(&["a"]),
1257 tool_results(&["a"]),
1258 ];
1259 assert!(!has_unbalanced_tool_use(&messages));
1260 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1261 assert_eq!(out.len(), 3);
1262 assert_balanced(&out);
1263 }
1264
1265 #[test]
1266 fn partial_cancellation_merges_into_existing_results_message() {
1267 let messages = vec![
1269 assistant_tool_uses(&["q1", "q2", "q3", "q4"]),
1270 tool_results(&["q1"]),
1271 ];
1272 assert!(has_unbalanced_tool_use(&messages));
1273
1274 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1275 assert_eq!(
1276 out.len(),
1277 2,
1278 "synthetic results merge into the existing message"
1279 );
1280 assert_balanced(&out);
1281
1282 let Content::Blocks(blocks) = &out[1].content else {
1283 panic!("results message must carry blocks");
1284 };
1285 let cancelled: Vec<&str> = blocks
1286 .iter()
1287 .filter_map(|b| match b {
1288 ContentBlock::ToolResult {
1289 tool_use_id,
1290 content,
1291 is_error: Some(true),
1292 } if content == USER_CANCELLED_TOOL_RESULT => Some(tool_use_id.as_str()),
1293 _ => None,
1294 })
1295 .collect();
1296 assert_eq!(cancelled, vec!["q2", "q3", "q4"]);
1297 }
1298
1299 #[test]
1300 fn all_cancelled_with_no_following_message_appends_results() {
1301 let messages = vec![assistant_tool_uses(&["q1", "q2"])];
1303 assert!(has_unbalanced_tool_use(&messages));
1304
1305 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1306 assert_eq!(out.len(), 2, "a fresh results message is inserted");
1307 assert_eq!(out[1].role, Role::User);
1308 assert_balanced(&out);
1309 }
1310
1311 #[test]
1312 fn orphan_followed_by_user_prompt_inserts_results_between() {
1313 let messages = vec![
1316 assistant_tool_uses(&["q1"]),
1317 Message::user("a brand new question from the user"),
1318 ];
1319 assert!(has_unbalanced_tool_use(&messages));
1320
1321 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1322 assert_eq!(out.len(), 3);
1323 assert_balanced(&out);
1324 assert!(!message_tool_use_ids(&out[0]).is_empty());
1326 assert!(!message_tool_result_ids(&out[1]).is_empty());
1327 assert_eq!(
1328 out[2].content.first_text(),
1329 Some("a brand new question from the user")
1330 );
1331 }
1332
1333 #[test]
1334 fn balancing_is_idempotent() {
1335 let messages = vec![
1336 assistant_tool_uses(&["q1", "q2", "q3"]),
1337 tool_results(&["q2"]),
1338 ];
1339 let once = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1340 let twice = balance_tool_results(&once, USER_CANCELLED_TOOL_RESULT);
1341 assert_eq!(once.len(), twice.len());
1342 assert_balanced(&twice);
1343 }
1344
1345 #[test]
1346 fn no_tool_use_history_is_a_noop() {
1347 let messages = vec![Message::user("hi"), Message::assistant("hello")];
1348 assert!(!has_unbalanced_tool_use(&messages));
1349 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1350 assert_eq!(out.len(), 2);
1351 }
1352
1353 #[test]
1354 fn real_result_not_at_idx1_is_not_duplicated_or_relabelled() {
1355 let messages = vec![
1361 assistant_tool_uses(&["a"]),
1362 Message::user("an interjection between the call and its result"),
1363 tool_results(&["a"]),
1364 ];
1365 assert!(!has_unbalanced_tool_use(&messages));
1367
1368 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1369 let a_results: Vec<&ContentBlock> = out
1372 .iter()
1373 .flat_map(|m| match &m.content {
1374 Content::Blocks(b) => b.as_slice(),
1375 Content::Text(_) => &[][..],
1376 })
1377 .filter(
1378 |b| matches!(b, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "a"),
1379 )
1380 .collect();
1381 assert_eq!(a_results.len(), 1, "must not duplicate the real result");
1382 assert!(
1383 !matches!(a_results[0], ContentBlock::ToolResult { content, .. } if content == USER_CANCELLED_TOOL_RESULT),
1384 "the real successful result must not be relabelled cancelled",
1385 );
1386 }
1387}