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 },
124
125 SignatureDelta {
127 delta: String,
129 block_index: usize,
131 },
132
133 RedactedThinking {
135 data: String,
137 block_index: usize,
139 },
140
141 OpaqueReasoning {
147 provider: String,
149 data: serde_json::Value,
151 block_index: usize,
153 },
154
155 Error {
157 message: String,
159 kind: StreamErrorKind,
164 },
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176#[non_exhaustive]
177pub enum StreamErrorKind {
178 Connectivity,
182 ConnectionLost,
187 RateLimited(Option<Duration>),
194 ServerError,
196 InvalidRequest,
199 Unknown,
209}
210
211impl StreamErrorKind {
212 #[must_use]
216 pub const fn is_recoverable(self) -> bool {
217 matches!(
218 self,
219 Self::Connectivity | Self::ConnectionLost | Self::RateLimited(_) | Self::ServerError
220 )
221 }
222
223 #[must_use]
225 pub const fn retry_after(self) -> Option<Duration> {
226 match self {
227 Self::RateLimited(retry_after) => retry_after,
228 _ => None,
229 }
230 }
231
232 #[must_use]
234 pub const fn is_connectivity(self) -> bool {
235 matches!(self, Self::Connectivity | Self::ConnectionLost)
236 }
237
238 #[must_use]
251 pub const fn wire_label(self) -> &'static str {
252 match self {
253 Self::Connectivity => "connectivity",
254 Self::ConnectionLost => "connection_lost",
255 Self::RateLimited(_) => "rate_limited",
256 Self::ServerError => "server_error",
257 Self::InvalidRequest => "invalid_request",
258 Self::Unknown => "unknown",
259 }
260 }
261}
262
263#[must_use]
265pub fn classify_reqwest_error(error: &reqwest::Error) -> StreamErrorKind {
266 if is_proxy_tunnel_rejection(error) || is_tls_rejection(error) {
267 StreamErrorKind::ServerError
268 } else if error.is_connect() {
269 StreamErrorKind::Connectivity
270 } else if error.is_timeout() || has_connectivity_io_source(error) {
271 StreamErrorKind::ConnectionLost
272 } else {
273 StreamErrorKind::ServerError
274 }
275}
276
277fn is_proxy_tunnel_rejection(error: &reqwest::Error) -> bool {
278 if error.status() == Some(reqwest::StatusCode::PROXY_AUTHENTICATION_REQUIRED) {
279 return true;
280 }
281 let mut source = std::error::Error::source(error);
282 while let Some(cause) = source {
283 let message = cause.to_string();
284 if message.contains("tunnel error: unsuccessful")
285 || message.contains("proxy authorization required")
286 {
287 return true;
288 }
289 source = cause.source();
290 }
291 false
292}
293
294fn is_tls_rejection(error: &reqwest::Error) -> bool {
305 if has_connectivity_io_source(error) {
306 return false;
307 }
308 let mut source = std::error::Error::source(error);
309 while let Some(cause) = source {
310 if cause.downcast_ref::<native_tls::Error>().is_some() {
311 let message = cause.to_string().to_ascii_lowercase();
312 let transport_death = ["eof", "close", "reset", "broken pipe", "timed out"]
313 .iter()
314 .any(|marker| message.contains(marker));
315 return !transport_death;
316 }
317 source = cause.source();
318 }
319 false
320}
321
322fn has_connectivity_io_source(error: &reqwest::Error) -> bool {
323 let mut source = std::error::Error::source(error);
324 while let Some(cause) = source {
325 if let Some(io_error) = cause.downcast_ref::<std::io::Error>()
326 && matches!(
327 io_error.kind(),
328 std::io::ErrorKind::NotConnected
329 | std::io::ErrorKind::ConnectionRefused
330 | std::io::ErrorKind::ConnectionReset
331 | std::io::ErrorKind::ConnectionAborted
332 | std::io::ErrorKind::BrokenPipe
333 | std::io::ErrorKind::UnexpectedEof
334 | std::io::ErrorKind::TimedOut
335 | std::io::ErrorKind::NetworkDown
336 | std::io::ErrorKind::NetworkUnreachable
337 | std::io::ErrorKind::HostUnreachable
338 )
339 {
340 return true;
341 }
342 source = cause.source();
343 }
344 false
345}
346
347#[must_use]
348pub fn reqwest_error_delta(context: &str, error: &reqwest::Error) -> StreamDelta {
349 StreamDelta::Error {
350 message: format!("{context}: {error}"),
351 kind: classify_reqwest_error(error),
352 }
353}
354
355#[must_use]
356pub fn reqwest_body_error_delta(context: &str, error: &reqwest::Error) -> StreamDelta {
357 let kind = match classify_reqwest_error(error) {
358 StreamErrorKind::Connectivity => StreamErrorKind::ConnectionLost,
359 other => other,
360 };
361 StreamDelta::Error {
362 message: format!("{context}: {error}"),
363 kind,
364 }
365}
366
367pub type StreamBox<'a> = Pin<Box<dyn Stream<Item = anyhow::Result<StreamDelta>> + Send + 'a>>;
369
370fn add_usage(carried: Option<&Usage>, usage: &Usage) -> Usage {
372 let Some(carried) = carried else {
373 return usage.clone();
374 };
375 Usage {
376 input_tokens: carried.input_tokens.saturating_add(usage.input_tokens),
377 output_tokens: carried.output_tokens.saturating_add(usage.output_tokens),
378 cached_input_tokens: carried
379 .cached_input_tokens
380 .saturating_add(usage.cached_input_tokens),
381 cache_creation_input_tokens: carried
382 .cache_creation_input_tokens
383 .saturating_add(usage.cache_creation_input_tokens),
384 }
385}
386
387#[derive(Default)]
401pub(crate) struct UsageCarry {
402 carried: Option<Usage>,
404 current: Option<Usage>,
406}
407
408impl UsageCarry {
409 pub(crate) const fn new() -> Self {
410 Self {
411 carried: None,
412 current: None,
413 }
414 }
415
416 pub(crate) fn running_total(&mut self, usage: Usage) -> Usage {
419 let total = add_usage(self.carried.as_ref(), &usage);
420 self.current = Some(usage);
421 total
422 }
423
424 pub(crate) fn abandon(&mut self) {
427 if let Some(current) = self.current.take() {
428 self.carried = Some(add_usage(self.carried.as_ref(), ¤t));
429 }
430 }
431}
432
433#[derive(Debug, Default)]
438pub struct StreamAccumulator {
439 text_blocks: Vec<String>,
441 thinking_blocks: Vec<String>,
443 thinking_signatures: HashMap<usize, String>,
445 redacted_thinking_blocks: Vec<(usize, String)>,
447 opaque_reasoning_blocks: Vec<(usize, String, serde_json::Value)>,
449 tool_uses: Vec<ToolUseAccumulator>,
451 usage: Option<Usage>,
453 stop_reason: Option<StopReason>,
455}
456
457#[derive(Debug, Default)]
459pub struct ToolUseAccumulator {
460 pub id: String,
462 pub name: String,
464 pub input_json: String,
466 pub block_index: usize,
468 pub thought_signature: Option<String>,
470}
471
472impl StreamAccumulator {
473 #[must_use]
475 pub fn new() -> Self {
476 Self::default()
477 }
478
479 pub fn apply(&mut self, delta: &StreamDelta) {
481 match delta {
482 StreamDelta::TextDelta { delta, block_index } => {
483 if *block_index > MAX_BLOCK_INDEX {
484 log::warn!(
485 "dropping TextDelta with out-of-range block_index {block_index} (max {MAX_BLOCK_INDEX})"
486 );
487 return;
488 }
489 while self.text_blocks.len() <= *block_index {
490 self.text_blocks.push(String::new());
491 }
492 self.text_blocks[*block_index].push_str(delta);
493 }
494 StreamDelta::ThinkingDelta { delta, block_index } => {
495 if *block_index > MAX_BLOCK_INDEX {
496 log::warn!(
497 "dropping ThinkingDelta with out-of-range block_index {block_index} (max {MAX_BLOCK_INDEX})"
498 );
499 return;
500 }
501 while self.thinking_blocks.len() <= *block_index {
502 self.thinking_blocks.push(String::new());
503 }
504 self.thinking_blocks[*block_index].push_str(delta);
505 }
506 StreamDelta::ToolUseStart {
507 id,
508 name,
509 block_index,
510 thought_signature,
511 } => {
512 self.tool_uses.push(ToolUseAccumulator {
513 id: id.clone(),
514 name: name.clone(),
515 input_json: String::new(),
516 block_index: *block_index,
517 thought_signature: thought_signature.clone(),
518 });
519 }
520 StreamDelta::ToolInputDelta { id, delta, .. } => {
521 if let Some(tool) = self.tool_uses.iter_mut().find(|t| t.id == *id) {
522 tool.input_json.push_str(delta);
523 }
524 }
525 StreamDelta::SignatureDelta { delta, block_index } => {
526 self.thinking_signatures
527 .entry(*block_index)
528 .or_default()
529 .push_str(delta);
530 }
531 StreamDelta::RedactedThinking { data, block_index } => {
532 self.redacted_thinking_blocks
533 .push((*block_index, data.clone()));
534 }
535 StreamDelta::OpaqueReasoning {
536 provider,
537 data,
538 block_index,
539 } => {
540 self.opaque_reasoning_blocks
541 .push((*block_index, provider.clone(), data.clone()));
542 }
543 StreamDelta::Usage(u) => {
544 self.usage = Some(u.clone());
545 }
546 StreamDelta::Done { stop_reason } => {
547 self.stop_reason = *stop_reason;
548 }
549 StreamDelta::Error { .. } => {}
550 }
551 }
552
553 #[must_use]
555 pub const fn usage(&self) -> Option<&Usage> {
556 self.usage.as_ref()
557 }
558
559 #[must_use]
561 pub const fn stop_reason(&self) -> Option<&StopReason> {
562 self.stop_reason.as_ref()
563 }
564
565 #[must_use]
570 pub fn into_content_blocks(self) -> Vec<ContentBlock> {
571 let mut blocks: Vec<(usize, ContentBlock)> = Vec::new();
572
573 let mut signatures = self.thinking_signatures;
575 for (idx, thinking) in self.thinking_blocks.into_iter().enumerate() {
576 if !thinking.is_empty() {
577 let signature = signatures.remove(&idx).filter(|s| !s.is_empty());
578 blocks.push((
579 idx,
580 ContentBlock::Thinking {
581 thinking,
582 signature,
583 },
584 ));
585 }
586 }
587
588 for (idx, data) in self.redacted_thinking_blocks {
590 blocks.push((idx, ContentBlock::RedactedThinking { data }));
591 }
592
593 for (idx, provider, data) in self.opaque_reasoning_blocks {
595 blocks.push((idx, ContentBlock::OpaqueReasoning { provider, data }));
596 }
597
598 for (idx, text) in self.text_blocks.into_iter().enumerate() {
600 if !text.is_empty() {
601 blocks.push((idx, ContentBlock::Text { text }));
602 }
603 }
604
605 for tool in self.tool_uses {
607 let input: serde_json::Value =
608 serde_json::from_str(&tool.input_json).unwrap_or_else(|e| {
609 log::warn!(
610 "Failed to parse streamed tool input JSON for tool '{}' (id={}): {} — \
611 input_json ({} bytes): '{}'",
612 tool.name,
613 tool.id,
614 e,
615 tool.input_json.len(),
616 tool.input_json.chars().take(500).collect::<String>(),
617 );
618 serde_json::json!({})
619 });
620 blocks.push((
621 tool.block_index,
622 ContentBlock::ToolUse {
623 id: tool.id,
624 name: tool.name,
625 input,
626 thought_signature: tool.thought_signature,
627 },
628 ));
629 }
630
631 blocks.sort_by_key(|(idx, _)| *idx);
633
634 blocks.into_iter().map(|(_, block)| block).collect()
635 }
636
637 pub const fn take_usage(&mut self) -> Option<Usage> {
639 self.usage.take()
640 }
641
642 pub const fn take_stop_reason(&mut self) -> Option<StopReason> {
644 self.stop_reason.take()
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651
652 #[test]
653 fn test_accumulator_text_deltas() {
654 let mut acc = StreamAccumulator::new();
655
656 acc.apply(&StreamDelta::TextDelta {
657 delta: "Hello".to_string(),
658 block_index: 0,
659 });
660 acc.apply(&StreamDelta::TextDelta {
661 delta: " world".to_string(),
662 block_index: 0,
663 });
664
665 let blocks = acc.into_content_blocks();
666 assert_eq!(blocks.len(), 1);
667 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello world"));
668 }
669
670 #[test]
671 fn test_accumulator_multiple_text_blocks() {
672 let mut acc = StreamAccumulator::new();
673
674 acc.apply(&StreamDelta::TextDelta {
675 delta: "First".to_string(),
676 block_index: 0,
677 });
678 acc.apply(&StreamDelta::TextDelta {
679 delta: "Second".to_string(),
680 block_index: 1,
681 });
682
683 let blocks = acc.into_content_blocks();
684 assert_eq!(blocks.len(), 2);
685 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "First"));
686 assert!(matches!(&blocks[1], ContentBlock::Text { text } if text == "Second"));
687 }
688
689 #[test]
690 fn test_accumulator_thinking_signature() {
691 let mut acc = StreamAccumulator::new();
692
693 acc.apply(&StreamDelta::ThinkingDelta {
694 delta: "Reasoning".to_string(),
695 block_index: 0,
696 });
697 acc.apply(&StreamDelta::SignatureDelta {
698 delta: "sig_123".to_string(),
699 block_index: 0,
700 });
701
702 let blocks = acc.into_content_blocks();
703 assert_eq!(blocks.len(), 1);
704 assert!(matches!(
705 &blocks[0],
706 ContentBlock::Thinking { thinking, signature }
707 if thinking == "Reasoning" && signature.as_deref() == Some("sig_123")
708 ));
709 }
710
711 #[test]
712 fn accumulator_preserves_opaque_reasoning_payload_and_order() {
713 let mut acc = StreamAccumulator::new();
714 acc.apply(&StreamDelta::TextDelta {
715 delta: "visible".to_owned(),
716 block_index: 2,
717 });
718 acc.apply(&StreamDelta::OpaqueReasoning {
719 provider: "test-provider".to_owned(),
720 data: serde_json::json!({
721 "id": "reasoning_1",
722 "encrypted_content": "do-not-inspect"
723 }),
724 block_index: 1,
725 });
726
727 let blocks = acc.into_content_blocks();
728 assert_eq!(blocks.len(), 2);
729 assert!(matches!(
730 &blocks[0],
731 ContentBlock::OpaqueReasoning { provider, data }
732 if provider == "test-provider"
733 && data["id"] == "reasoning_1"
734 && data["encrypted_content"] == "do-not-inspect"
735 ));
736 assert!(matches!(
737 &blocks[1],
738 ContentBlock::Text { text } if text == "visible"
739 ));
740 }
741
742 #[test]
743 fn test_accumulator_tool_use() {
744 let mut acc = StreamAccumulator::new();
745
746 acc.apply(&StreamDelta::ToolUseStart {
747 id: "call_123".to_string(),
748 name: "read_file".to_string(),
749 block_index: 0,
750 thought_signature: None,
751 });
752 acc.apply(&StreamDelta::ToolInputDelta {
753 id: "call_123".to_string(),
754 delta: r#"{"path":"#.to_string(),
755 block_index: 0,
756 });
757 acc.apply(&StreamDelta::ToolInputDelta {
758 id: "call_123".to_string(),
759 delta: r#""test.txt"}"#.to_string(),
760 block_index: 0,
761 });
762
763 let blocks = acc.into_content_blocks();
764 assert_eq!(blocks.len(), 1);
765 match &blocks[0] {
766 ContentBlock::ToolUse {
767 id, name, input, ..
768 } => {
769 assert_eq!(id, "call_123");
770 assert_eq!(name, "read_file");
771 assert_eq!(input["path"], "test.txt");
772 }
773 _ => panic!("Expected ToolUse block"),
774 }
775 }
776
777 #[test]
778 fn test_accumulator_mixed_content() {
779 let mut acc = StreamAccumulator::new();
780
781 acc.apply(&StreamDelta::TextDelta {
782 delta: "Let me read that file.".to_string(),
783 block_index: 0,
784 });
785 acc.apply(&StreamDelta::ToolUseStart {
786 id: "call_456".to_string(),
787 name: "read_file".to_string(),
788 block_index: 1,
789 thought_signature: None,
790 });
791 acc.apply(&StreamDelta::ToolInputDelta {
792 id: "call_456".to_string(),
793 delta: r#"{"path":"file.txt"}"#.to_string(),
794 block_index: 1,
795 });
796 acc.apply(&StreamDelta::Usage(Usage {
797 input_tokens: 100,
798 output_tokens: 50,
799 cached_input_tokens: 0,
800 cache_creation_input_tokens: 0,
801 }));
802 acc.apply(&StreamDelta::Done {
803 stop_reason: Some(StopReason::ToolUse),
804 });
805
806 assert!(acc.usage().is_some());
807 assert_eq!(acc.usage().map(|u| u.input_tokens), Some(100));
808 assert!(matches!(acc.stop_reason(), Some(StopReason::ToolUse)));
809
810 let blocks = acc.into_content_blocks();
811 assert_eq!(blocks.len(), 2);
812 assert!(matches!(&blocks[0], ContentBlock::Text { .. }));
813 assert!(matches!(&blocks[1], ContentBlock::ToolUse { .. }));
814 }
815
816 #[test]
817 fn test_accumulator_invalid_tool_json() {
818 let mut acc = StreamAccumulator::new();
819
820 acc.apply(&StreamDelta::ToolUseStart {
821 id: "call_789".to_string(),
822 name: "test_tool".to_string(),
823 block_index: 0,
824 thought_signature: None,
825 });
826 acc.apply(&StreamDelta::ToolInputDelta {
827 id: "call_789".to_string(),
828 delta: "invalid json {".to_string(),
829 block_index: 0,
830 });
831
832 let blocks = acc.into_content_blocks();
833 assert_eq!(blocks.len(), 1);
834 match &blocks[0] {
835 ContentBlock::ToolUse { input, .. } => {
836 assert!(input.is_object());
837 }
838 _ => panic!("Expected ToolUse block"),
839 }
840 }
841
842 #[test]
843 fn test_accumulator_empty_tool_input_falls_back_to_empty_object() {
844 let mut acc = StreamAccumulator::new();
849
850 acc.apply(&StreamDelta::ToolUseStart {
851 id: "call_empty".to_string(),
852 name: "read".to_string(),
853 block_index: 0,
854 thought_signature: None,
855 });
856 let blocks = acc.into_content_blocks();
859 assert_eq!(blocks.len(), 1);
860 match &blocks[0] {
861 ContentBlock::ToolUse { input, name, .. } => {
862 assert_eq!(name, "read");
863 assert_eq!(input, &serde_json::json!({}));
864 }
865 _ => panic!("Expected ToolUse block"),
866 }
867 }
868
869 #[test]
870 fn test_accumulator_mismatched_delta_id_drops_input() {
871 let mut acc = StreamAccumulator::new();
874
875 acc.apply(&StreamDelta::ToolUseStart {
876 id: "call_A".to_string(),
877 name: "bash".to_string(),
878 block_index: 0,
879 thought_signature: None,
880 });
881 acc.apply(&StreamDelta::ToolInputDelta {
883 id: "call_B".to_string(),
884 delta: r#"{"command":"ls"}"#.to_string(),
885 block_index: 0,
886 });
887
888 let blocks = acc.into_content_blocks();
889 assert_eq!(blocks.len(), 1);
890 match &blocks[0] {
891 ContentBlock::ToolUse { input, .. } => {
892 assert_eq!(input, &serde_json::json!({}));
894 }
895 _ => panic!("Expected ToolUse block"),
896 }
897 }
898
899 #[test]
900 fn test_accumulator_empty() {
901 let acc = StreamAccumulator::new();
902 let blocks = acc.into_content_blocks();
903 assert!(blocks.is_empty());
904 }
905
906 #[test]
907 fn test_accumulator_skips_empty_text() {
908 let mut acc = StreamAccumulator::new();
909
910 acc.apply(&StreamDelta::TextDelta {
911 delta: String::new(),
912 block_index: 0,
913 });
914 acc.apply(&StreamDelta::TextDelta {
915 delta: "Hello".to_string(),
916 block_index: 1,
917 });
918
919 let blocks = acc.into_content_blocks();
920 assert_eq!(blocks.len(), 1);
921 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello"));
922 }
923
924 #[test]
925 fn test_accumulator_ignores_out_of_range_block_index() {
926 let mut acc = StreamAccumulator::new();
930
931 acc.apply(&StreamDelta::TextDelta {
932 delta: "ok".to_string(),
933 block_index: 0,
934 });
935 acc.apply(&StreamDelta::TextDelta {
936 delta: "boom".to_string(),
937 block_index: usize::MAX,
938 });
939 acc.apply(&StreamDelta::ThinkingDelta {
940 delta: "boom".to_string(),
941 block_index: usize::MAX,
942 });
943
944 let blocks = acc.into_content_blocks();
945 assert_eq!(blocks.len(), 1);
946 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "ok"));
947 }
948
949 #[tokio::test]
950 async fn classifies_typed_connect_failure_as_connectivity() -> anyhow::Result<()> {
951 let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
952 let address = listener.local_addr()?;
953 drop(listener);
954
955 let result = reqwest::Client::new()
956 .get(format!("http://{address}"))
957 .send()
958 .await;
959 let Err(error) = result else {
960 anyhow::bail!("closed local port unexpectedly accepted a connection")
961 };
962 assert_eq!(
963 classify_reqwest_error(&error),
964 StreamErrorKind::Connectivity
965 );
966 Ok(())
967 }
968
969 #[tokio::test]
970 async fn proxy_tunnel_rejection_is_not_connectivity() -> anyhow::Result<()> {
971 use tokio::io::AsyncWriteExt as _;
972
973 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
974 let address = listener.local_addr()?;
975 let server = tokio::spawn(async move {
976 let (mut socket, _) = listener.accept().await?;
977 socket
978 .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
979 .await?;
980 anyhow::Ok(())
981 });
982 let client = reqwest::Client::builder()
983 .proxy(reqwest::Proxy::all(format!("http://{address}"))?)
984 .build()?;
985 let Err(error) = client.get("https://example.invalid").send().await else {
986 anyhow::bail!("rejected proxy tunnel unexpectedly succeeded")
987 };
988 assert_eq!(classify_reqwest_error(&error), StreamErrorKind::ServerError);
989 server.await??;
990 Ok(())
991 }
992
993 #[tokio::test]
994 async fn tls_handshake_transport_drop_is_connectivity() -> anyhow::Result<()> {
995 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
996 let address = listener.local_addr()?;
997 let server = tokio::spawn(async move {
998 let (socket, _) = listener.accept().await?;
999 drop(socket);
1000 anyhow::Ok(())
1001 });
1002 let client = reqwest::Client::builder().no_proxy().build()?;
1003 let Err(error) = client.get(format!("https://{address}")).send().await else {
1004 anyhow::bail!("dropped TLS handshake unexpectedly succeeded")
1005 };
1006 assert_eq!(
1007 classify_reqwest_error(&error),
1008 StreamErrorKind::Connectivity
1009 );
1010 server.await??;
1011 Ok(())
1012 }
1013
1014 #[tokio::test]
1021 async fn tls_certificate_rejection_is_bounded_server_error() -> anyhow::Result<()> {
1022 const SELF_SIGNED_CERT_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----
1023MIIDJzCCAg+gAwIBAgIUPiG3JI6c72crNdzYks8mo1pmHMEwDQYJKoZIhvcNAQEL
1024BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDcxNDE4NDYzMloYDzIxMjYw
1025NjIwMTg0NjMyWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB
1026AQUAA4IBDwAwggEKAoIBAQCtBOh4EAP48fjE59F+L9qNEp/yUlOJXYJbm6m4nzTg
102700RNc+dqsfrObIWJDuAaiKimunkGrSy77ELNAHlJmtOSkq8hu1C5/k6LW0GvPHuC
1028faPFEevCmxbERVZnt1f9IQ2e77oZz752cNzDlUIKyy5v3LpGaL8vT1bLAFuHT9z/
10293mlqEwyK7mQlS3LZvwJQ6NfL2lgr5uVDFdsvfAY4mhbV8uRjKj+IZnOV1WYqQ62o
1030xbjC/NKXbvqKBigOhbo+idk1sjKbkjm2uvyjmUszRpfh7YX2wkk3UqZgN1+zsRDK
1031MBMyuZkkr7Vb/8ed07SN8Ma64fwCrrQba4l/R8TJmQpXAgMBAAGjbzBtMB0GA1Ud
1032DgQWBBT8LxETkCZh4h6qjMlLJMooNHTgkTAfBgNVHSMEGDAWgBT8LxETkCZh4h6q
1033jMlLJMooNHTgkTAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCCWxvY2FsaG9z
1034dIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEACjZ8oqjFooFxjS3BnbhrNrF29/Jv
1035PbX32Tg3+3qUkS5+XnO64mLm+pQzUGs16+TyqdEkck//51KkyvzrnnGRYGc5eHEQ
1036zorkR1zlE+c8sjKcenvVkkLEKWaWNtEvpb+U0Ps6rP2Y1Jo4/AxTuxXrYxQ+XSTy
1037V4HyKriK6utlmhGpKUZhTZPTiTC/GaAwimCFgfw4wDuWGow92z3AnR9Q3KFpgrTP
1038B5z+i0oiNv6GpalGq3oe1ucKt+fduYWsC2Vea/PObZowciqbsA0mv3oHlyT9jPFT
1039hY9YjeYgUtEnf0BlrUrgbpd9DnVd5TNU0nDbPC7bv/yu8nF1nKUFWa2nsw==
1040-----END CERTIFICATE-----";
1041 const SELF_SIGNED_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
1042MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCtBOh4EAP48fjE
104359F+L9qNEp/yUlOJXYJbm6m4nzTg00RNc+dqsfrObIWJDuAaiKimunkGrSy77ELN
1044AHlJmtOSkq8hu1C5/k6LW0GvPHuCfaPFEevCmxbERVZnt1f9IQ2e77oZz752cNzD
1045lUIKyy5v3LpGaL8vT1bLAFuHT9z/3mlqEwyK7mQlS3LZvwJQ6NfL2lgr5uVDFdsv
1046fAY4mhbV8uRjKj+IZnOV1WYqQ62oxbjC/NKXbvqKBigOhbo+idk1sjKbkjm2uvyj
1047mUszRpfh7YX2wkk3UqZgN1+zsRDKMBMyuZkkr7Vb/8ed07SN8Ma64fwCrrQba4l/
1048R8TJmQpXAgMBAAECggEAAk8G9RctnmRIMARx4K+tyGUfukGL+NDFHQjSNnL1Zyya
1049hDgQNfXDBX8gNwh6SBBbw8HIPKUR7D4GVCr181v8B8AqUxZnSNwSWzyv/zEc6sxX
1050Y5lOHo4oOx07vm2NYITQ5DaJsq95eKYf5AI5W+CDMZ3t5GOgbXavD01la0RPDCD7
1051d+H9WI7RiKlCaiD174FQfSSwcAHpesrUcopPxMfZzpjxYClGdmMp7/RTmSVg8jex
1052eGceJvZujmjTnYczIce0Ibtozbq91qbwro32U2wbkvNpbU8GTG+st6nRlNRGmHeF
1053AJnOw+CiY9x7KaG4ZhsEY4VRk8YRJo/cLrPcx87JwQKBgQDv76cDYUgFzFXKWH+1
1054hc+oTLuUcn+X6E3ljvMKk9P4nQDgTRDxx5bBm6lHVv3IZoszi60Hvzblqr2HIO5S
1055Gl9KVBkHCLaYc8ny4rYQKVjKLA2/TnDE8Y8FhFnZTpBeEWhb2axQE5zb8WA6Ku6L
1056gEl04OSHjMlqAWt2Va5PZnFQQQKBgQC4mlfFFkfu92RKkYfhXkXUd5psSL4/1C1S
1057wYnqyL7rmAMmKO+y2MdnS1SAwSFGtmexibEcpDu8OASPQoovy4O5De+p/wL7v7aJ
1058+X2J9zaM2ggQN3tYz/HWCdCSpZJy+ufHtLwW9ESu0wW2G0ESRUxtEKvmBB/b/nrO
1059pK7VWxW0lwKBgQCWPG1LRIKgfs3JIZj1xI++Ri2+SeNy7ta3wsaT/PRhW43M5PST
1060L/JJ0HoyXVoTPYI0CGWT0DtDm6GJFymi5zh7hiUVrnMHCpmNKD/v5rPeA6+n9inO
1061Z6KyRaks1HC5NhUuTiIDEgTKA13JjlBHsVBNivQNnC4R3km3kvbOaMrTAQKBgAoR
10626U3H/F6NwjvLGoVxtg90Asl7Yl1q/pnwEszq7Hc/kJRpUUIJTz9UPaTUZDNOSfPG
1063VhIA531J9P23nIAk8ueKWhOE5K3E9HksUevPv3sJfb0cua7LkR6i5GzLeWSqSTB8
1064rHH4GzMKMdqQPAl6HEQqz6W5fd9rT1msZBkhYdq7AoGBAJFTgwSK84D707FxGASw
1065SyuZBVIVd3iF341tsgX48Q1SVq70Uu6AQ0qPJHyxk6pe8aCiermlvVX26nqSQqr7
1066RrpkOaRQNnAfmLmSHvHWZmErDzlsl7pKdIByHK5nx1ccE8xspPEfHsg00E/SWWD+
1067CQR0IwmxMNda1bOi/AL4rcN3
1068-----END PRIVATE KEY-----";
1069
1070 use anyhow::Context as _;
1071
1072 let identity = native_tls::Identity::from_pkcs8(SELF_SIGNED_CERT_PEM, SELF_SIGNED_KEY_PEM)?;
1073 let acceptor = native_tls::TlsAcceptor::new(identity)?;
1074 let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
1075 let address = listener.local_addr()?;
1076 let server = std::thread::spawn(move || {
1077 if let Ok((socket, _)) = listener.accept() {
1078 drop(acceptor.accept(socket));
1081 }
1082 });
1083
1084 let client = reqwest::Client::builder().no_proxy().build()?;
1085 let result = client
1086 .get(format!("https://localhost:{}", address.port()))
1087 .send()
1088 .await;
1089 let Err(error) = result else {
1090 anyhow::bail!("self-signed certificate unexpectedly accepted")
1091 };
1092 assert_eq!(classify_reqwest_error(&error), StreamErrorKind::ServerError);
1093 server
1094 .join()
1095 .ok()
1096 .context("TLS test server thread panicked")?;
1097 Ok(())
1098 }
1099
1100 #[tokio::test]
1101 async fn classifies_premature_http_eof_as_connection_lost() -> anyhow::Result<()> {
1102 use tokio::io::AsyncWriteExt as _;
1103
1104 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1105 let address = listener.local_addr()?;
1106 let server = tokio::spawn(async move {
1107 let (mut socket, _) = listener.accept().await?;
1108 socket
1109 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nx")
1110 .await?;
1111 anyhow::Ok(())
1112 });
1113
1114 let response = reqwest::Client::new()
1115 .get(format!("http://{address}"))
1116 .send()
1117 .await?;
1118 let Err(error) = response.bytes().await else {
1119 anyhow::bail!("truncated HTTP body unexpectedly completed")
1120 };
1121 let StreamDelta::Error { kind, .. } = reqwest_body_error_delta("stream error", &error)
1122 else {
1123 anyhow::bail!("body error helper did not return an error delta")
1124 };
1125 assert_eq!(kind, StreamErrorKind::ConnectionLost);
1126 server.await??;
1127 Ok(())
1128 }
1129
1130 #[cfg(any(feature = "openai", feature = "openai-codex"))]
1131 #[test]
1132 fn test_sse_line_buffer_splits_multiple_lines() {
1133 let mut buf = SseLineBuffer::new();
1134 buf.extend(b"data: one\ndata: two\n");
1135 assert_eq!(buf.next_line().as_deref(), Some("data: one"));
1136 assert_eq!(buf.next_line().as_deref(), Some("data: two"));
1137 assert_eq!(buf.next_line(), None);
1138 }
1139
1140 #[cfg(any(feature = "openai", feature = "openai-codex"))]
1141 #[test]
1142 fn test_sse_line_buffer_buffers_partial_line_until_newline() {
1143 let mut buf = SseLineBuffer::new();
1144 buf.extend(b"data: par");
1145 assert_eq!(buf.next_line(), None);
1146 buf.extend(b"tial\n");
1147 assert_eq!(buf.next_line().as_deref(), Some("data: partial"));
1148 }
1149
1150 #[cfg(any(feature = "openai", feature = "openai-codex"))]
1151 #[test]
1152 fn test_sse_line_buffer_handles_utf8_split_across_chunks() {
1153 let mut buf = SseLineBuffer::new();
1158 let line = "data: café\n";
1159 let bytes = line.as_bytes();
1160 let split = bytes.len() - 2; buf.extend(&bytes[..split]);
1162 assert_eq!(buf.next_line(), None);
1163 buf.extend(&bytes[split..]);
1164 assert_eq!(buf.next_line().as_deref(), Some("data: café"));
1165 }
1166}