Skip to main content

agent_sdk_providers/
streaming.rs

1//! Streaming types for LLM responses.
2//!
3//! This module provides types for handling streaming responses from LLM providers.
4//! The [`StreamDelta`] enum represents individual events in a streaming response,
5//! and [`StreamAccumulator`] helps collect these events into a final response.
6
7use 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
15/// Upper bound on the block index [`StreamAccumulator`] will materialize.
16///
17/// `block_index` is taken verbatim from provider wire data (the SSE `index`
18/// field) and `base_url` is user-configurable (any OpenAI-compatible endpoint),
19/// so a corrupted or hostile event carrying a huge index could otherwise drive
20/// an unbounded `Vec` allocation and exhaust host memory. Text/thinking deltas
21/// whose index exceeds this bound are dropped with a warning rather than grown
22/// into.
23const MAX_BLOCK_INDEX: usize = 4096;
24
25/// Incremental splitter for line-delimited SSE byte streams.
26///
27/// `reqwest`'s `bytes_stream` yields arbitrary byte boundaries, so a multi-byte
28/// UTF-8 character can land split across two network chunks. Decoding each raw
29/// chunk independently with `String::from_utf8_lossy` permanently corrupts such
30/// characters into `U+FFFD` in user-visible text deltas. This buffer instead
31/// accumulates raw bytes and only UTF-8-decodes *complete* lines (terminated by
32/// `\n`); because a newline byte (`0x0A`) can never be part of a multi-byte
33/// UTF-8 sequence, the end of a complete line is always a valid character
34/// boundary and decodes losslessly.
35///
36/// It also avoids the quadratic `buffer = buffer[pos + 1..].to_string()` copy of
37/// the naive splitter: [`BytesMut::split_to`] advances the read cursor without
38/// copying the unconsumed tail, so splitting is amortized O(1) per line instead
39/// of O(remaining-buffer).
40#[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    /// Create an empty buffer.
49    #[must_use]
50    pub(crate) fn new() -> Self {
51        Self::default()
52    }
53
54    /// Append a freshly received network chunk.
55    pub(crate) fn extend(&mut self, chunk: &[u8]) {
56        self.buf.extend_from_slice(chunk);
57    }
58
59    /// Pop the next complete line (without its trailing `\n`), or `None` when no
60    /// full line is buffered yet. Incomplete trailing bytes — including a
61    /// multi-byte character split across a chunk boundary — stay buffered for the
62    /// next call.
63    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/// Events yielded during streaming LLM responses.
72///
73/// Each variant represents a different type of event that can occur
74/// during a streaming response from an LLM provider.
75#[derive(Debug, Clone)]
76#[non_exhaustive]
77pub enum StreamDelta {
78    /// A text delta for streaming text content.
79    TextDelta {
80        /// The text fragment to append
81        delta: String,
82        /// Index of the content block being streamed
83        block_index: usize,
84    },
85
86    /// A thinking delta for streaming thinking/reasoning content.
87    ThinkingDelta {
88        /// The thinking fragment to append
89        delta: String,
90        /// Index of the content block being streamed
91        block_index: usize,
92    },
93
94    /// Start of a tool use block (name and id are known).
95    ToolUseStart {
96        /// Unique identifier for this tool call
97        id: String,
98        /// Name of the tool being called
99        name: String,
100        /// Index of the content block
101        block_index: usize,
102        /// Optional thought signature (used by Gemini 3.x models)
103        thought_signature: Option<String>,
104    },
105
106    /// Incremental JSON for tool input (partial/incomplete JSON).
107    ToolInputDelta {
108        /// Tool call ID this delta belongs to
109        id: String,
110        /// JSON fragment to append
111        delta: String,
112        /// Index of the content block
113        block_index: usize,
114    },
115
116    /// Usage information (typically at stream end).
117    Usage(Usage),
118
119    /// Stream completed with stop reason.
120    Done {
121        /// Why the stream ended
122        stop_reason: Option<StopReason>,
123    },
124
125    /// A signature delta for a thinking block.
126    SignatureDelta {
127        /// The signature fragment to append
128        delta: String,
129        /// Index of the content block being streamed
130        block_index: usize,
131    },
132
133    /// A redacted thinking block received at `content_block_start`.
134    RedactedThinking {
135        /// Opaque data payload
136        data: String,
137        /// Index of the content block
138        block_index: usize,
139    },
140
141    /// A complete provider-owned reasoning-state item.
142    ///
143    /// Unlike text/thinking deltas this item is not user-visible and must not
144    /// be interpreted. It is carried through the stream solely so agent
145    /// history can replay it to the provider that owns it.
146    OpaqueReasoning {
147        /// Provider protocol that owns the payload.
148        provider: String,
149        /// Exact provider response item to preserve.
150        data: serde_json::Value,
151        /// Index used to retain the provider's output-item ordering.
152        block_index: usize,
153    },
154
155    /// Error during streaming.
156    Error {
157        /// Error message
158        message: String,
159        /// Categorization of the error so downstream consumers can map
160        /// it back to the correct [`agent_sdk_foundation::llm::ChatOutcome`]
161        /// variant or audit-record `TurnAttemptOutcome` without losing
162        /// the rate-limit / server-error / invalid-request distinction.
163        kind: StreamErrorKind,
164    },
165}
166
167/// Classification of a [`StreamDelta::Error`] event.
168///
169/// Mirrors [`ChatOutcome`](agent_sdk_foundation::llm::ChatOutcome)'s error
170/// variants so providers that emit errors via streaming preserve the
171/// same precision that non-streaming `chat()` callers see — every
172/// supported provider can map its underlying error (HTTP status,
173/// validation failure, mid-stream disconnect) directly onto one of
174/// these categories at the construction site.
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176#[non_exhaustive]
177pub enum StreamErrorKind {
178    /// The request could not establish a provider connection because DNS,
179    /// routing, or the network is unavailable. Callers may wait indefinitely
180    /// for connectivity, provided cancellation remains cooperative.
181    Connectivity,
182    /// An established provider response stream lost its underlying network
183    /// connection. It has the same wait policy as [`Self::Connectivity`], but
184    /// durable runtimes must close that provider call's audit attempt before
185    /// retrying it.
186    ConnectionLost,
187    /// Provider returned HTTP 429 / explicit rate-limit signal.
188    ///
189    /// Carries the server-supplied retry delay when the provider gave one —
190    /// a `Retry-After` header, or a hint embedded in the error body (Gemini's
191    /// `google.rpc.RetryInfo`, `OpenAI`'s "try again in 20s"). `None` when the
192    /// provider supplied no usable hint, in which case callers use backoff.
193    RateLimited(Option<Duration>),
194    /// Provider returned HTTP 5xx or reported a transient runtime failure.
195    ServerError,
196    /// Caller-side error: validation failure before dispatch, HTTP
197    /// 4xx other than 429, or a non-retriable provider rejection.
198    InvalidRequest,
199    /// Escape hatch for a streaming error a provider could not classify
200    /// into one of the categories above.
201    ///
202    /// Producers should prefer a specific variant whenever the
203    /// underlying signal (HTTP status, validation failure, mid-stream
204    /// disconnect) allows it; `Unknown` exists so future error sources
205    /// and providers can be added without a breaking change. It is
206    /// treated as non-recoverable by [`StreamErrorKind::is_recoverable`]
207    /// (callers should not blindly retry an unclassified failure).
208    Unknown,
209}
210
211impl StreamErrorKind {
212    /// `true` when the error is potentially transient and the caller
213    /// may retry. Connectivity, rate-limit, and server errors are
214    /// recoverable; invalid-request is not.
215    #[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    /// The server-supplied retry delay carried by a rate-limit error, if any.
224    #[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    /// `true` for failures governed by the unbounded, cancellable offline wait.
233    #[must_use]
234    pub const fn is_connectivity(self) -> bool {
235        matches!(self, Self::Connectivity | Self::ConnectionLost)
236    }
237}
238
239/// Classify a typed HTTP client failure without relying on display text.
240#[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
270/// `true` when a TLS peer answered the handshake and rejected the session
271/// (certificate validation, hostname mismatch, protocol or cipher
272/// negotiation, an interception proxy presenting the wrong identity, …).
273///
274/// A peer that speaks TLS at us is reachable, so these are bounded server
275/// errors, never connectivity waits: retrying cannot fix a policy or
276/// configuration rejection, and misreading one as "offline" would park the
277/// caller in an indefinite wait on a failure that is deterministic. The one
278/// exception is a TLS-wrapped *transport* death — a socket that EOFs or
279/// resets mid-handshake — which stays on the connectivity path.
280fn 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
343/// Type alias for a boxed stream of stream deltas.
344pub type StreamBox<'a> = Pin<Box<dyn Stream<Item = anyhow::Result<StreamDelta>> + Send + 'a>>;
345
346/// Sum two usage readings, saturating so token counters never wrap.
347fn 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/// Preserves usage across a stream-splicing wrapper's attempt boundary.
364///
365/// A wrapper that abandons one inner stream and splices in another (failover,
366/// credential refresh) has a hazard: [`StreamAccumulator`] keeps only the LAST
367/// `Usage` delta it sees, so the retried stream's usage would erase the
368/// abandoned one — silently un-billing tokens the provider already charged for.
369///
370/// The carry closes that: on every outgoing `Usage` delta, the wrapper calls
371/// [`running_total`](Self::running_total) to rewrite it to the sum of all
372/// abandoned attempts plus this stream's latest reading; when it abandons a
373/// stream to retry, it calls [`abandon`](Self::abandon) to fold that stream's
374/// usage into the carry. The final delta the consumer sees — the only one the
375/// accumulator keeps — is therefore the true total across every attempt.
376#[derive(Default)]
377pub(crate) struct UsageCarry {
378    /// Usage billed by abandoned attempts.
379    carried: Option<Usage>,
380    /// The current attempt's latest usage reading (last-wins within an attempt).
381    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    /// Record `usage` as the current attempt's reading and return the running
393    /// total (abandoned attempts + this reading) to yield in its place.
394    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    /// Fold the current attempt's usage into the carry because its stream is
401    /// being abandoned for a retry.
402    pub(crate) fn abandon(&mut self) {
403        if let Some(current) = self.current.take() {
404            self.carried = Some(add_usage(self.carried.as_ref(), &current));
405        }
406    }
407}
408
409/// Helper to accumulate streamed content into a final response.
410///
411/// This struct collects [`StreamDelta`] events and can convert them
412/// into the final content blocks once the stream is complete.
413#[derive(Debug, Default)]
414pub struct StreamAccumulator {
415    /// Accumulated text for each block index
416    text_blocks: Vec<String>,
417    /// Accumulated thinking blocks for each block index
418    thinking_blocks: Vec<String>,
419    /// Accumulated signatures keyed by block index
420    thinking_signatures: HashMap<usize, String>,
421    /// Redacted thinking blocks: (`block_index`, data)
422    redacted_thinking_blocks: Vec<(usize, String)>,
423    /// Provider-owned opaque reasoning: (`block_index`, provider, data)
424    opaque_reasoning_blocks: Vec<(usize, String, serde_json::Value)>,
425    /// Accumulated tool use calls
426    tool_uses: Vec<ToolUseAccumulator>,
427    /// Usage information from the stream
428    usage: Option<Usage>,
429    /// Stop reason from the stream
430    stop_reason: Option<StopReason>,
431}
432
433/// Accumulator for a single tool use during streaming.
434#[derive(Debug, Default)]
435pub struct ToolUseAccumulator {
436    /// Tool call ID
437    pub id: String,
438    /// Tool name
439    pub name: String,
440    /// Accumulated JSON input (may be incomplete during streaming)
441    pub input_json: String,
442    /// Block index for ordering
443    pub block_index: usize,
444    /// Optional thought signature (used by Gemini 3.x models)
445    pub thought_signature: Option<String>,
446}
447
448impl StreamAccumulator {
449    /// Create a new empty accumulator.
450    #[must_use]
451    pub fn new() -> Self {
452        Self::default()
453    }
454
455    /// Apply a stream delta to the accumulator.
456    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    /// Get the accumulated usage information.
530    #[must_use]
531    pub const fn usage(&self) -> Option<&Usage> {
532        self.usage.as_ref()
533    }
534
535    /// Get the stop reason.
536    #[must_use]
537    pub const fn stop_reason(&self) -> Option<&StopReason> {
538        self.stop_reason.as_ref()
539    }
540
541    /// Convert accumulated content to `ContentBlock`s.
542    ///
543    /// This consumes the accumulator and returns the final content blocks.
544    /// Tool use JSON is parsed at this point; invalid JSON results in a null input.
545    #[must_use]
546    pub fn into_content_blocks(self) -> Vec<ContentBlock> {
547        let mut blocks: Vec<(usize, ContentBlock)> = Vec::new();
548
549        // Add thinking blocks with their indices, attaching signatures
550        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        // Add redacted thinking blocks
565        for (idx, data) in self.redacted_thinking_blocks {
566            blocks.push((idx, ContentBlock::RedactedThinking { data }));
567        }
568
569        // Add provider-owned reasoning state without interpreting its payload.
570        for (idx, provider, data) in self.opaque_reasoning_blocks {
571            blocks.push((idx, ContentBlock::OpaqueReasoning { provider, data }));
572        }
573
574        // Add text blocks with their indices
575        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        // Add tool uses with their indices
582        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        // Sort by block index to maintain order
608        blocks.sort_by_key(|(idx, _)| *idx);
609
610        blocks.into_iter().map(|(_, block)| block).collect()
611    }
612
613    /// Take ownership of accumulated usage.
614    pub const fn take_usage(&mut self) -> Option<Usage> {
615        self.usage.take()
616    }
617
618    /// Take ownership of stop reason.
619    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        // If no ToolInputDelta is received (e.g., stream interrupted or
821        // deltas had mismatched IDs), the tool use block should still be
822        // produced with an empty object so that the error is attributable
823        // to the tool rather than silently lost.
824        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        // No ToolInputDelta applied
833
834        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        // If ToolInputDelta has a different ID than any ToolUseStart,
848        // the input is silently dropped (the tool gets empty {}).
849        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        // Delta with wrong ID
858        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                // Input should be empty because the delta had a mismatched ID
869                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        // A hostile/corrupted event with a huge block_index must not drive an
903        // unbounded Vec allocation. The delta is dropped, leaving the accumulator
904        // tiny rather than allocating billions of empty Strings.
905        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    /// A live TLS peer that fails certificate validation is a policy
991    /// rejection, not an outage — waiting for connectivity cannot fix it, so
992    /// it must stay on the bounded path. The server presents a self-signed
993    /// certificate the client refuses; the assertion also pins the
994    /// `native_tls::Error` downcast in `is_tls_rejection` against a version
995    /// drift between reqwest's native-tls and the workspace's.
996    #[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                // The client aborts after rejecting the certificate, so the
1055                // server-side handshake result is an expected error.
1056                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        // "café" — the 'é' is the two bytes 0xC3 0xA9. Split the chunk boundary
1130        // *inside* that character: the naive per-chunk from_utf8_lossy would emit
1131        // a U+FFFD replacement char; the line buffer must decode it losslessly
1132        // because it only decodes the complete line.
1133        let mut buf = SseLineBuffer::new();
1134        let line = "data: café\n";
1135        let bytes = line.as_bytes();
1136        let split = bytes.len() - 2; // between 0xC3 and 0xA9
1137        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}