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 = "tool_use")]
515 ToolUse {
516 id: String,
517 name: String,
518 input: serde_json::Value,
519 #[serde(skip_serializing_if = "Option::is_none")]
522 thought_signature: Option<String>,
523 },
524
525 #[serde(rename = "tool_result")]
526 ToolResult {
527 tool_use_id: String,
528 content: String,
529 #[serde(skip_serializing_if = "Option::is_none")]
530 is_error: Option<bool>,
531 },
532
533 #[serde(rename = "image")]
534 Image { source: ContentSource },
535
536 #[serde(rename = "document")]
537 Document { source: ContentSource },
538}
539
540#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
541pub struct Tool {
542 pub name: String,
543 pub description: String,
544 pub input_schema: serde_json::Value,
545 pub display_name: String,
547 pub tier: super::types::ToolTier,
549}
550
551#[derive(Debug, Clone)]
552pub struct ChatResponse {
553 pub id: String,
554 pub content: Vec<ContentBlock>,
555 pub model: String,
556 pub stop_reason: Option<StopReason>,
557 pub usage: Usage,
558}
559
560impl ChatResponse {
561 #[must_use]
562 pub fn first_text(&self) -> Option<&str> {
563 self.content.iter().find_map(|b| match b {
564 ContentBlock::Text { text } => Some(text.as_str()),
565 _ => None,
566 })
567 }
568
569 #[must_use]
570 pub fn first_thinking(&self) -> Option<&str> {
571 self.content.iter().find_map(|b| match b {
572 ContentBlock::Thinking { thinking, .. } => Some(thinking.as_str()),
573 _ => None,
574 })
575 }
576
577 pub fn tool_uses(&self) -> impl Iterator<Item = (&str, &str, &serde_json::Value)> {
578 self.content.iter().filter_map(|b| match b {
579 ContentBlock::ToolUse {
580 id, name, input, ..
581 } => Some((id.as_str(), name.as_str(), input)),
582 _ => None,
583 })
584 }
585
586 #[must_use]
587 pub fn has_tool_use(&self) -> bool {
588 self.content
589 .iter()
590 .any(|b| matches!(b, ContentBlock::ToolUse { .. }))
591 }
592}
593
594#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
595#[serde(rename_all = "snake_case")]
596#[non_exhaustive]
597pub enum StopReason {
598 EndTurn,
599 ToolUse,
600 MaxTokens,
601 StopSequence,
602 Refusal,
603 ModelContextWindowExceeded,
604 #[serde(other)]
613 Unknown,
614}
615
616impl StopReason {
617 #[must_use]
620 pub const fn as_str(&self) -> &'static str {
621 match self {
622 Self::EndTurn => "end_turn",
623 Self::ToolUse => "tool_use",
624 Self::MaxTokens => "max_tokens",
625 Self::StopSequence => "stop_sequence",
626 Self::Refusal => "refusal",
627 Self::ModelContextWindowExceeded => "model_context_window_exceeded",
628 Self::Unknown => "unknown",
629 }
630 }
631}
632
633#[derive(Debug, Clone, Serialize, Deserialize)]
634pub struct Usage {
635 pub input_tokens: u32,
637 pub output_tokens: u32,
638 #[serde(default)]
640 pub cached_input_tokens: u32,
641 #[serde(default)]
643 pub cache_creation_input_tokens: u32,
644}
645
646#[derive(Debug, Clone)]
647#[non_exhaustive]
648pub enum ChatOutcome {
649 Success(ChatResponse),
650 RateLimited(Option<Duration>),
657 InvalidRequest(String),
658 ServerError(String),
659}
660
661#[must_use]
671pub fn parse_retry_after(value: &str) -> Option<Duration> {
672 let trimmed = value.trim();
673 if trimmed.is_empty() {
674 return None;
675 }
676
677 if let Ok(seconds) = trimmed.parse::<u64>() {
679 return Some(Duration::from_secs(seconds));
680 }
681
682 let target = parse_imf_fixdate(trimmed)?;
684 let now = time::OffsetDateTime::now_utc();
685 if target <= now {
686 return None;
687 }
688 (target - now).try_into().ok()
689}
690
691fn parse_imf_fixdate(value: &str) -> Option<time::OffsetDateTime> {
693 let format = time::format_description::parse_borrowed::<1>(
696 "[weekday repr:short], [day] [month repr:short] [year] \
697 [hour]:[minute]:[second] GMT",
698 )
699 .ok()?;
700 time::PrimitiveDateTime::parse(value, &format)
701 .ok()
702 .map(time::PrimitiveDateTime::assume_utc)
703}
704
705pub const USER_CANCELLED_TOOL_RESULT: &str = "User cancelled";
715
716fn message_tool_use_ids(message: &Message) -> Vec<&str> {
721 match &message.content {
722 Content::Text(_) => Vec::new(),
723 Content::Blocks(blocks) => blocks
724 .iter()
725 .filter_map(|block| match block {
726 ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
727 _ => None,
728 })
729 .collect(),
730 }
731}
732
733fn message_tool_result_ids(message: &Message) -> std::collections::HashSet<&str> {
737 match &message.content {
738 Content::Text(_) => std::collections::HashSet::new(),
739 Content::Blocks(blocks) => blocks
740 .iter()
741 .filter_map(|block| match block {
742 ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()),
743 _ => None,
744 })
745 .collect(),
746 }
747}
748
749fn all_answered_tool_use_ids(messages: &[Message]) -> std::collections::HashSet<&str> {
758 messages.iter().flat_map(message_tool_result_ids).collect()
759}
760
761#[must_use]
771pub fn has_unbalanced_tool_use(messages: &[Message]) -> bool {
772 let answered = all_answered_tool_use_ids(messages);
773 messages
774 .iter()
775 .flat_map(message_tool_use_ids)
776 .any(|id| !answered.contains(id))
777}
778
779#[must_use]
805pub fn balance_tool_results(messages: &[Message], cancel_text: &str) -> Vec<Message> {
806 let answered = all_answered_tool_use_ids(messages);
809 let mut out: Vec<Message> = Vec::with_capacity(messages.len() + 1);
810 let mut idx = 0;
811 while idx < messages.len() {
812 let message = &messages[idx];
813 let tool_use_ids = message_tool_use_ids(message);
814 if tool_use_ids.is_empty() {
815 out.push(message.clone());
816 idx += 1;
817 continue;
818 }
819
820 let synthetic: Vec<ContentBlock> = tool_use_ids
821 .iter()
822 .filter(|id| !answered.contains(*id))
823 .map(|id| ContentBlock::ToolResult {
824 tool_use_id: (*id).to_owned(),
825 content: cancel_text.to_owned(),
826 is_error: Some(true),
827 })
828 .collect();
829
830 out.push(message.clone());
831
832 let next = messages.get(idx + 1);
833
834 if synthetic.is_empty() {
835 idx += 1;
838 continue;
839 }
840
841 match next {
847 Some(next_message) if !message_tool_result_ids(next_message).is_empty() => {
848 let mut merged = next_message.clone();
849 if let Content::Blocks(blocks) = &mut merged.content {
850 blocks.extend(synthetic);
851 } else {
852 merged.content = Content::Blocks(synthetic);
856 }
857 out.push(merged);
858 idx += 2;
859 }
860 _ => {
861 out.push(Message::user_with_content(synthetic));
862 idx += 1;
863 }
864 }
865 }
866 out
867}
868
869#[cfg(test)]
870mod tests {
871 use super::*;
872
873 #[test]
874 fn chat_request_new_defaults_then_setters() {
875 let req = ChatRequest::new("sys", vec![Message::user("hi")]);
876 assert_eq!(req.system, "sys");
877 assert_eq!(req.messages.len(), 1);
878 assert_eq!(req.max_tokens, ChatRequest::DEFAULT_MAX_TOKENS);
879 assert!(!req.max_tokens_explicit);
880 assert!(req.tools.is_none());
881 assert!(req.tool_choice.is_none());
882 assert!(req.response_format.is_none());
883
884 let req = req
885 .with_max_tokens(1234)
886 .with_tool_choice(ToolChoice::Auto)
887 .with_response_format(ResponseFormat::new(
888 "r",
889 serde_json::json!({"type": "object"}),
890 ))
891 .with_session_id("s-1");
892 assert_eq!(req.max_tokens, 1234);
893 assert!(req.max_tokens_explicit);
894 assert!(matches!(req.tool_choice, Some(ToolChoice::Auto)));
895 assert!(req.response_format.is_some());
896 assert_eq!(req.session_id.as_deref(), Some("s-1"));
897 }
898
899 #[test]
900 fn stop_reason_known_values_round_trip() -> Result<(), serde_json::Error> {
901 for (json, expected) in [
902 ("\"end_turn\"", StopReason::EndTurn),
903 ("\"tool_use\"", StopReason::ToolUse),
904 ("\"max_tokens\"", StopReason::MaxTokens),
905 ("\"stop_sequence\"", StopReason::StopSequence),
906 ("\"refusal\"", StopReason::Refusal),
907 (
908 "\"model_context_window_exceeded\"",
909 StopReason::ModelContextWindowExceeded,
910 ),
911 ] {
912 let parsed: StopReason = serde_json::from_str(json)?;
913 assert_eq!(parsed, expected);
914 assert_eq!(serde_json::to_string(&parsed)?, json);
915 }
916 Ok(())
917 }
918
919 #[test]
920 fn stop_reason_unknown_value_deserializes_to_unknown() -> Result<(), serde_json::Error> {
921 let parsed: StopReason = serde_json::from_str("\"some_future_reason\"")?;
924 assert_eq!(parsed, StopReason::Unknown);
925 assert_eq!(parsed.as_str(), "unknown");
926 Ok(())
927 }
928
929 #[test]
930 fn stop_reason_unknown_serializes_to_unknown() -> Result<(), serde_json::Error> {
931 assert_eq!(serde_json::to_string(&StopReason::Unknown)?, "\"unknown\"");
932 Ok(())
933 }
934
935 #[test]
943 fn content_block_text_wire_format() -> Result<(), serde_json::Error> {
944 let json = serde_json::to_value(ContentBlock::Text { text: "hi".into() })?;
945 assert_eq!(json, serde_json::json!({"type": "text", "text": "hi"}));
946 Ok(())
947 }
948
949 #[test]
950 fn content_block_thinking_omits_none_signature() -> Result<(), serde_json::Error> {
951 let none = serde_json::to_value(ContentBlock::Thinking {
952 thinking: "t".into(),
953 signature: None,
954 })?;
955 assert_eq!(
956 none,
957 serde_json::json!({"type": "thinking", "thinking": "t"})
958 );
959
960 let some = serde_json::to_value(ContentBlock::Thinking {
961 thinking: "t".into(),
962 signature: Some("sig".into()),
963 })?;
964 assert_eq!(
965 some,
966 serde_json::json!({"type": "thinking", "thinking": "t", "signature": "sig"})
967 );
968 Ok(())
969 }
970
971 #[test]
972 fn content_block_tool_use_omits_none_thought_signature() -> Result<(), serde_json::Error> {
973 let none = serde_json::to_value(ContentBlock::ToolUse {
974 id: "i".into(),
975 name: "n".into(),
976 input: serde_json::json!({"a": 1}),
977 thought_signature: None,
978 })?;
979 assert_eq!(
980 none,
981 serde_json::json!({"type": "tool_use", "id": "i", "name": "n", "input": {"a": 1}})
982 );
983
984 let some = serde_json::to_value(ContentBlock::ToolUse {
985 id: "i".into(),
986 name: "n".into(),
987 input: serde_json::json!({}),
988 thought_signature: Some("ts".into()),
989 })?;
990 assert_eq!(
991 some.get("thought_signature").and_then(|v| v.as_str()),
992 Some("ts")
993 );
994 Ok(())
995 }
996
997 #[test]
998 fn content_block_tool_result_omits_none_is_error() -> Result<(), serde_json::Error> {
999 let none = serde_json::to_value(ContentBlock::ToolResult {
1000 tool_use_id: "t".into(),
1001 content: "out".into(),
1002 is_error: None,
1003 })?;
1004 assert_eq!(
1005 none,
1006 serde_json::json!({"type": "tool_result", "tool_use_id": "t", "content": "out"})
1007 );
1008
1009 let some = serde_json::to_value(ContentBlock::ToolResult {
1010 tool_use_id: "t".into(),
1011 content: "out".into(),
1012 is_error: Some(true),
1013 })?;
1014 assert_eq!(
1015 some.get("is_error").and_then(serde_json::Value::as_bool),
1016 Some(true)
1017 );
1018 Ok(())
1019 }
1020
1021 #[test]
1022 fn content_block_remaining_variant_tags() -> Result<(), serde_json::Error> {
1023 assert_eq!(
1024 serde_json::to_value(ContentBlock::RedactedThinking { data: "d".into() })?,
1025 serde_json::json!({"type": "redacted_thinking", "data": "d"})
1026 );
1027 assert_eq!(
1028 serde_json::to_value(ContentBlock::Image {
1029 source: ContentSource::new("image/png", "b64"),
1030 })?,
1031 serde_json::json!({"type": "image", "source": {"media_type": "image/png", "data": "b64"}})
1032 );
1033 assert_eq!(
1034 serde_json::to_value(ContentBlock::Document {
1035 source: ContentSource::new("application/pdf", "b64"),
1036 })?,
1037 serde_json::json!({"type": "document", "source": {"media_type": "application/pdf", "data": "b64"}})
1038 );
1039 Ok(())
1040 }
1041
1042 #[test]
1043 fn content_block_every_tag_round_trips() -> Result<(), serde_json::Error> {
1044 let blocks = vec![
1045 ContentBlock::Text { text: "t".into() },
1046 ContentBlock::Thinking {
1047 thinking: "th".into(),
1048 signature: Some("s".into()),
1049 },
1050 ContentBlock::RedactedThinking { data: "d".into() },
1051 ContentBlock::ToolUse {
1052 id: "i".into(),
1053 name: "n".into(),
1054 input: serde_json::json!({"x": 1}),
1055 thought_signature: None,
1056 },
1057 ContentBlock::ToolResult {
1058 tool_use_id: "t".into(),
1059 content: "c".into(),
1060 is_error: Some(true),
1061 },
1062 ContentBlock::Image {
1063 source: ContentSource::new("image/png", "b"),
1064 },
1065 ContentBlock::Document {
1066 source: ContentSource::new("application/pdf", "b"),
1067 },
1068 ];
1069 for block in blocks {
1070 let json = serde_json::to_value(&block)?;
1071 let back: ContentBlock = serde_json::from_value(json.clone())?;
1072 assert_eq!(serde_json::to_value(&back)?, json);
1073 }
1074 Ok(())
1075 }
1076
1077 #[test]
1080 fn content_text_serializes_as_bare_string() -> Result<(), serde_json::Error> {
1081 let json = serde_json::to_value(Content::Text("hello".into()))?;
1082 assert_eq!(json, serde_json::json!("hello"));
1083 let back: Content = serde_json::from_value(serde_json::json!("hello"))?;
1084 assert!(matches!(back, Content::Text(s) if s == "hello"));
1085 Ok(())
1086 }
1087
1088 #[test]
1089 fn content_blocks_serialize_as_array_including_empty() -> Result<(), serde_json::Error> {
1090 let json = serde_json::to_value(Content::Blocks(vec![ContentBlock::Text {
1091 text: "x".into(),
1092 }]))?;
1093 assert_eq!(json, serde_json::json!([{"type": "text", "text": "x"}]));
1094
1095 let empty = serde_json::to_value(Content::Blocks(vec![]))?;
1098 assert_eq!(empty, serde_json::json!([]));
1099 let back: Content = serde_json::from_value(empty)?;
1100 assert!(matches!(back, Content::Blocks(b) if b.is_empty()));
1101 Ok(())
1102 }
1103
1104 #[test]
1107 fn message_wire_format_text_and_blocks() -> Result<(), serde_json::Error> {
1108 let user = serde_json::to_value(Message::user("hi"))?;
1109 assert_eq!(user, serde_json::json!({"role": "user", "content": "hi"}));
1110
1111 let assistant =
1112 serde_json::to_value(Message::assistant_with_content(vec![ContentBlock::Text {
1113 text: "yo".into(),
1114 }]))?;
1115 assert_eq!(
1116 assistant,
1117 serde_json::json!({"role": "assistant", "content": [{"type": "text", "text": "yo"}]})
1118 );
1119
1120 let back: Message =
1121 serde_json::from_value(serde_json::json!({"role": "user", "content": "hi"}))?;
1122 assert_eq!(back.role, Role::User);
1123 assert!(matches!(back.content, Content::Text(s) if s == "hi"));
1124 Ok(())
1125 }
1126
1127 #[test]
1130 fn parse_retry_after_delta_seconds() {
1131 assert_eq!(parse_retry_after("125"), Some(Duration::from_secs(125)));
1132 assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
1133 assert_eq!(parse_retry_after(" 30 "), Some(Duration::from_secs(30)));
1135 }
1136
1137 #[test]
1138 fn parse_retry_after_rejects_garbage_and_empty() {
1139 assert_eq!(parse_retry_after(""), None);
1140 assert_eq!(parse_retry_after(" "), None);
1141 assert_eq!(parse_retry_after("soon"), None);
1142 assert_eq!(parse_retry_after("-5"), None);
1144 }
1145
1146 #[test]
1147 fn parse_retry_after_past_imf_date_is_none() {
1148 assert_eq!(parse_retry_after("Sun, 06 Nov 1994 08:49:37 GMT"), None);
1150 }
1151
1152 #[test]
1153 fn parse_retry_after_future_imf_date_is_some() {
1154 let parsed = parse_retry_after("Fri, 31 Dec 9999 23:59:59 GMT");
1158 assert!(parsed.is_some_and(|d| d > Duration::from_secs(1_000_000)));
1159 }
1160
1161 #[test]
1164 fn cache_ttl_wire_strings() {
1165 assert_eq!(CacheTtl::FiveMinutes.as_wire_str(), "5m");
1166 assert_eq!(CacheTtl::OneHour.as_wire_str(), "1h");
1167 }
1168
1169 #[test]
1170 fn cache_config_builders_and_default_request_cache_is_none() {
1171 let req = ChatRequest::new("sys", vec![Message::user("hi")]);
1172 assert!(
1173 req.cache.is_none(),
1174 "default request must not set a cache config"
1175 );
1176
1177 let enabled = CacheConfig::enabled().with_ttl(CacheTtl::OneHour);
1178 assert!(enabled.enabled);
1179 assert_eq!(enabled.ttl, Some(CacheTtl::OneHour));
1180 assert_eq!(enabled.max_breakpoints, None);
1181
1182 let disabled = CacheConfig::disabled();
1183 assert!(!disabled.enabled);
1184
1185 let capped = CacheConfig::enabled().with_max_breakpoints(2);
1186 assert_eq!(capped.max_breakpoints, Some(2));
1187
1188 let req = ChatRequest::new("s", vec![]).with_cache(CacheConfig::disabled());
1189 assert!(req.cache.is_some_and(|c| !c.enabled));
1190 }
1191
1192 fn assistant_tool_uses(ids: &[&str]) -> Message {
1193 let blocks = ids
1194 .iter()
1195 .map(|id| ContentBlock::ToolUse {
1196 id: (*id).to_string(),
1197 name: "ask_user".to_string(),
1198 input: serde_json::json!({}),
1199 thought_signature: None,
1200 })
1201 .collect();
1202 Message::assistant_with_content(blocks)
1203 }
1204
1205 fn tool_results(ids: &[&str]) -> Message {
1206 let blocks = ids
1207 .iter()
1208 .map(|id| ContentBlock::ToolResult {
1209 tool_use_id: (*id).to_string(),
1210 content: "answered".to_string(),
1211 is_error: None,
1212 })
1213 .collect();
1214 Message::user_with_content(blocks)
1215 }
1216
1217 fn assert_balanced(messages: &[Message]) {
1218 assert!(
1219 !has_unbalanced_tool_use(messages),
1220 "expected balanced history, found an orphaned tool_use",
1221 );
1222 }
1223
1224 #[test]
1225 fn balanced_history_is_left_untouched() {
1226 let messages = vec![
1227 Message::user("hi"),
1228 assistant_tool_uses(&["a"]),
1229 tool_results(&["a"]),
1230 ];
1231 assert!(!has_unbalanced_tool_use(&messages));
1232 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1233 assert_eq!(out.len(), 3);
1234 assert_balanced(&out);
1235 }
1236
1237 #[test]
1238 fn partial_cancellation_merges_into_existing_results_message() {
1239 let messages = vec![
1241 assistant_tool_uses(&["q1", "q2", "q3", "q4"]),
1242 tool_results(&["q1"]),
1243 ];
1244 assert!(has_unbalanced_tool_use(&messages));
1245
1246 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1247 assert_eq!(
1248 out.len(),
1249 2,
1250 "synthetic results merge into the existing message"
1251 );
1252 assert_balanced(&out);
1253
1254 let Content::Blocks(blocks) = &out[1].content else {
1255 panic!("results message must carry blocks");
1256 };
1257 let cancelled: Vec<&str> = blocks
1258 .iter()
1259 .filter_map(|b| match b {
1260 ContentBlock::ToolResult {
1261 tool_use_id,
1262 content,
1263 is_error: Some(true),
1264 } if content == USER_CANCELLED_TOOL_RESULT => Some(tool_use_id.as_str()),
1265 _ => None,
1266 })
1267 .collect();
1268 assert_eq!(cancelled, vec!["q2", "q3", "q4"]);
1269 }
1270
1271 #[test]
1272 fn all_cancelled_with_no_following_message_appends_results() {
1273 let messages = vec![assistant_tool_uses(&["q1", "q2"])];
1275 assert!(has_unbalanced_tool_use(&messages));
1276
1277 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1278 assert_eq!(out.len(), 2, "a fresh results message is inserted");
1279 assert_eq!(out[1].role, Role::User);
1280 assert_balanced(&out);
1281 }
1282
1283 #[test]
1284 fn orphan_followed_by_user_prompt_inserts_results_between() {
1285 let messages = vec![
1288 assistant_tool_uses(&["q1"]),
1289 Message::user("a brand new question from the user"),
1290 ];
1291 assert!(has_unbalanced_tool_use(&messages));
1292
1293 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1294 assert_eq!(out.len(), 3);
1295 assert_balanced(&out);
1296 assert!(!message_tool_use_ids(&out[0]).is_empty());
1298 assert!(!message_tool_result_ids(&out[1]).is_empty());
1299 assert!(out[2].content.first_text() == Some("a brand new question from the user"));
1300 }
1301
1302 #[test]
1303 fn balancing_is_idempotent() {
1304 let messages = vec![
1305 assistant_tool_uses(&["q1", "q2", "q3"]),
1306 tool_results(&["q2"]),
1307 ];
1308 let once = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1309 let twice = balance_tool_results(&once, USER_CANCELLED_TOOL_RESULT);
1310 assert_eq!(once.len(), twice.len());
1311 assert_balanced(&twice);
1312 }
1313
1314 #[test]
1315 fn no_tool_use_history_is_a_noop() {
1316 let messages = vec![Message::user("hi"), Message::assistant("hello")];
1317 assert!(!has_unbalanced_tool_use(&messages));
1318 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1319 assert_eq!(out.len(), 2);
1320 }
1321
1322 #[test]
1323 fn real_result_not_at_idx1_is_not_duplicated_or_relabelled() {
1324 let messages = vec![
1330 assistant_tool_uses(&["a"]),
1331 Message::user("an interjection between the call and its result"),
1332 tool_results(&["a"]),
1333 ];
1334 assert!(!has_unbalanced_tool_use(&messages));
1336
1337 let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1338 let a_results: Vec<&ContentBlock> = out
1341 .iter()
1342 .flat_map(|m| match &m.content {
1343 Content::Blocks(b) => b.as_slice(),
1344 Content::Text(_) => &[][..],
1345 })
1346 .filter(
1347 |b| matches!(b, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "a"),
1348 )
1349 .collect();
1350 assert_eq!(a_results.len(), 1, "must not duplicate the real result");
1351 assert!(
1352 !matches!(a_results[0], ContentBlock::ToolResult { content, .. } if content == USER_CANCELLED_TOOL_RESULT),
1353 "the real successful result must not be relabelled cancelled",
1354 );
1355 }
1356}