1use agent_sdk_foundation::llm::{ContentBlock, 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 input_tokens: carried.input_tokens.saturating_add(usage.input_tokens),
385 output_tokens: carried.output_tokens.saturating_add(usage.output_tokens),
386 cached_input_tokens: carried
387 .cached_input_tokens
388 .saturating_add(usage.cached_input_tokens),
389 cache_creation_input_tokens: carried
390 .cache_creation_input_tokens
391 .saturating_add(usage.cache_creation_input_tokens),
392 }
393}
394
395#[derive(Default)]
409pub(crate) struct UsageCarry {
410 carried: Option<Usage>,
412 current: Option<Usage>,
414}
415
416impl UsageCarry {
417 pub(crate) const fn new() -> Self {
418 Self {
419 carried: None,
420 current: None,
421 }
422 }
423
424 pub(crate) fn running_total(&mut self, usage: Usage) -> Usage {
427 let total = add_usage(self.carried.as_ref(), &usage);
428 self.current = Some(usage);
429 total
430 }
431
432 pub(crate) fn abandon(&mut self) {
435 if let Some(current) = self.current.take() {
436 self.carried = Some(add_usage(self.carried.as_ref(), ¤t));
437 }
438 }
439}
440
441#[derive(Debug, Default)]
446pub struct StreamAccumulator {
447 text_blocks: Vec<String>,
449 thinking_blocks: Vec<String>,
451 thinking_signatures: HashMap<usize, String>,
453 redacted_thinking_blocks: Vec<(usize, String)>,
455 opaque_reasoning_blocks: Vec<(usize, String, serde_json::Value)>,
457 tool_uses: Vec<ToolUseAccumulator>,
459 usage: Option<Usage>,
461 stop_reason: Option<StopReason>,
463 served_route: Option<String>,
465}
466
467#[derive(Debug, Default)]
469pub struct ToolUseAccumulator {
470 pub id: String,
472 pub name: String,
474 pub input_json: String,
476 pub block_index: usize,
478 pub thought_signature: Option<String>,
480}
481
482impl StreamAccumulator {
483 #[must_use]
485 pub fn new() -> Self {
486 Self::default()
487 }
488
489 pub fn apply(&mut self, delta: &StreamDelta) {
491 match delta {
492 StreamDelta::TextDelta { delta, block_index } => {
493 if *block_index > MAX_BLOCK_INDEX {
494 log::warn!(
495 "dropping TextDelta with out-of-range block_index {block_index} (max {MAX_BLOCK_INDEX})"
496 );
497 return;
498 }
499 while self.text_blocks.len() <= *block_index {
500 self.text_blocks.push(String::new());
501 }
502 self.text_blocks[*block_index].push_str(delta);
503 }
504 StreamDelta::ThinkingDelta { delta, block_index } => {
505 if *block_index > MAX_BLOCK_INDEX {
506 log::warn!(
507 "dropping ThinkingDelta with out-of-range block_index {block_index} (max {MAX_BLOCK_INDEX})"
508 );
509 return;
510 }
511 while self.thinking_blocks.len() <= *block_index {
512 self.thinking_blocks.push(String::new());
513 }
514 self.thinking_blocks[*block_index].push_str(delta);
515 }
516 StreamDelta::ToolUseStart {
517 id,
518 name,
519 block_index,
520 thought_signature,
521 } => {
522 self.tool_uses.push(ToolUseAccumulator {
523 id: id.clone(),
524 name: name.clone(),
525 input_json: String::new(),
526 block_index: *block_index,
527 thought_signature: thought_signature.clone(),
528 });
529 }
530 StreamDelta::ToolInputDelta { id, delta, .. } => {
531 if let Some(tool) = self.tool_uses.iter_mut().find(|t| t.id == *id) {
532 tool.input_json.push_str(delta);
533 }
534 }
535 StreamDelta::SignatureDelta { delta, block_index } => {
536 self.thinking_signatures
537 .entry(*block_index)
538 .or_default()
539 .push_str(delta);
540 }
541 StreamDelta::RedactedThinking { data, block_index } => {
542 self.redacted_thinking_blocks
543 .push((*block_index, data.clone()));
544 }
545 StreamDelta::OpaqueReasoning {
546 provider,
547 data,
548 block_index,
549 } => {
550 self.opaque_reasoning_blocks
551 .push((*block_index, provider.clone(), data.clone()));
552 }
553 StreamDelta::Usage(u) => {
554 self.usage = Some(u.clone());
555 }
556 StreamDelta::Done {
557 stop_reason,
558 served_route,
559 } => {
560 self.stop_reason = *stop_reason;
561 self.served_route.clone_from(served_route);
562 }
563 StreamDelta::Error { .. } => {}
564 }
565 }
566
567 #[must_use]
569 pub const fn usage(&self) -> Option<&Usage> {
570 self.usage.as_ref()
571 }
572
573 #[must_use]
575 pub const fn stop_reason(&self) -> Option<&StopReason> {
576 self.stop_reason.as_ref()
577 }
578
579 #[must_use]
581 pub fn served_route(&self) -> Option<&str> {
582 self.served_route.as_deref()
583 }
584
585 #[must_use]
590 pub fn into_content_blocks(self) -> Vec<ContentBlock> {
591 let mut blocks: Vec<(usize, ContentBlock)> = Vec::new();
592
593 let mut signatures = self.thinking_signatures;
595 for (idx, thinking) in self.thinking_blocks.into_iter().enumerate() {
596 if !thinking.is_empty() {
597 let signature = signatures.remove(&idx).filter(|s| !s.is_empty());
598 blocks.push((
599 idx,
600 ContentBlock::Thinking {
601 thinking,
602 signature,
603 },
604 ));
605 }
606 }
607
608 for (idx, data) in self.redacted_thinking_blocks {
610 blocks.push((idx, ContentBlock::RedactedThinking { data }));
611 }
612
613 for (idx, provider, data) in self.opaque_reasoning_blocks {
615 blocks.push((idx, ContentBlock::OpaqueReasoning { provider, data }));
616 }
617
618 for (idx, text) in self.text_blocks.into_iter().enumerate() {
620 if !text.is_empty() {
621 blocks.push((idx, ContentBlock::Text { text }));
622 }
623 }
624
625 for tool in self.tool_uses {
627 let input: serde_json::Value =
628 serde_json::from_str(&tool.input_json).unwrap_or_else(|e| {
629 log::warn!(
630 "Failed to parse streamed tool input JSON for tool '{}' (id={}): {} — \
631 input_json ({} bytes): '{}'",
632 tool.name,
633 tool.id,
634 e,
635 tool.input_json.len(),
636 tool.input_json.chars().take(500).collect::<String>(),
637 );
638 serde_json::json!({})
639 });
640 blocks.push((
641 tool.block_index,
642 ContentBlock::ToolUse {
643 id: tool.id,
644 name: tool.name,
645 input,
646 thought_signature: tool.thought_signature,
647 },
648 ));
649 }
650
651 blocks.sort_by_key(|(idx, _)| *idx);
653
654 blocks.into_iter().map(|(_, block)| block).collect()
655 }
656
657 pub const fn take_usage(&mut self) -> Option<Usage> {
659 self.usage.take()
660 }
661
662 pub const fn take_stop_reason(&mut self) -> Option<StopReason> {
664 self.stop_reason.take()
665 }
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671
672 #[test]
673 fn test_accumulator_text_deltas() {
674 let mut acc = StreamAccumulator::new();
675
676 acc.apply(&StreamDelta::TextDelta {
677 delta: "Hello".to_string(),
678 block_index: 0,
679 });
680 acc.apply(&StreamDelta::TextDelta {
681 delta: " world".to_string(),
682 block_index: 0,
683 });
684
685 let blocks = acc.into_content_blocks();
686 assert_eq!(blocks.len(), 1);
687 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello world"));
688 }
689
690 #[test]
691 fn test_accumulator_multiple_text_blocks() {
692 let mut acc = StreamAccumulator::new();
693
694 acc.apply(&StreamDelta::TextDelta {
695 delta: "First".to_string(),
696 block_index: 0,
697 });
698 acc.apply(&StreamDelta::TextDelta {
699 delta: "Second".to_string(),
700 block_index: 1,
701 });
702
703 let blocks = acc.into_content_blocks();
704 assert_eq!(blocks.len(), 2);
705 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "First"));
706 assert!(matches!(&blocks[1], ContentBlock::Text { text } if text == "Second"));
707 }
708
709 #[test]
710 fn test_accumulator_thinking_signature() {
711 let mut acc = StreamAccumulator::new();
712
713 acc.apply(&StreamDelta::ThinkingDelta {
714 delta: "Reasoning".to_string(),
715 block_index: 0,
716 });
717 acc.apply(&StreamDelta::SignatureDelta {
718 delta: "sig_123".to_string(),
719 block_index: 0,
720 });
721
722 let blocks = acc.into_content_blocks();
723 assert_eq!(blocks.len(), 1);
724 assert!(matches!(
725 &blocks[0],
726 ContentBlock::Thinking { thinking, signature }
727 if thinking == "Reasoning" && signature.as_deref() == Some("sig_123")
728 ));
729 }
730
731 #[test]
732 fn accumulator_preserves_opaque_reasoning_payload_and_order() {
733 let mut acc = StreamAccumulator::new();
734 acc.apply(&StreamDelta::TextDelta {
735 delta: "visible".to_owned(),
736 block_index: 2,
737 });
738 acc.apply(&StreamDelta::OpaqueReasoning {
739 provider: "test-provider".to_owned(),
740 data: serde_json::json!({
741 "id": "reasoning_1",
742 "encrypted_content": "do-not-inspect"
743 }),
744 block_index: 1,
745 });
746
747 let blocks = acc.into_content_blocks();
748 assert_eq!(blocks.len(), 2);
749 assert!(matches!(
750 &blocks[0],
751 ContentBlock::OpaqueReasoning { provider, data }
752 if provider == "test-provider"
753 && data["id"] == "reasoning_1"
754 && data["encrypted_content"] == "do-not-inspect"
755 ));
756 assert!(matches!(
757 &blocks[1],
758 ContentBlock::Text { text } if text == "visible"
759 ));
760 }
761
762 #[test]
763 fn test_accumulator_tool_use() {
764 let mut acc = StreamAccumulator::new();
765
766 acc.apply(&StreamDelta::ToolUseStart {
767 id: "call_123".to_string(),
768 name: "read_file".to_string(),
769 block_index: 0,
770 thought_signature: None,
771 });
772 acc.apply(&StreamDelta::ToolInputDelta {
773 id: "call_123".to_string(),
774 delta: r#"{"path":"#.to_string(),
775 block_index: 0,
776 });
777 acc.apply(&StreamDelta::ToolInputDelta {
778 id: "call_123".to_string(),
779 delta: r#""test.txt"}"#.to_string(),
780 block_index: 0,
781 });
782
783 let blocks = acc.into_content_blocks();
784 assert_eq!(blocks.len(), 1);
785 match &blocks[0] {
786 ContentBlock::ToolUse {
787 id, name, input, ..
788 } => {
789 assert_eq!(id, "call_123");
790 assert_eq!(name, "read_file");
791 assert_eq!(input["path"], "test.txt");
792 }
793 _ => panic!("Expected ToolUse block"),
794 }
795 }
796
797 #[test]
798 fn test_accumulator_mixed_content() {
799 let mut acc = StreamAccumulator::new();
800
801 acc.apply(&StreamDelta::TextDelta {
802 delta: "Let me read that file.".to_string(),
803 block_index: 0,
804 });
805 acc.apply(&StreamDelta::ToolUseStart {
806 id: "call_456".to_string(),
807 name: "read_file".to_string(),
808 block_index: 1,
809 thought_signature: None,
810 });
811 acc.apply(&StreamDelta::ToolInputDelta {
812 id: "call_456".to_string(),
813 delta: r#"{"path":"file.txt"}"#.to_string(),
814 block_index: 1,
815 });
816 acc.apply(&StreamDelta::Usage(Usage {
817 input_tokens: 100,
818 output_tokens: 50,
819 cached_input_tokens: 0,
820 cache_creation_input_tokens: 0,
821 }));
822 acc.apply(&StreamDelta::Done {
823 stop_reason: Some(StopReason::ToolUse),
824 served_route: None,
825 });
826
827 assert!(acc.usage().is_some());
828 assert_eq!(acc.usage().map(|u| u.input_tokens), Some(100));
829 assert!(matches!(acc.stop_reason(), Some(StopReason::ToolUse)));
830
831 let blocks = acc.into_content_blocks();
832 assert_eq!(blocks.len(), 2);
833 assert!(matches!(&blocks[0], ContentBlock::Text { .. }));
834 assert!(matches!(&blocks[1], ContentBlock::ToolUse { .. }));
835 }
836
837 #[test]
838 fn accumulator_captures_the_done_markers_served_route() {
839 let mut acc = StreamAccumulator::new();
840 assert_eq!(acc.served_route(), None);
841 acc.apply(&StreamDelta::Done {
842 stop_reason: Some(StopReason::EndTurn),
843 served_route: Some("openrouter".to_owned()),
844 });
845 assert_eq!(acc.served_route(), Some("openrouter"));
846
847 let mut without = StreamAccumulator::new();
848 without.apply(&StreamDelta::Done {
849 stop_reason: Some(StopReason::EndTurn),
850 served_route: None,
851 });
852 assert_eq!(without.served_route(), None);
853 }
854
855 #[test]
856 fn test_accumulator_invalid_tool_json() {
857 let mut acc = StreamAccumulator::new();
858
859 acc.apply(&StreamDelta::ToolUseStart {
860 id: "call_789".to_string(),
861 name: "test_tool".to_string(),
862 block_index: 0,
863 thought_signature: None,
864 });
865 acc.apply(&StreamDelta::ToolInputDelta {
866 id: "call_789".to_string(),
867 delta: "invalid json {".to_string(),
868 block_index: 0,
869 });
870
871 let blocks = acc.into_content_blocks();
872 assert_eq!(blocks.len(), 1);
873 match &blocks[0] {
874 ContentBlock::ToolUse { input, .. } => {
875 assert!(input.is_object());
876 }
877 _ => panic!("Expected ToolUse block"),
878 }
879 }
880
881 #[test]
882 fn test_accumulator_empty_tool_input_falls_back_to_empty_object() {
883 let mut acc = StreamAccumulator::new();
888
889 acc.apply(&StreamDelta::ToolUseStart {
890 id: "call_empty".to_string(),
891 name: "read".to_string(),
892 block_index: 0,
893 thought_signature: None,
894 });
895 let blocks = acc.into_content_blocks();
898 assert_eq!(blocks.len(), 1);
899 match &blocks[0] {
900 ContentBlock::ToolUse { input, name, .. } => {
901 assert_eq!(name, "read");
902 assert_eq!(input, &serde_json::json!({}));
903 }
904 _ => panic!("Expected ToolUse block"),
905 }
906 }
907
908 #[test]
909 fn test_accumulator_mismatched_delta_id_drops_input() {
910 let mut acc = StreamAccumulator::new();
913
914 acc.apply(&StreamDelta::ToolUseStart {
915 id: "call_A".to_string(),
916 name: "bash".to_string(),
917 block_index: 0,
918 thought_signature: None,
919 });
920 acc.apply(&StreamDelta::ToolInputDelta {
922 id: "call_B".to_string(),
923 delta: r#"{"command":"ls"}"#.to_string(),
924 block_index: 0,
925 });
926
927 let blocks = acc.into_content_blocks();
928 assert_eq!(blocks.len(), 1);
929 match &blocks[0] {
930 ContentBlock::ToolUse { input, .. } => {
931 assert_eq!(input, &serde_json::json!({}));
933 }
934 _ => panic!("Expected ToolUse block"),
935 }
936 }
937
938 #[test]
939 fn test_accumulator_empty() {
940 let acc = StreamAccumulator::new();
941 let blocks = acc.into_content_blocks();
942 assert!(blocks.is_empty());
943 }
944
945 #[test]
946 fn test_accumulator_skips_empty_text() {
947 let mut acc = StreamAccumulator::new();
948
949 acc.apply(&StreamDelta::TextDelta {
950 delta: String::new(),
951 block_index: 0,
952 });
953 acc.apply(&StreamDelta::TextDelta {
954 delta: "Hello".to_string(),
955 block_index: 1,
956 });
957
958 let blocks = acc.into_content_blocks();
959 assert_eq!(blocks.len(), 1);
960 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello"));
961 }
962
963 #[test]
964 fn test_accumulator_ignores_out_of_range_block_index() {
965 let mut acc = StreamAccumulator::new();
969
970 acc.apply(&StreamDelta::TextDelta {
971 delta: "ok".to_string(),
972 block_index: 0,
973 });
974 acc.apply(&StreamDelta::TextDelta {
975 delta: "boom".to_string(),
976 block_index: usize::MAX,
977 });
978 acc.apply(&StreamDelta::ThinkingDelta {
979 delta: "boom".to_string(),
980 block_index: usize::MAX,
981 });
982
983 let blocks = acc.into_content_blocks();
984 assert_eq!(blocks.len(), 1);
985 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "ok"));
986 }
987
988 #[tokio::test]
989 async fn classifies_typed_connect_failure_as_connectivity() -> anyhow::Result<()> {
990 let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
991 let address = listener.local_addr()?;
992 drop(listener);
993
994 let result = reqwest::Client::new()
995 .get(format!("http://{address}"))
996 .send()
997 .await;
998 let Err(error) = result else {
999 anyhow::bail!("closed local port unexpectedly accepted a connection")
1000 };
1001 assert_eq!(
1002 classify_reqwest_error(&error),
1003 StreamErrorKind::Connectivity
1004 );
1005 Ok(())
1006 }
1007
1008 #[tokio::test]
1009 async fn proxy_tunnel_rejection_is_not_connectivity() -> anyhow::Result<()> {
1010 use tokio::io::AsyncWriteExt as _;
1011
1012 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1013 let address = listener.local_addr()?;
1014 let server = tokio::spawn(async move {
1015 let (mut socket, _) = listener.accept().await?;
1016 socket
1017 .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
1018 .await?;
1019 anyhow::Ok(())
1020 });
1021 let client = reqwest::Client::builder()
1022 .proxy(reqwest::Proxy::all(format!("http://{address}"))?)
1023 .build()?;
1024 let Err(error) = client.get("https://example.invalid").send().await else {
1025 anyhow::bail!("rejected proxy tunnel unexpectedly succeeded")
1026 };
1027 assert_eq!(classify_reqwest_error(&error), StreamErrorKind::ServerError);
1028 server.await??;
1029 Ok(())
1030 }
1031
1032 #[tokio::test]
1033 async fn tls_handshake_transport_drop_is_connectivity() -> anyhow::Result<()> {
1034 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1035 let address = listener.local_addr()?;
1036 let server = tokio::spawn(async move {
1037 let (socket, _) = listener.accept().await?;
1038 drop(socket);
1039 anyhow::Ok(())
1040 });
1041 let client = reqwest::Client::builder().no_proxy().build()?;
1042 let Err(error) = client.get(format!("https://{address}")).send().await else {
1043 anyhow::bail!("dropped TLS handshake unexpectedly succeeded")
1044 };
1045 assert_eq!(
1046 classify_reqwest_error(&error),
1047 StreamErrorKind::Connectivity
1048 );
1049 server.await??;
1050 Ok(())
1051 }
1052
1053 #[tokio::test]
1060 async fn tls_certificate_rejection_is_bounded_server_error() -> anyhow::Result<()> {
1061 const SELF_SIGNED_CERT_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----
1062MIIDJzCCAg+gAwIBAgIUPiG3JI6c72crNdzYks8mo1pmHMEwDQYJKoZIhvcNAQEL
1063BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDcxNDE4NDYzMloYDzIxMjYw
1064NjIwMTg0NjMyWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB
1065AQUAA4IBDwAwggEKAoIBAQCtBOh4EAP48fjE59F+L9qNEp/yUlOJXYJbm6m4nzTg
106600RNc+dqsfrObIWJDuAaiKimunkGrSy77ELNAHlJmtOSkq8hu1C5/k6LW0GvPHuC
1067faPFEevCmxbERVZnt1f9IQ2e77oZz752cNzDlUIKyy5v3LpGaL8vT1bLAFuHT9z/
10683mlqEwyK7mQlS3LZvwJQ6NfL2lgr5uVDFdsvfAY4mhbV8uRjKj+IZnOV1WYqQ62o
1069xbjC/NKXbvqKBigOhbo+idk1sjKbkjm2uvyjmUszRpfh7YX2wkk3UqZgN1+zsRDK
1070MBMyuZkkr7Vb/8ed07SN8Ma64fwCrrQba4l/R8TJmQpXAgMBAAGjbzBtMB0GA1Ud
1071DgQWBBT8LxETkCZh4h6qjMlLJMooNHTgkTAfBgNVHSMEGDAWgBT8LxETkCZh4h6q
1072jMlLJMooNHTgkTAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCCWxvY2FsaG9z
1073dIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEACjZ8oqjFooFxjS3BnbhrNrF29/Jv
1074PbX32Tg3+3qUkS5+XnO64mLm+pQzUGs16+TyqdEkck//51KkyvzrnnGRYGc5eHEQ
1075zorkR1zlE+c8sjKcenvVkkLEKWaWNtEvpb+U0Ps6rP2Y1Jo4/AxTuxXrYxQ+XSTy
1076V4HyKriK6utlmhGpKUZhTZPTiTC/GaAwimCFgfw4wDuWGow92z3AnR9Q3KFpgrTP
1077B5z+i0oiNv6GpalGq3oe1ucKt+fduYWsC2Vea/PObZowciqbsA0mv3oHlyT9jPFT
1078hY9YjeYgUtEnf0BlrUrgbpd9DnVd5TNU0nDbPC7bv/yu8nF1nKUFWa2nsw==
1079-----END CERTIFICATE-----";
1080 const SELF_SIGNED_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
1081MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCtBOh4EAP48fjE
108259F+L9qNEp/yUlOJXYJbm6m4nzTg00RNc+dqsfrObIWJDuAaiKimunkGrSy77ELN
1083AHlJmtOSkq8hu1C5/k6LW0GvPHuCfaPFEevCmxbERVZnt1f9IQ2e77oZz752cNzD
1084lUIKyy5v3LpGaL8vT1bLAFuHT9z/3mlqEwyK7mQlS3LZvwJQ6NfL2lgr5uVDFdsv
1085fAY4mhbV8uRjKj+IZnOV1WYqQ62oxbjC/NKXbvqKBigOhbo+idk1sjKbkjm2uvyj
1086mUszRpfh7YX2wkk3UqZgN1+zsRDKMBMyuZkkr7Vb/8ed07SN8Ma64fwCrrQba4l/
1087R8TJmQpXAgMBAAECggEAAk8G9RctnmRIMARx4K+tyGUfukGL+NDFHQjSNnL1Zyya
1088hDgQNfXDBX8gNwh6SBBbw8HIPKUR7D4GVCr181v8B8AqUxZnSNwSWzyv/zEc6sxX
1089Y5lOHo4oOx07vm2NYITQ5DaJsq95eKYf5AI5W+CDMZ3t5GOgbXavD01la0RPDCD7
1090d+H9WI7RiKlCaiD174FQfSSwcAHpesrUcopPxMfZzpjxYClGdmMp7/RTmSVg8jex
1091eGceJvZujmjTnYczIce0Ibtozbq91qbwro32U2wbkvNpbU8GTG+st6nRlNRGmHeF
1092AJnOw+CiY9x7KaG4ZhsEY4VRk8YRJo/cLrPcx87JwQKBgQDv76cDYUgFzFXKWH+1
1093hc+oTLuUcn+X6E3ljvMKk9P4nQDgTRDxx5bBm6lHVv3IZoszi60Hvzblqr2HIO5S
1094Gl9KVBkHCLaYc8ny4rYQKVjKLA2/TnDE8Y8FhFnZTpBeEWhb2axQE5zb8WA6Ku6L
1095gEl04OSHjMlqAWt2Va5PZnFQQQKBgQC4mlfFFkfu92RKkYfhXkXUd5psSL4/1C1S
1096wYnqyL7rmAMmKO+y2MdnS1SAwSFGtmexibEcpDu8OASPQoovy4O5De+p/wL7v7aJ
1097+X2J9zaM2ggQN3tYz/HWCdCSpZJy+ufHtLwW9ESu0wW2G0ESRUxtEKvmBB/b/nrO
1098pK7VWxW0lwKBgQCWPG1LRIKgfs3JIZj1xI++Ri2+SeNy7ta3wsaT/PRhW43M5PST
1099L/JJ0HoyXVoTPYI0CGWT0DtDm6GJFymi5zh7hiUVrnMHCpmNKD/v5rPeA6+n9inO
1100Z6KyRaks1HC5NhUuTiIDEgTKA13JjlBHsVBNivQNnC4R3km3kvbOaMrTAQKBgAoR
11016U3H/F6NwjvLGoVxtg90Asl7Yl1q/pnwEszq7Hc/kJRpUUIJTz9UPaTUZDNOSfPG
1102VhIA531J9P23nIAk8ueKWhOE5K3E9HksUevPv3sJfb0cua7LkR6i5GzLeWSqSTB8
1103rHH4GzMKMdqQPAl6HEQqz6W5fd9rT1msZBkhYdq7AoGBAJFTgwSK84D707FxGASw
1104SyuZBVIVd3iF341tsgX48Q1SVq70Uu6AQ0qPJHyxk6pe8aCiermlvVX26nqSQqr7
1105RrpkOaRQNnAfmLmSHvHWZmErDzlsl7pKdIByHK5nx1ccE8xspPEfHsg00E/SWWD+
1106CQR0IwmxMNda1bOi/AL4rcN3
1107-----END PRIVATE KEY-----";
1108
1109 use anyhow::Context as _;
1110
1111 let identity = native_tls::Identity::from_pkcs8(SELF_SIGNED_CERT_PEM, SELF_SIGNED_KEY_PEM)?;
1112 let acceptor = native_tls::TlsAcceptor::new(identity)?;
1113 let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
1114 let address = listener.local_addr()?;
1115 let server = std::thread::spawn(move || {
1116 if let Ok((socket, _)) = listener.accept() {
1117 drop(acceptor.accept(socket));
1120 }
1121 });
1122
1123 let client = reqwest::Client::builder().no_proxy().build()?;
1124 let result = client
1125 .get(format!("https://localhost:{}", address.port()))
1126 .send()
1127 .await;
1128 let Err(error) = result else {
1129 anyhow::bail!("self-signed certificate unexpectedly accepted")
1130 };
1131 assert_eq!(classify_reqwest_error(&error), StreamErrorKind::ServerError);
1132 server
1133 .join()
1134 .ok()
1135 .context("TLS test server thread panicked")?;
1136 Ok(())
1137 }
1138
1139 #[tokio::test]
1140 async fn classifies_premature_http_eof_as_connection_lost() -> anyhow::Result<()> {
1141 use tokio::io::AsyncWriteExt as _;
1142
1143 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1144 let address = listener.local_addr()?;
1145 let server = tokio::spawn(async move {
1146 let (mut socket, _) = listener.accept().await?;
1147 socket
1148 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nx")
1149 .await?;
1150 anyhow::Ok(())
1151 });
1152
1153 let response = reqwest::Client::new()
1154 .get(format!("http://{address}"))
1155 .send()
1156 .await?;
1157 let Err(error) = response.bytes().await else {
1158 anyhow::bail!("truncated HTTP body unexpectedly completed")
1159 };
1160 let StreamDelta::Error { kind, .. } = reqwest_body_error_delta("stream error", &error)
1161 else {
1162 anyhow::bail!("body error helper did not return an error delta")
1163 };
1164 assert_eq!(kind, StreamErrorKind::ConnectionLost);
1165 server.await??;
1166 Ok(())
1167 }
1168
1169 #[cfg(any(feature = "openai", feature = "openai-codex"))]
1170 #[test]
1171 fn test_sse_line_buffer_splits_multiple_lines() {
1172 let mut buf = SseLineBuffer::new();
1173 buf.extend(b"data: one\ndata: two\n");
1174 assert_eq!(buf.next_line().as_deref(), Some("data: one"));
1175 assert_eq!(buf.next_line().as_deref(), Some("data: two"));
1176 assert_eq!(buf.next_line(), None);
1177 }
1178
1179 #[cfg(any(feature = "openai", feature = "openai-codex"))]
1180 #[test]
1181 fn test_sse_line_buffer_buffers_partial_line_until_newline() {
1182 let mut buf = SseLineBuffer::new();
1183 buf.extend(b"data: par");
1184 assert_eq!(buf.next_line(), None);
1185 buf.extend(b"tial\n");
1186 assert_eq!(buf.next_line().as_deref(), Some("data: partial"));
1187 }
1188
1189 #[cfg(any(feature = "openai", feature = "openai-codex"))]
1190 #[test]
1191 fn test_sse_line_buffer_handles_utf8_split_across_chunks() {
1192 let mut buf = SseLineBuffer::new();
1197 let line = "data: café\n";
1198 let bytes = line.as_bytes();
1199 let split = bytes.len() - 2; buf.extend(&bytes[..split]);
1201 assert_eq!(buf.next_line(), None);
1202 buf.extend(&bytes[split..]);
1203 assert_eq!(buf.next_line().as_deref(), Some("data: café"));
1204 }
1205}