1use agent_sdk_foundation::llm::{ContentBlock, ServedSpeed, StopReason, Usage};
8#[cfg(any(feature = "openai", feature = "openai-codex"))]
9use bytes::BytesMut;
10use futures::Stream;
11use std::collections::HashMap;
12use std::pin::Pin;
13use std::time::Duration;
14
15const MAX_BLOCK_INDEX: usize = 4096;
24
25#[cfg(any(feature = "openai", feature = "openai-codex"))]
41#[derive(Debug, Default)]
42pub(crate) struct SseLineBuffer {
43 buf: BytesMut,
44}
45
46#[cfg(any(feature = "openai", feature = "openai-codex"))]
47impl SseLineBuffer {
48 #[must_use]
50 pub(crate) fn new() -> Self {
51 Self::default()
52 }
53
54 pub(crate) fn extend(&mut self, chunk: &[u8]) {
56 self.buf.extend_from_slice(chunk);
57 }
58
59 pub(crate) fn next_line(&mut self) -> Option<String> {
64 let newline = self.buf.iter().position(|&b| b == b'\n')?;
65 let mut line = self.buf.split_to(newline + 1);
66 line.truncate(newline);
67 Some(String::from_utf8_lossy(&line).into_owned())
68 }
69}
70
71#[derive(Debug, Clone)]
76#[non_exhaustive]
77pub enum StreamDelta {
78 TextDelta {
80 delta: String,
82 block_index: usize,
84 },
85
86 ThinkingDelta {
88 delta: String,
90 block_index: usize,
92 },
93
94 ToolUseStart {
96 id: String,
98 name: String,
100 block_index: usize,
102 thought_signature: Option<String>,
104 },
105
106 ToolInputDelta {
108 id: String,
110 delta: String,
112 block_index: usize,
114 },
115
116 Usage(Usage),
118
119 Done {
121 stop_reason: Option<StopReason>,
123 served_route: Option<String>,
131 },
132
133 SignatureDelta {
135 delta: String,
137 block_index: usize,
139 },
140
141 RedactedThinking {
143 data: String,
145 block_index: usize,
147 },
148
149 OpaqueReasoning {
155 provider: String,
157 data: serde_json::Value,
159 block_index: usize,
161 },
162
163 Error {
165 message: String,
167 kind: StreamErrorKind,
172 },
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184#[non_exhaustive]
185pub enum StreamErrorKind {
186 Connectivity,
190 ConnectionLost,
195 RateLimited(Option<Duration>),
202 ServerError,
204 InvalidRequest,
207 Unknown,
217}
218
219impl StreamErrorKind {
220 #[must_use]
224 pub const fn is_recoverable(self) -> bool {
225 matches!(
226 self,
227 Self::Connectivity | Self::ConnectionLost | Self::RateLimited(_) | Self::ServerError
228 )
229 }
230
231 #[must_use]
233 pub const fn retry_after(self) -> Option<Duration> {
234 match self {
235 Self::RateLimited(retry_after) => retry_after,
236 _ => None,
237 }
238 }
239
240 #[must_use]
242 pub const fn is_connectivity(self) -> bool {
243 matches!(self, Self::Connectivity | Self::ConnectionLost)
244 }
245
246 #[must_use]
259 pub const fn wire_label(self) -> &'static str {
260 match self {
261 Self::Connectivity => "connectivity",
262 Self::ConnectionLost => "connection_lost",
263 Self::RateLimited(_) => "rate_limited",
264 Self::ServerError => "server_error",
265 Self::InvalidRequest => "invalid_request",
266 Self::Unknown => "unknown",
267 }
268 }
269}
270
271#[must_use]
273pub fn classify_reqwest_error(error: &reqwest::Error) -> StreamErrorKind {
274 if is_proxy_tunnel_rejection(error) || is_tls_rejection(error) {
275 StreamErrorKind::ServerError
276 } else if error.is_connect() {
277 StreamErrorKind::Connectivity
278 } else if error.is_timeout() || has_connectivity_io_source(error) {
279 StreamErrorKind::ConnectionLost
280 } else {
281 StreamErrorKind::ServerError
282 }
283}
284
285fn is_proxy_tunnel_rejection(error: &reqwest::Error) -> bool {
286 if error.status() == Some(reqwest::StatusCode::PROXY_AUTHENTICATION_REQUIRED) {
287 return true;
288 }
289 let mut source = std::error::Error::source(error);
290 while let Some(cause) = source {
291 let message = cause.to_string();
292 if message.contains("tunnel error: unsuccessful")
293 || message.contains("proxy authorization required")
294 {
295 return true;
296 }
297 source = cause.source();
298 }
299 false
300}
301
302fn is_tls_rejection(error: &reqwest::Error) -> bool {
313 if has_connectivity_io_source(error) {
314 return false;
315 }
316 let mut source = std::error::Error::source(error);
317 while let Some(cause) = source {
318 if cause.downcast_ref::<native_tls::Error>().is_some() {
319 let message = cause.to_string().to_ascii_lowercase();
320 let transport_death = ["eof", "close", "reset", "broken pipe", "timed out"]
321 .iter()
322 .any(|marker| message.contains(marker));
323 return !transport_death;
324 }
325 source = cause.source();
326 }
327 false
328}
329
330fn has_connectivity_io_source(error: &reqwest::Error) -> bool {
331 let mut source = std::error::Error::source(error);
332 while let Some(cause) = source {
333 if let Some(io_error) = cause.downcast_ref::<std::io::Error>()
334 && matches!(
335 io_error.kind(),
336 std::io::ErrorKind::NotConnected
337 | std::io::ErrorKind::ConnectionRefused
338 | std::io::ErrorKind::ConnectionReset
339 | std::io::ErrorKind::ConnectionAborted
340 | std::io::ErrorKind::BrokenPipe
341 | std::io::ErrorKind::UnexpectedEof
342 | std::io::ErrorKind::TimedOut
343 | std::io::ErrorKind::NetworkDown
344 | std::io::ErrorKind::NetworkUnreachable
345 | std::io::ErrorKind::HostUnreachable
346 )
347 {
348 return true;
349 }
350 source = cause.source();
351 }
352 false
353}
354
355#[must_use]
356pub fn reqwest_error_delta(context: &str, error: &reqwest::Error) -> StreamDelta {
357 StreamDelta::Error {
358 message: format!("{context}: {error}"),
359 kind: classify_reqwest_error(error),
360 }
361}
362
363#[must_use]
364pub fn reqwest_body_error_delta(context: &str, error: &reqwest::Error) -> StreamDelta {
365 let kind = match classify_reqwest_error(error) {
366 StreamErrorKind::Connectivity => StreamErrorKind::ConnectionLost,
367 other => other,
368 };
369 StreamDelta::Error {
370 message: format!("{context}: {error}"),
371 kind,
372 }
373}
374
375pub type StreamBox<'a> = Pin<Box<dyn Stream<Item = anyhow::Result<StreamDelta>> + Send + 'a>>;
377
378fn add_usage(carried: Option<&Usage>, usage: &Usage) -> Usage {
380 let Some(carried) = carried else {
381 return usage.clone();
382 };
383 Usage {
384 served_speed: ServedSpeed::merge(carried.served_speed, usage.served_speed),
385 input_tokens: carried.input_tokens.saturating_add(usage.input_tokens),
386 output_tokens: carried.output_tokens.saturating_add(usage.output_tokens),
387 cached_input_tokens: carried
388 .cached_input_tokens
389 .saturating_add(usage.cached_input_tokens),
390 cache_creation_input_tokens: carried
391 .cache_creation_input_tokens
392 .saturating_add(usage.cache_creation_input_tokens),
393 }
394}
395
396#[derive(Default)]
410pub(crate) struct UsageCarry {
411 carried: Option<Usage>,
413 current: Option<Usage>,
415}
416
417impl UsageCarry {
418 pub(crate) const fn new() -> Self {
419 Self {
420 carried: None,
421 current: None,
422 }
423 }
424
425 pub(crate) fn running_total(&mut self, usage: Usage) -> Usage {
428 let total = add_usage(self.carried.as_ref(), &usage);
429 self.current = Some(usage);
430 total
431 }
432
433 pub(crate) fn abandon(&mut self) {
436 if let Some(current) = self.current.take() {
437 self.carried = Some(add_usage(self.carried.as_ref(), ¤t));
438 }
439 }
440}
441
442#[derive(Debug, Default)]
447pub struct StreamAccumulator {
448 text_blocks: Vec<String>,
450 thinking_blocks: Vec<String>,
452 thinking_signatures: HashMap<usize, String>,
454 redacted_thinking_blocks: Vec<(usize, String)>,
456 opaque_reasoning_blocks: Vec<(usize, String, serde_json::Value)>,
458 tool_uses: Vec<ToolUseAccumulator>,
460 usage: Option<Usage>,
462 stop_reason: Option<StopReason>,
464 served_route: Option<String>,
466}
467
468#[derive(Debug, Default)]
470pub struct ToolUseAccumulator {
471 pub id: String,
473 pub name: String,
475 pub input_json: String,
477 pub block_index: usize,
479 pub thought_signature: Option<String>,
481}
482
483impl StreamAccumulator {
484 #[must_use]
486 pub fn new() -> Self {
487 Self::default()
488 }
489
490 pub fn apply(&mut self, delta: &StreamDelta) {
492 match delta {
493 StreamDelta::TextDelta { delta, block_index } => {
494 if *block_index > MAX_BLOCK_INDEX {
495 log::warn!(
496 "dropping TextDelta with out-of-range block_index {block_index} (max {MAX_BLOCK_INDEX})"
497 );
498 return;
499 }
500 while self.text_blocks.len() <= *block_index {
501 self.text_blocks.push(String::new());
502 }
503 self.text_blocks[*block_index].push_str(delta);
504 }
505 StreamDelta::ThinkingDelta { delta, block_index } => {
506 if *block_index > MAX_BLOCK_INDEX {
507 log::warn!(
508 "dropping ThinkingDelta with out-of-range block_index {block_index} (max {MAX_BLOCK_INDEX})"
509 );
510 return;
511 }
512 while self.thinking_blocks.len() <= *block_index {
513 self.thinking_blocks.push(String::new());
514 }
515 self.thinking_blocks[*block_index].push_str(delta);
516 }
517 StreamDelta::ToolUseStart {
518 id,
519 name,
520 block_index,
521 thought_signature,
522 } => {
523 self.tool_uses.push(ToolUseAccumulator {
524 id: id.clone(),
525 name: name.clone(),
526 input_json: String::new(),
527 block_index: *block_index,
528 thought_signature: thought_signature.clone(),
529 });
530 }
531 StreamDelta::ToolInputDelta { id, delta, .. } => {
532 if let Some(tool) = self.tool_uses.iter_mut().find(|t| t.id == *id) {
533 tool.input_json.push_str(delta);
534 }
535 }
536 StreamDelta::SignatureDelta { delta, block_index } => {
537 self.thinking_signatures
538 .entry(*block_index)
539 .or_default()
540 .push_str(delta);
541 }
542 StreamDelta::RedactedThinking { data, block_index } => {
543 self.redacted_thinking_blocks
544 .push((*block_index, data.clone()));
545 }
546 StreamDelta::OpaqueReasoning {
547 provider,
548 data,
549 block_index,
550 } => {
551 self.opaque_reasoning_blocks
552 .push((*block_index, provider.clone(), data.clone()));
553 }
554 StreamDelta::Usage(u) => {
555 self.usage = Some(u.clone());
556 }
557 StreamDelta::Done {
558 stop_reason,
559 served_route,
560 } => {
561 self.stop_reason = *stop_reason;
562 self.served_route.clone_from(served_route);
563 }
564 StreamDelta::Error { .. } => {}
565 }
566 }
567
568 #[must_use]
570 pub const fn usage(&self) -> Option<&Usage> {
571 self.usage.as_ref()
572 }
573
574 #[must_use]
576 pub const fn stop_reason(&self) -> Option<&StopReason> {
577 self.stop_reason.as_ref()
578 }
579
580 #[must_use]
582 pub fn served_route(&self) -> Option<&str> {
583 self.served_route.as_deref()
584 }
585
586 #[must_use]
591 pub fn into_content_blocks(self) -> Vec<ContentBlock> {
592 let mut blocks: Vec<(usize, ContentBlock)> = Vec::new();
593
594 let mut signatures = self.thinking_signatures;
596 for (idx, thinking) in self.thinking_blocks.into_iter().enumerate() {
597 if !thinking.is_empty() {
598 let signature = signatures.remove(&idx).filter(|s| !s.is_empty());
599 blocks.push((
600 idx,
601 ContentBlock::Thinking {
602 thinking,
603 signature,
604 },
605 ));
606 }
607 }
608
609 for (idx, data) in self.redacted_thinking_blocks {
611 blocks.push((idx, ContentBlock::RedactedThinking { data }));
612 }
613
614 for (idx, provider, data) in self.opaque_reasoning_blocks {
616 blocks.push((idx, ContentBlock::OpaqueReasoning { provider, data }));
617 }
618
619 for (idx, text) in self.text_blocks.into_iter().enumerate() {
621 if !text.is_empty() {
622 blocks.push((idx, ContentBlock::Text { text }));
623 }
624 }
625
626 for tool in self.tool_uses {
628 let input: serde_json::Value =
629 serde_json::from_str(&tool.input_json).unwrap_or_else(|e| {
630 log::warn!(
631 "Failed to parse streamed tool input JSON for tool '{}' (id={}): {} — \
632 input_json ({} bytes): '{}'",
633 tool.name,
634 tool.id,
635 e,
636 tool.input_json.len(),
637 tool.input_json.chars().take(500).collect::<String>(),
638 );
639 serde_json::json!({})
640 });
641 blocks.push((
642 tool.block_index,
643 ContentBlock::ToolUse {
644 id: tool.id,
645 name: tool.name,
646 input,
647 thought_signature: tool.thought_signature,
648 },
649 ));
650 }
651
652 blocks.sort_by_key(|(idx, _)| *idx);
654
655 blocks.into_iter().map(|(_, block)| block).collect()
656 }
657
658 pub const fn take_usage(&mut self) -> Option<Usage> {
660 self.usage.take()
661 }
662
663 pub const fn take_stop_reason(&mut self) -> Option<StopReason> {
665 self.stop_reason.take()
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use agent_sdk_foundation::llm::SpeedTier;
673
674 #[test]
675 fn folding_usage_surfaces_a_downgrade_instead_of_picking_a_winner() {
676 let fast = Usage {
677 input_tokens: 10,
678 output_tokens: 5,
679 served_speed: Some(ServedSpeed::Uniform(SpeedTier::Fast)),
680 ..Usage::default()
681 };
682 let downgraded = Usage {
683 input_tokens: 3,
684 output_tokens: 2,
685 served_speed: Some(ServedSpeed::Uniform(SpeedTier::Standard)),
686 ..Usage::default()
687 };
688
689 let total = add_usage(Some(&fast), &downgraded);
690 assert_eq!(total.input_tokens, 13);
691 assert_eq!(total.output_tokens, 7);
692 assert_eq!(
693 total.served_speed,
694 Some(ServedSpeed::Mixed),
695 "a turn mixing an expedited call with a downgraded one must not \
696 report either tier as if it covered the whole turn",
697 );
698 }
699
700 #[test]
701 fn folding_usage_keeps_a_uniform_tier() {
702 let call = Usage {
703 input_tokens: 10,
704 output_tokens: 5,
705 served_speed: Some(ServedSpeed::Uniform(SpeedTier::Fast)),
706 ..Usage::default()
707 };
708 let total = add_usage(Some(&call), &call);
709 assert_eq!(
710 total.served_speed,
711 Some(ServedSpeed::Uniform(SpeedTier::Fast))
712 );
713 }
714
715 #[test]
716 fn test_accumulator_text_deltas() {
717 let mut acc = StreamAccumulator::new();
718
719 acc.apply(&StreamDelta::TextDelta {
720 delta: "Hello".to_string(),
721 block_index: 0,
722 });
723 acc.apply(&StreamDelta::TextDelta {
724 delta: " world".to_string(),
725 block_index: 0,
726 });
727
728 let blocks = acc.into_content_blocks();
729 assert_eq!(blocks.len(), 1);
730 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello world"));
731 }
732
733 #[test]
734 fn test_accumulator_multiple_text_blocks() {
735 let mut acc = StreamAccumulator::new();
736
737 acc.apply(&StreamDelta::TextDelta {
738 delta: "First".to_string(),
739 block_index: 0,
740 });
741 acc.apply(&StreamDelta::TextDelta {
742 delta: "Second".to_string(),
743 block_index: 1,
744 });
745
746 let blocks = acc.into_content_blocks();
747 assert_eq!(blocks.len(), 2);
748 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "First"));
749 assert!(matches!(&blocks[1], ContentBlock::Text { text } if text == "Second"));
750 }
751
752 #[test]
753 fn test_accumulator_thinking_signature() {
754 let mut acc = StreamAccumulator::new();
755
756 acc.apply(&StreamDelta::ThinkingDelta {
757 delta: "Reasoning".to_string(),
758 block_index: 0,
759 });
760 acc.apply(&StreamDelta::SignatureDelta {
761 delta: "sig_123".to_string(),
762 block_index: 0,
763 });
764
765 let blocks = acc.into_content_blocks();
766 assert_eq!(blocks.len(), 1);
767 assert!(matches!(
768 &blocks[0],
769 ContentBlock::Thinking { thinking, signature }
770 if thinking == "Reasoning" && signature.as_deref() == Some("sig_123")
771 ));
772 }
773
774 #[test]
775 fn accumulator_preserves_opaque_reasoning_payload_and_order() {
776 let mut acc = StreamAccumulator::new();
777 acc.apply(&StreamDelta::TextDelta {
778 delta: "visible".to_owned(),
779 block_index: 2,
780 });
781 acc.apply(&StreamDelta::OpaqueReasoning {
782 provider: "test-provider".to_owned(),
783 data: serde_json::json!({
784 "id": "reasoning_1",
785 "encrypted_content": "do-not-inspect"
786 }),
787 block_index: 1,
788 });
789
790 let blocks = acc.into_content_blocks();
791 assert_eq!(blocks.len(), 2);
792 assert!(matches!(
793 &blocks[0],
794 ContentBlock::OpaqueReasoning { provider, data }
795 if provider == "test-provider"
796 && data["id"] == "reasoning_1"
797 && data["encrypted_content"] == "do-not-inspect"
798 ));
799 assert!(matches!(
800 &blocks[1],
801 ContentBlock::Text { text } if text == "visible"
802 ));
803 }
804
805 #[test]
806 fn test_accumulator_tool_use() {
807 let mut acc = StreamAccumulator::new();
808
809 acc.apply(&StreamDelta::ToolUseStart {
810 id: "call_123".to_string(),
811 name: "read_file".to_string(),
812 block_index: 0,
813 thought_signature: None,
814 });
815 acc.apply(&StreamDelta::ToolInputDelta {
816 id: "call_123".to_string(),
817 delta: r#"{"path":"#.to_string(),
818 block_index: 0,
819 });
820 acc.apply(&StreamDelta::ToolInputDelta {
821 id: "call_123".to_string(),
822 delta: r#""test.txt"}"#.to_string(),
823 block_index: 0,
824 });
825
826 let blocks = acc.into_content_blocks();
827 assert_eq!(blocks.len(), 1);
828 match &blocks[0] {
829 ContentBlock::ToolUse {
830 id, name, input, ..
831 } => {
832 assert_eq!(id, "call_123");
833 assert_eq!(name, "read_file");
834 assert_eq!(input["path"], "test.txt");
835 }
836 _ => panic!("Expected ToolUse block"),
837 }
838 }
839
840 #[test]
841 fn test_accumulator_mixed_content() {
842 let mut acc = StreamAccumulator::new();
843
844 acc.apply(&StreamDelta::TextDelta {
845 delta: "Let me read that file.".to_string(),
846 block_index: 0,
847 });
848 acc.apply(&StreamDelta::ToolUseStart {
849 id: "call_456".to_string(),
850 name: "read_file".to_string(),
851 block_index: 1,
852 thought_signature: None,
853 });
854 acc.apply(&StreamDelta::ToolInputDelta {
855 id: "call_456".to_string(),
856 delta: r#"{"path":"file.txt"}"#.to_string(),
857 block_index: 1,
858 });
859 acc.apply(&StreamDelta::Usage(Usage {
860 served_speed: None,
861 input_tokens: 100,
862 output_tokens: 50,
863 cached_input_tokens: 0,
864 cache_creation_input_tokens: 0,
865 }));
866 acc.apply(&StreamDelta::Done {
867 stop_reason: Some(StopReason::ToolUse),
868 served_route: None,
869 });
870
871 assert!(acc.usage().is_some());
872 assert_eq!(acc.usage().map(|u| u.input_tokens), Some(100));
873 assert!(matches!(acc.stop_reason(), Some(StopReason::ToolUse)));
874
875 let blocks = acc.into_content_blocks();
876 assert_eq!(blocks.len(), 2);
877 assert!(matches!(&blocks[0], ContentBlock::Text { .. }));
878 assert!(matches!(&blocks[1], ContentBlock::ToolUse { .. }));
879 }
880
881 #[test]
882 fn accumulator_captures_the_done_markers_served_route() {
883 let mut acc = StreamAccumulator::new();
884 assert_eq!(acc.served_route(), None);
885 acc.apply(&StreamDelta::Done {
886 stop_reason: Some(StopReason::EndTurn),
887 served_route: Some("openrouter".to_owned()),
888 });
889 assert_eq!(acc.served_route(), Some("openrouter"));
890
891 let mut without = StreamAccumulator::new();
892 without.apply(&StreamDelta::Done {
893 stop_reason: Some(StopReason::EndTurn),
894 served_route: None,
895 });
896 assert_eq!(without.served_route(), None);
897 }
898
899 #[test]
900 fn test_accumulator_invalid_tool_json() {
901 let mut acc = StreamAccumulator::new();
902
903 acc.apply(&StreamDelta::ToolUseStart {
904 id: "call_789".to_string(),
905 name: "test_tool".to_string(),
906 block_index: 0,
907 thought_signature: None,
908 });
909 acc.apply(&StreamDelta::ToolInputDelta {
910 id: "call_789".to_string(),
911 delta: "invalid json {".to_string(),
912 block_index: 0,
913 });
914
915 let blocks = acc.into_content_blocks();
916 assert_eq!(blocks.len(), 1);
917 match &blocks[0] {
918 ContentBlock::ToolUse { input, .. } => {
919 assert!(input.is_object());
920 }
921 _ => panic!("Expected ToolUse block"),
922 }
923 }
924
925 #[test]
926 fn test_accumulator_empty_tool_input_falls_back_to_empty_object() {
927 let mut acc = StreamAccumulator::new();
932
933 acc.apply(&StreamDelta::ToolUseStart {
934 id: "call_empty".to_string(),
935 name: "read".to_string(),
936 block_index: 0,
937 thought_signature: None,
938 });
939 let blocks = acc.into_content_blocks();
942 assert_eq!(blocks.len(), 1);
943 match &blocks[0] {
944 ContentBlock::ToolUse { input, name, .. } => {
945 assert_eq!(name, "read");
946 assert_eq!(input, &serde_json::json!({}));
947 }
948 _ => panic!("Expected ToolUse block"),
949 }
950 }
951
952 #[test]
953 fn test_accumulator_mismatched_delta_id_drops_input() {
954 let mut acc = StreamAccumulator::new();
957
958 acc.apply(&StreamDelta::ToolUseStart {
959 id: "call_A".to_string(),
960 name: "bash".to_string(),
961 block_index: 0,
962 thought_signature: None,
963 });
964 acc.apply(&StreamDelta::ToolInputDelta {
966 id: "call_B".to_string(),
967 delta: r#"{"command":"ls"}"#.to_string(),
968 block_index: 0,
969 });
970
971 let blocks = acc.into_content_blocks();
972 assert_eq!(blocks.len(), 1);
973 match &blocks[0] {
974 ContentBlock::ToolUse { input, .. } => {
975 assert_eq!(input, &serde_json::json!({}));
977 }
978 _ => panic!("Expected ToolUse block"),
979 }
980 }
981
982 #[test]
983 fn test_accumulator_empty() {
984 let acc = StreamAccumulator::new();
985 let blocks = acc.into_content_blocks();
986 assert!(blocks.is_empty());
987 }
988
989 #[test]
990 fn test_accumulator_skips_empty_text() {
991 let mut acc = StreamAccumulator::new();
992
993 acc.apply(&StreamDelta::TextDelta {
994 delta: String::new(),
995 block_index: 0,
996 });
997 acc.apply(&StreamDelta::TextDelta {
998 delta: "Hello".to_string(),
999 block_index: 1,
1000 });
1001
1002 let blocks = acc.into_content_blocks();
1003 assert_eq!(blocks.len(), 1);
1004 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello"));
1005 }
1006
1007 #[test]
1008 fn test_accumulator_ignores_out_of_range_block_index() {
1009 let mut acc = StreamAccumulator::new();
1013
1014 acc.apply(&StreamDelta::TextDelta {
1015 delta: "ok".to_string(),
1016 block_index: 0,
1017 });
1018 acc.apply(&StreamDelta::TextDelta {
1019 delta: "boom".to_string(),
1020 block_index: usize::MAX,
1021 });
1022 acc.apply(&StreamDelta::ThinkingDelta {
1023 delta: "boom".to_string(),
1024 block_index: usize::MAX,
1025 });
1026
1027 let blocks = acc.into_content_blocks();
1028 assert_eq!(blocks.len(), 1);
1029 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "ok"));
1030 }
1031
1032 #[tokio::test]
1033 async fn classifies_typed_connect_failure_as_connectivity() -> anyhow::Result<()> {
1034 let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
1035 let address = listener.local_addr()?;
1036 drop(listener);
1037
1038 let result = reqwest::Client::new()
1039 .get(format!("http://{address}"))
1040 .send()
1041 .await;
1042 let Err(error) = result else {
1043 anyhow::bail!("closed local port unexpectedly accepted a connection")
1044 };
1045 assert_eq!(
1046 classify_reqwest_error(&error),
1047 StreamErrorKind::Connectivity
1048 );
1049 Ok(())
1050 }
1051
1052 #[tokio::test]
1053 async fn proxy_tunnel_rejection_is_not_connectivity() -> anyhow::Result<()> {
1054 use tokio::io::AsyncWriteExt as _;
1055
1056 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1057 let address = listener.local_addr()?;
1058 let server = tokio::spawn(async move {
1059 let (mut socket, _) = listener.accept().await?;
1060 socket
1061 .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
1062 .await?;
1063 anyhow::Ok(())
1064 });
1065 let client = reqwest::Client::builder()
1066 .proxy(reqwest::Proxy::all(format!("http://{address}"))?)
1067 .build()?;
1068 let Err(error) = client.get("https://example.invalid").send().await else {
1069 anyhow::bail!("rejected proxy tunnel unexpectedly succeeded")
1070 };
1071 assert_eq!(classify_reqwest_error(&error), StreamErrorKind::ServerError);
1072 server.await??;
1073 Ok(())
1074 }
1075
1076 #[tokio::test]
1077 async fn tls_handshake_transport_drop_is_connectivity() -> anyhow::Result<()> {
1078 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1079 let address = listener.local_addr()?;
1080 let server = tokio::spawn(async move {
1081 let (socket, _) = listener.accept().await?;
1082 drop(socket);
1083 anyhow::Ok(())
1084 });
1085 let client = reqwest::Client::builder().no_proxy().build()?;
1086 let Err(error) = client.get(format!("https://{address}")).send().await else {
1087 anyhow::bail!("dropped TLS handshake unexpectedly succeeded")
1088 };
1089 assert_eq!(
1090 classify_reqwest_error(&error),
1091 StreamErrorKind::Connectivity
1092 );
1093 server.await??;
1094 Ok(())
1095 }
1096
1097 #[tokio::test]
1104 async fn tls_certificate_rejection_is_bounded_server_error() -> anyhow::Result<()> {
1105 const SELF_SIGNED_CERT_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----
1106MIIDJzCCAg+gAwIBAgIUPiG3JI6c72crNdzYks8mo1pmHMEwDQYJKoZIhvcNAQEL
1107BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDcxNDE4NDYzMloYDzIxMjYw
1108NjIwMTg0NjMyWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB
1109AQUAA4IBDwAwggEKAoIBAQCtBOh4EAP48fjE59F+L9qNEp/yUlOJXYJbm6m4nzTg
111000RNc+dqsfrObIWJDuAaiKimunkGrSy77ELNAHlJmtOSkq8hu1C5/k6LW0GvPHuC
1111faPFEevCmxbERVZnt1f9IQ2e77oZz752cNzDlUIKyy5v3LpGaL8vT1bLAFuHT9z/
11123mlqEwyK7mQlS3LZvwJQ6NfL2lgr5uVDFdsvfAY4mhbV8uRjKj+IZnOV1WYqQ62o
1113xbjC/NKXbvqKBigOhbo+idk1sjKbkjm2uvyjmUszRpfh7YX2wkk3UqZgN1+zsRDK
1114MBMyuZkkr7Vb/8ed07SN8Ma64fwCrrQba4l/R8TJmQpXAgMBAAGjbzBtMB0GA1Ud
1115DgQWBBT8LxETkCZh4h6qjMlLJMooNHTgkTAfBgNVHSMEGDAWgBT8LxETkCZh4h6q
1116jMlLJMooNHTgkTAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCCWxvY2FsaG9z
1117dIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEACjZ8oqjFooFxjS3BnbhrNrF29/Jv
1118PbX32Tg3+3qUkS5+XnO64mLm+pQzUGs16+TyqdEkck//51KkyvzrnnGRYGc5eHEQ
1119zorkR1zlE+c8sjKcenvVkkLEKWaWNtEvpb+U0Ps6rP2Y1Jo4/AxTuxXrYxQ+XSTy
1120V4HyKriK6utlmhGpKUZhTZPTiTC/GaAwimCFgfw4wDuWGow92z3AnR9Q3KFpgrTP
1121B5z+i0oiNv6GpalGq3oe1ucKt+fduYWsC2Vea/PObZowciqbsA0mv3oHlyT9jPFT
1122hY9YjeYgUtEnf0BlrUrgbpd9DnVd5TNU0nDbPC7bv/yu8nF1nKUFWa2nsw==
1123-----END CERTIFICATE-----";
1124 const SELF_SIGNED_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
1125MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCtBOh4EAP48fjE
112659F+L9qNEp/yUlOJXYJbm6m4nzTg00RNc+dqsfrObIWJDuAaiKimunkGrSy77ELN
1127AHlJmtOSkq8hu1C5/k6LW0GvPHuCfaPFEevCmxbERVZnt1f9IQ2e77oZz752cNzD
1128lUIKyy5v3LpGaL8vT1bLAFuHT9z/3mlqEwyK7mQlS3LZvwJQ6NfL2lgr5uVDFdsv
1129fAY4mhbV8uRjKj+IZnOV1WYqQ62oxbjC/NKXbvqKBigOhbo+idk1sjKbkjm2uvyj
1130mUszRpfh7YX2wkk3UqZgN1+zsRDKMBMyuZkkr7Vb/8ed07SN8Ma64fwCrrQba4l/
1131R8TJmQpXAgMBAAECggEAAk8G9RctnmRIMARx4K+tyGUfukGL+NDFHQjSNnL1Zyya
1132hDgQNfXDBX8gNwh6SBBbw8HIPKUR7D4GVCr181v8B8AqUxZnSNwSWzyv/zEc6sxX
1133Y5lOHo4oOx07vm2NYITQ5DaJsq95eKYf5AI5W+CDMZ3t5GOgbXavD01la0RPDCD7
1134d+H9WI7RiKlCaiD174FQfSSwcAHpesrUcopPxMfZzpjxYClGdmMp7/RTmSVg8jex
1135eGceJvZujmjTnYczIce0Ibtozbq91qbwro32U2wbkvNpbU8GTG+st6nRlNRGmHeF
1136AJnOw+CiY9x7KaG4ZhsEY4VRk8YRJo/cLrPcx87JwQKBgQDv76cDYUgFzFXKWH+1
1137hc+oTLuUcn+X6E3ljvMKk9P4nQDgTRDxx5bBm6lHVv3IZoszi60Hvzblqr2HIO5S
1138Gl9KVBkHCLaYc8ny4rYQKVjKLA2/TnDE8Y8FhFnZTpBeEWhb2axQE5zb8WA6Ku6L
1139gEl04OSHjMlqAWt2Va5PZnFQQQKBgQC4mlfFFkfu92RKkYfhXkXUd5psSL4/1C1S
1140wYnqyL7rmAMmKO+y2MdnS1SAwSFGtmexibEcpDu8OASPQoovy4O5De+p/wL7v7aJ
1141+X2J9zaM2ggQN3tYz/HWCdCSpZJy+ufHtLwW9ESu0wW2G0ESRUxtEKvmBB/b/nrO
1142pK7VWxW0lwKBgQCWPG1LRIKgfs3JIZj1xI++Ri2+SeNy7ta3wsaT/PRhW43M5PST
1143L/JJ0HoyXVoTPYI0CGWT0DtDm6GJFymi5zh7hiUVrnMHCpmNKD/v5rPeA6+n9inO
1144Z6KyRaks1HC5NhUuTiIDEgTKA13JjlBHsVBNivQNnC4R3km3kvbOaMrTAQKBgAoR
11456U3H/F6NwjvLGoVxtg90Asl7Yl1q/pnwEszq7Hc/kJRpUUIJTz9UPaTUZDNOSfPG
1146VhIA531J9P23nIAk8ueKWhOE5K3E9HksUevPv3sJfb0cua7LkR6i5GzLeWSqSTB8
1147rHH4GzMKMdqQPAl6HEQqz6W5fd9rT1msZBkhYdq7AoGBAJFTgwSK84D707FxGASw
1148SyuZBVIVd3iF341tsgX48Q1SVq70Uu6AQ0qPJHyxk6pe8aCiermlvVX26nqSQqr7
1149RrpkOaRQNnAfmLmSHvHWZmErDzlsl7pKdIByHK5nx1ccE8xspPEfHsg00E/SWWD+
1150CQR0IwmxMNda1bOi/AL4rcN3
1151-----END PRIVATE KEY-----";
1152
1153 use anyhow::Context as _;
1154
1155 let identity = native_tls::Identity::from_pkcs8(SELF_SIGNED_CERT_PEM, SELF_SIGNED_KEY_PEM)?;
1156 let acceptor = native_tls::TlsAcceptor::new(identity)?;
1157 let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
1158 let address = listener.local_addr()?;
1159 let server = std::thread::spawn(move || {
1160 if let Ok((socket, _)) = listener.accept() {
1161 drop(acceptor.accept(socket));
1164 }
1165 });
1166
1167 let client = reqwest::Client::builder().no_proxy().build()?;
1168 let result = client
1169 .get(format!("https://localhost:{}", address.port()))
1170 .send()
1171 .await;
1172 let Err(error) = result else {
1173 anyhow::bail!("self-signed certificate unexpectedly accepted")
1174 };
1175 assert_eq!(classify_reqwest_error(&error), StreamErrorKind::ServerError);
1176 server
1177 .join()
1178 .ok()
1179 .context("TLS test server thread panicked")?;
1180 Ok(())
1181 }
1182
1183 #[tokio::test]
1184 async fn classifies_premature_http_eof_as_connection_lost() -> anyhow::Result<()> {
1185 use tokio::io::AsyncWriteExt as _;
1186
1187 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1188 let address = listener.local_addr()?;
1189 let server = tokio::spawn(async move {
1190 let (mut socket, _) = listener.accept().await?;
1191 socket
1192 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nx")
1193 .await?;
1194 anyhow::Ok(())
1195 });
1196
1197 let response = reqwest::Client::new()
1198 .get(format!("http://{address}"))
1199 .send()
1200 .await?;
1201 let Err(error) = response.bytes().await else {
1202 anyhow::bail!("truncated HTTP body unexpectedly completed")
1203 };
1204 let StreamDelta::Error { kind, .. } = reqwest_body_error_delta("stream error", &error)
1205 else {
1206 anyhow::bail!("body error helper did not return an error delta")
1207 };
1208 assert_eq!(kind, StreamErrorKind::ConnectionLost);
1209 server.await??;
1210 Ok(())
1211 }
1212
1213 #[cfg(any(feature = "openai", feature = "openai-codex"))]
1214 #[test]
1215 fn test_sse_line_buffer_splits_multiple_lines() {
1216 let mut buf = SseLineBuffer::new();
1217 buf.extend(b"data: one\ndata: two\n");
1218 assert_eq!(buf.next_line().as_deref(), Some("data: one"));
1219 assert_eq!(buf.next_line().as_deref(), Some("data: two"));
1220 assert_eq!(buf.next_line(), None);
1221 }
1222
1223 #[cfg(any(feature = "openai", feature = "openai-codex"))]
1224 #[test]
1225 fn test_sse_line_buffer_buffers_partial_line_until_newline() {
1226 let mut buf = SseLineBuffer::new();
1227 buf.extend(b"data: par");
1228 assert_eq!(buf.next_line(), None);
1229 buf.extend(b"tial\n");
1230 assert_eq!(buf.next_line().as_deref(), Some("data: partial"));
1231 }
1232
1233 #[cfg(any(feature = "openai", feature = "openai-codex"))]
1234 #[test]
1235 fn test_sse_line_buffer_handles_utf8_split_across_chunks() {
1236 let mut buf = SseLineBuffer::new();
1241 let line = "data: café\n";
1242 let bytes = line.as_bytes();
1243 let split = bytes.len() - 2; buf.extend(&bytes[..split]);
1245 assert_eq!(buf.next_line(), None);
1246 buf.extend(&bytes[split..]);
1247 assert_eq!(buf.next_line().as_deref(), Some("data: café"));
1248 }
1249}