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